mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
refactor: improve error handling with structured API errors and retry logic
Replace console.error calls with Exception.log, implement typed API errors with automatic retry for transient failures (rate limits, server errors), add error state tracking to conversation content, and enhance system prompt with action-first operating principle, update unit tests
This commit is contained in:
parent
167a2b13a5
commit
18372ce01b
21 changed files with 363 additions and 120 deletions
|
|
@ -15,6 +15,8 @@ import type { SettingsService } from "Services/SettingsService";
|
|||
import type { RawMessageStreamEvent, ContentBlockParam, Tool } from '@anthropic-ai/sdk/resources/messages';
|
||||
import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schemas/AIFunctionTypes";
|
||||
import { StringTools } from "Helpers/StringTools";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiErrorType } from "Types/ApiError";
|
||||
|
||||
export class Claude implements IAIClass {
|
||||
|
||||
|
|
@ -124,7 +126,7 @@ export class Claude implements IAIClass {
|
|||
this.accumulatedFunctionId || undefined
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse accumulated function args:", error);
|
||||
Exception.log(error);
|
||||
}
|
||||
// Reset accumulation for next potential tool use
|
||||
this.accumulatedFunctionName = null;
|
||||
|
|
@ -155,9 +157,13 @@ export class Claude implements IAIClass {
|
|||
shouldContinue: shouldContinue,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown parsing error";
|
||||
console.error("Failed to parse stream chunk:", message, "Chunk:", chunk);
|
||||
return { content: "", isComplete: false, error: `Failed to parse chunk: ${message}` };
|
||||
Exception.log(error);
|
||||
return {
|
||||
content: "",
|
||||
isComplete: true,
|
||||
error: `Failed to parse chunk: ${Exception.messageFrom(error)}`,
|
||||
errorType: ApiErrorType.UNKNOWN
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +200,7 @@ export class Claude implements IAIClass {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse function call:", error);
|
||||
Exception.log(error);
|
||||
// Fall back to treating as text
|
||||
if (contentToExtract.trim() === "") {
|
||||
contentBlocks.push({
|
||||
|
|
@ -204,7 +210,7 @@ export class Claude implements IAIClass {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid JSON in functionCall field");
|
||||
Exception.log(`Invalid JSON in functionCall field:\n${content.functionCall}`);
|
||||
// Fall back to treating as text
|
||||
if (contentToExtract.trim() === "") {
|
||||
contentBlocks.push({
|
||||
|
|
@ -234,14 +240,14 @@ export class Claude implements IAIClass {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse function response:", error);
|
||||
Exception.log(error);
|
||||
contentBlocks.push({
|
||||
type: "text",
|
||||
text: contentToExtract
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid JSON in function response content");
|
||||
Exception.log(`Invalid JSON in function response content:\n${contentToExtract}`);
|
||||
contentBlocks.push({
|
||||
type: "text",
|
||||
text: contentToExtract
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schem
|
|||
import type { Candidate, Part, FunctionDeclaration } from "@google/genai";
|
||||
import { FinishReason } from "@google/genai";
|
||||
import { StringTools } from "Helpers/StringTools";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiErrorType } from "Types/ApiError";
|
||||
|
||||
export class Gemini implements IAIClass {
|
||||
|
||||
|
|
@ -150,9 +152,13 @@ export class Gemini implements IAIClass {
|
|||
shouldContinue: shouldContinue,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown parsing error";
|
||||
console.error("Failed to parse stream chunk:", message, "Chunk:", chunk);
|
||||
return { content: "", isComplete: false, error: `Failed to parse chunk: ${message}` };
|
||||
Exception.log(error);
|
||||
return {
|
||||
content: "",
|
||||
isComplete: true,
|
||||
error: `Failed to parse chunk: ${Exception.messageFrom(error)}`,
|
||||
errorType: ApiErrorType.UNKNOWN
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -178,11 +184,11 @@ export class Gemini implements IAIClass {
|
|||
parts.push({ text: contentToExtract });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse function response:", error);
|
||||
Exception.log(error);
|
||||
parts.push({ text: contentToExtract });
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid JSON in function response content");
|
||||
Exception.log(`Invalid JSON in function response content:\n${contentToExtract}`)
|
||||
parts.push({ text: contentToExtract });
|
||||
}
|
||||
} else {
|
||||
|
|
@ -203,10 +209,10 @@ export class Gemini implements IAIClass {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse function call:", error);
|
||||
Exception.log(error);
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid JSON in functionCall field");
|
||||
Exception.log(`Invalid JSON in functionCall field:\n${content.functionCall}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schem
|
|||
import { StringTools } from "Helpers/StringTools";
|
||||
import type { ResponseEvent, ResponseOutputTextDelta, ResponseFunctionCallArgumentsDone, ResponseDone, OpenAIFunctionTool } from "./OpenAITypes";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiErrorType } from "Types/ApiError";
|
||||
|
||||
export class OpenAI implements IAIClass {
|
||||
|
||||
|
|
@ -170,7 +171,12 @@ export class OpenAI implements IAIClass {
|
|||
};
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return { content: "", isComplete: false, error: Exception.messageFrom(error) };
|
||||
return {
|
||||
content: "",
|
||||
isComplete: true,
|
||||
error: `Failed to parse chunk: ${Exception.messageFrom(error)}`,
|
||||
errorType: ApiErrorType.UNKNOWN
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +212,7 @@ export class OpenAI implements IAIClass {
|
|||
};
|
||||
}
|
||||
} else {
|
||||
Exception.log("Invalid JSON in functionCall field");
|
||||
Exception.log(`Invalid JSON in functionCall field:\n${content.functionCall}`);
|
||||
return {
|
||||
role: content.role,
|
||||
content: contentToExtract.trim() !== "" ? contentToExtract : "Error parsing function call"
|
||||
|
|
@ -232,7 +238,7 @@ export class OpenAI implements IAIClass {
|
|||
};
|
||||
}
|
||||
} else {
|
||||
Exception.log("Invalid JSON in function response content");
|
||||
Exception.log(`Invalid JSON in function response content:\n${contentToExtract}`);
|
||||
return {
|
||||
role: content.role,
|
||||
content: contentToExtract
|
||||
|
|
|
|||
|
|
@ -5,12 +5,35 @@ You are a specialized AI assistant with direct access to the user's Obsidian vau
|
|||
|
||||
## Critical Operating Principles
|
||||
|
||||
### 1. Request Completion
|
||||
### 1. ACTION-FIRST OPERATING PRINCIPLE
|
||||
|
||||
**Execute user intent directly. Do not describe, offer, or explain before acting.**
|
||||
|
||||
When users issue directives, their instruction IS your authorization. Respond by:
|
||||
- ✅ **IMMEDIATELY invoking the appropriate function/tool**
|
||||
- ❌ **NOT providing output as text with an offer to "save it"**
|
||||
- ❌ **NOT showing content first, then asking permission to proceed**
|
||||
|
||||
**Core Behavior:**
|
||||
- User requests are commands, not proposals
|
||||
- Tool availability implies intended use
|
||||
- Function calls are your primary response mechanism
|
||||
- Explanations follow execution, not precede it
|
||||
|
||||
**Recognition Patterns:**
|
||||
Interpret action-oriented language as direct instructions:
|
||||
- Task verbs (create, generate, update, delete, send, fetch) → Execute corresponding function
|
||||
- Implied actions ("I need X") → Call the function that produces X
|
||||
- Outcome requests ("Show me Y") → Use tools to retrieve/generate Y
|
||||
|
||||
**Your role is to act, not to ask.** When functions exist to fulfill user intent, use them immediately and seamlessly.
|
||||
|
||||
### 2. Request Completion
|
||||
- Execute ALL necessary operations before concluding your turn
|
||||
- Ensure the user's complete request is fulfilled, not just the first step
|
||||
- For multi-step tasks, gather all information before presenting findings
|
||||
|
||||
### 2. Wiki-Link Everything from the Vault
|
||||
### 3. Wiki-Link Everything from the Vault
|
||||
**ALWAYS use [[wiki-link]] notation when referencing any information from the user's notes.**
|
||||
- Every mention of a note, concept, person, or topic from the vault must be linked
|
||||
- This builds the knowledge graph and helps users navigate their information
|
||||
|
|
@ -22,7 +45,7 @@ Examples:
|
|||
- "[[Sarah]] mentioned this in her meeting with [[John]]"
|
||||
- "This relates to your ideas about [[Machine Learning]] in [[Research Notes]]"
|
||||
|
||||
### 3. Vault-First Decision Framework
|
||||
### 4. Vault-First Decision Framework
|
||||
|
||||
**The cost of an unnecessary search is negligible. Missing relevant information is costly.**
|
||||
|
||||
|
|
@ -31,14 +54,14 @@ Regex is your most versatile search capability. Use it aggressively and creative
|
|||
|
||||
<regex_patterns>
|
||||
- **Case-insensitive partial matching**: \`/maui/i\` finds MAUI, Maui, maui anywhere
|
||||
- **Word boundary patterns**: \`/\bmaui\b/i\` matches whole words only
|
||||
- **Word boundary patterns**: \`/\\bmaui\\b/i\` matches whole words only
|
||||
- **Prefix/suffix patterns**: \`/maui.*dev/i\` or \`/.*mobile.*app.*/i\`
|
||||
- **Alternative spellings**: \`/gr(a|e)y/i\` matches gray or grey
|
||||
- **Optional characters**: \`/dockers?/i\` matches docker or dockers
|
||||
- **Wildcard sequences**: \`/proj.*alpha/i\` matches "project alpha", "proj_alpha", etc.
|
||||
- **Multiple alternatives**: \`/(kubernetes|k8s|kube)/i\` catches all variations
|
||||
- **Character classes**: \`/[Dd]ocker/\` for case variations
|
||||
- **Numeric patterns**: \`/vd+.d+/\` for version numbers like v1.2
|
||||
- **Numeric patterns**: \`/v\\d+\\.\\d+/\` for version numbers like v1.2
|
||||
</regex_patterns>
|
||||
|
||||
When to deploy regex (use frequently):
|
||||
|
|
@ -55,6 +78,7 @@ Example workflow:
|
|||
- Search "Project Alpha" fails → Try \`/proj.*alpha/i\` for flexible matching
|
||||
|
||||
#### IMMEDIATE VAULT SEARCH Required When:
|
||||
- Query contains references to individuals who are not commonly known ("for Elika", "in the style of James")
|
||||
- Query contains definite articles suggesting specific reference ("the project", "the prices", "the data")
|
||||
- Query uses possessive pronouns ("my ideas", "our plans", "my notes about")
|
||||
- Query references potentially documented information (projects, data, decisions, meetings, research)
|
||||
|
|
@ -72,7 +96,7 @@ Example workflow:
|
|||
Acknowledge the search, then provide general assistance:
|
||||
"I searched your vault but didn't find notes about [topic]. Here's what I can tell you: [general information]. Would you like me to create a note about this?"
|
||||
|
||||
### 4. Progressive Search Strategy
|
||||
### 5. Progressive Search Strategy
|
||||
|
||||
**NEVER accept a failed search as final. Always try multiple approaches before concluding information doesn't exist.**
|
||||
|
||||
|
|
@ -198,6 +222,8 @@ Integrate all findings into cohesive response:
|
|||
❌ Giving up after first failed search attempt
|
||||
❌ Searching exact literal phrases instead of extracting key entities
|
||||
❌ Asking permission before searching ("Would you like me to search?")
|
||||
❌ Asking "Would you like me to create this?" when user already said to create it
|
||||
❌ Showing what a file would contain instead of actually creating it
|
||||
❌ Providing incremental progress updates instead of complete results
|
||||
❌ Missing obvious relationship inferences from found content
|
||||
❌ Listing all matches when query had directory qualifiers
|
||||
|
|
@ -207,12 +233,13 @@ Integrate all findings into cohesive response:
|
|||
## Decision Framework
|
||||
|
||||
**Always ask yourself:**
|
||||
1. **"Am I using [[wiki-links]] for every vault reference?"** → Always required
|
||||
2. **"Could this information exist in the user's notes?"** → Search vault first
|
||||
3. **"Did my first search fail? Have I tried all progressive tiers?"** → Keep searching
|
||||
4. **"Can I infer the answer from related content I found?"** → Read and reason about relationships
|
||||
5. **"Does this query need multiple search approaches?"** → Scale to complexity
|
||||
6. **"Should I suggest additional related notes?"** → Offer connections when helpful
|
||||
1. **"Have I completed the users request?"** → Reflect on what can still be achieved
|
||||
2. **"Am I using [[wiki-links]] for every vault reference?"** → Always required
|
||||
3. **"Could this information exist in the user's notes?"** → Search vault first
|
||||
4. **"Did my first search fail? Have I tried all progressive tiers?"** → Keep searching
|
||||
5. **"Can I infer the answer from related content I found?"** → Read and reason about relationships
|
||||
6. **"Does this query need multiple search approaches?"** → Scale to complexity
|
||||
7. **"Should I suggest additional related notes?"** → Offer connections when helpful
|
||||
|
||||
**When uncertain**: Always search the vault first. When search fails, always try alternative strategies before concluding "not found."
|
||||
|
||||
|
|
@ -265,5 +292,5 @@ Process:
|
|||
|
||||
---
|
||||
|
||||
**Core Philosophy**: Always use [[wiki-links]] for vault references to build the knowledge graph. Be proactive with vault searches using progressive multi-tier strategies—never give up after the first attempt. Respect the semantic meaning of the user's organizational structure. Infer relationships from context rather than requiring explicit statements. Scale your search complexity to match the query. Always complete the full request before concluding.
|
||||
**Core Philosophy**: Use available tools to support assisting the user. Always use [[wiki-links]] for vault references to build the knowledge graph. Be proactive with vault searches using progressive multi-tier strategies—never give up after the first attempt. Respect the semantic meaning of the user's organizational structure. Infer relationships from context rather than requiring explicit statements. Scale your search complexity to match the query. Always complete the full request before concluding.
|
||||
`;
|
||||
|
|
@ -120,9 +120,13 @@
|
|||
|
||||
// For assistant messages that aren't streaming, use traditional parsing
|
||||
if (!isCurrentlyStreaming) {
|
||||
// Check if this is a cancelled request message
|
||||
|
||||
if (message.content.includes(Selector.ApiRequestAborted)) {
|
||||
return `<span class="${Selector.ApiRequestAborted}">${Copy.ApiRequestAborted}</span>`;
|
||||
return `<span class="${Selector.ErrorSelector}">${Copy.ApiRequestAborted}</span>`;
|
||||
}
|
||||
|
||||
if (message.errorType) {
|
||||
return `<div class="${Selector.ErrorSelector}">${message.content}</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { StringTools } from "Helpers/StringTools";
|
||||
import { ConversationContent } from "./ConversationContent";
|
||||
import { ApiErrorType } from "Types/ApiError";
|
||||
|
||||
export class Conversation {
|
||||
|
||||
|
|
@ -36,6 +37,15 @@ export class Conversation {
|
|||
const conversationContent: ConversationContent | undefined = this.contents[this.contents.length - 1];
|
||||
if (conversationContent) {
|
||||
conversationContent.content = content;
|
||||
conversationContent.errorType = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public setMostRecentError(content: string, errorType: ApiErrorType) {
|
||||
const conversationContent: ConversationContent | undefined = this.contents[this.contents.length - 1];
|
||||
if (conversationContent) {
|
||||
conversationContent.content = content;
|
||||
conversationContent.errorType = errorType;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Role } from "Enums/Role";
|
||||
import { ApiErrorType } from "Types/ApiError";
|
||||
|
||||
export class ConversationContent {
|
||||
role: Role;
|
||||
|
|
@ -9,8 +10,9 @@ export class ConversationContent {
|
|||
isFunctionCall: boolean;
|
||||
isFunctionCallResponse: boolean;
|
||||
toolId?: string;
|
||||
errorType?: ApiErrorType;
|
||||
|
||||
constructor(role: Role, content: string = "", promptContent: string = "", functionCall: string = "", timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string) {
|
||||
constructor(role: Role, content: string = "", promptContent: string = "", functionCall: string = "", timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string, errorType?: ApiErrorType) {
|
||||
this.role = role;
|
||||
this.content = content;
|
||||
this.promptContent = promptContent;
|
||||
|
|
@ -19,10 +21,11 @@ export class ConversationContent {
|
|||
this.isFunctionCall = isFunctionCall;
|
||||
this.isFunctionCallResponse = isFunctionCallResponse;
|
||||
this.toolId = toolId;
|
||||
this.errorType = errorType;
|
||||
}
|
||||
|
||||
public static isConversationContentData(this: void, data: unknown): data is {
|
||||
role: string; content: string; promptContent: string; functionCall: string; timestamp: string, isFunctionCall: boolean, isFunctionCallResponse: boolean, toolId?: string
|
||||
role: string; content: string; promptContent: string; functionCall: string; timestamp: string, isFunctionCall: boolean, isFunctionCallResponse: boolean, toolId?: string, errorType?: string
|
||||
} {
|
||||
return (
|
||||
data !== null &&
|
||||
|
|
@ -34,13 +37,15 @@ export class ConversationContent {
|
|||
"timestamp" in data &&
|
||||
"isFunctionCall" in data &&
|
||||
"isFunctionCallResponse" in data &&
|
||||
"errorType" in data &&
|
||||
typeof data.role === "string" &&
|
||||
typeof data.content === "string" &&
|
||||
typeof data.promptContent === "string" &&
|
||||
typeof data.functionCall === "string" &&
|
||||
typeof data.timestamp === "string" &&
|
||||
typeof data.isFunctionCall === "boolean" &&
|
||||
typeof data.isFunctionCallResponse === "boolean"
|
||||
typeof data.isFunctionCallResponse === "boolean" &&
|
||||
(typeof data.errorType === "string" || data.errorType === undefined)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,16 @@ export enum Selector {
|
|||
AIExclusionsInput = "ai-exclusions-input",
|
||||
ApiKeySettingOk = "api-key-setting-ok",
|
||||
ApiKeySettingError = "api-key-setting-error",
|
||||
ApiRequestAborted = "api-request-aborted",
|
||||
ConversationHistoryModal = "conversation-history-modal",
|
||||
HelpModal = "help-modal",
|
||||
ContextSettingItemDescription = "context-setting-item-description"
|
||||
ContextSettingItemDescription = "context-setting-item-description",
|
||||
|
||||
ApiRequestAborted = "api-request-aborted",
|
||||
APIRequestError = "api-request-error",
|
||||
|
||||
ErrorSelector = "error-selector"
|
||||
}
|
||||
|
||||
export function isErrorSelector(selector: Selector) {
|
||||
return selector === Selector.ApiRequestAborted || selector === Selector.APIRequestError;
|
||||
}
|
||||
|
|
@ -187,9 +187,8 @@ export class ChatService {
|
|||
let capturedShouldContinue = false;
|
||||
|
||||
for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions, this.abortController?.signal)) {
|
||||
if (chunk.error) {
|
||||
console.error("Streaming error:", chunk.error);
|
||||
conversation.setMostRecentContent(`Error: ${chunk.error}`);
|
||||
if (chunk.error && chunk.errorType) {
|
||||
conversation.setMostRecentError(chunk.error, chunk.errorType);
|
||||
callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString());
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ export class ConversationFileSystemService {
|
|||
timestamp: content.timestamp.toISOString(),
|
||||
isFunctionCall: content.isFunctionCall,
|
||||
isFunctionCallResponse: content.isFunctionCallResponse,
|
||||
toolId: content.toolId
|
||||
toolId: content.toolId,
|
||||
errorType: content.errorType
|
||||
}))
|
||||
};
|
||||
|
||||
|
|
@ -104,7 +105,8 @@ export class ConversationFileSystemService {
|
|||
new Date(content.timestamp),
|
||||
content.isFunctionCall,
|
||||
content.isFunctionCallResponse,
|
||||
content.toolId
|
||||
content.toolId,
|
||||
content.errorType
|
||||
);
|
||||
});
|
||||
conversations.push(conversation);
|
||||
|
|
|
|||
|
|
@ -1,50 +1,96 @@
|
|||
import type { AIFunctionCall } from "AIClasses/AIFunctionCall";
|
||||
import { Selector } from "Enums/Selector";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
|
||||
export interface IStreamChunk {
|
||||
content: string;
|
||||
isComplete: boolean;
|
||||
error?: string;
|
||||
errorType?: ApiErrorType;
|
||||
functionCall?: AIFunctionCall;
|
||||
shouldContinue?: boolean;
|
||||
}
|
||||
|
||||
export class StreamingService {
|
||||
public async* streamRequest(
|
||||
url: string,
|
||||
requestBody: unknown,
|
||||
parseStreamChunk: (chunk: string) => IStreamChunk,
|
||||
abortSignal?: AbortSignal,
|
||||
additionalHeaders?: Record<string, string>
|
||||
): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...additionalHeaders,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: abortSignal,
|
||||
|
||||
private static readonly MAX_RETRIES = 3;
|
||||
private static readonly RETRY_DELAYS = [1000, 2000, 4000]; // ms
|
||||
|
||||
public async* streamRequest(url: string, requestBody: unknown, parseStreamChunk: (chunk: string) => IStreamChunk,
|
||||
abortSignal?: AbortSignal, additionalHeaders?: Record<string, string>): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= StreamingService.MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await this.makeRequest(url, requestBody, additionalHeaders, abortSignal);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
Exception.throw("Response body is not readable");
|
||||
}
|
||||
|
||||
const streamCompleted = yield* this.processStream(reader, parseStreamChunk);
|
||||
|
||||
if (!streamCompleted) {
|
||||
yield { content: "", isComplete: true };
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : Exception.new(error);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
yield this.createErrorChunk(lastError, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.shouldRetry(error, attempt)) {
|
||||
Exception.log(lastError);
|
||||
yield this.createErrorChunk(lastError);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sleep(StreamingService.RETRY_DELAYS[attempt]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
Exception.log(lastError);
|
||||
yield this.createErrorChunk(lastError);
|
||||
}
|
||||
}
|
||||
|
||||
private async makeRequest(url: string, requestBody: unknown,
|
||||
additionalHeaders?: Record<string, string>, abortSignal?: AbortSignal): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...additionalHeaders,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: abortSignal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
Exception.throw(`API request failed: ${response.status} - ${response.statusText} ${await response.text()}`);
|
||||
const responseBody = await response.text();
|
||||
throw ApiError.fromResponse(response.status, response.statusText, responseBody);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
Exception.throw("Response body is not readable");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
private async* processStream(reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
parseStreamChunk: (chunk: string) => IStreamChunk): AsyncGenerator<IStreamChunk, boolean, unknown> {
|
||||
|
||||
let buffer = "";
|
||||
let lastChunkWasComplete = false;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
|
|
@ -60,12 +106,13 @@ export class StreamingService {
|
|||
lastChunkWasComplete = chunk.isComplete;
|
||||
yield chunk;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
yield {
|
||||
content: "",
|
||||
isComplete: true,
|
||||
error: Exception.messageFrom(error)
|
||||
};
|
||||
Exception.log(error);
|
||||
yield {
|
||||
content: "",
|
||||
isComplete: true,
|
||||
error: Exception.messageFrom(error),
|
||||
errorType: ApiErrorType.UNKNOWN
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -75,23 +122,49 @@ export class StreamingService {
|
|||
}
|
||||
}
|
||||
|
||||
if (!lastChunkWasComplete) {
|
||||
yield { content: "", isComplete: true };
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't log abort errors as they're intentional
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
yield {
|
||||
content: Selector.ApiRequestAborted,
|
||||
isComplete: true
|
||||
};
|
||||
} else {
|
||||
yield {
|
||||
content: "",
|
||||
isComplete: true,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
return lastChunkWasComplete;
|
||||
}
|
||||
|
||||
private createErrorChunk(error: Error | ApiError, isAborted = false): IStreamChunk {
|
||||
if (isAborted) {
|
||||
return {
|
||||
content: Selector.ApiRequestAborted,
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
content: "",
|
||||
isComplete: true,
|
||||
error: error.info.userMessage,
|
||||
errorType: error.info.type
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: "",
|
||||
isComplete: true,
|
||||
error: Exception.messageFrom(error),
|
||||
errorType: ApiErrorType.UNKNOWN
|
||||
};
|
||||
}
|
||||
|
||||
private shouldRetry(error: unknown, attempt: number): boolean {
|
||||
// Don't retry abort errors
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't retry non-retryable errors
|
||||
if (error instanceof ApiError && !error.info.isRetryable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return attempt < StreamingService.MAX_RETRIES;
|
||||
}
|
||||
|
||||
private async sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
96
Types/ApiError.ts
Normal file
96
Types/ApiError.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
export enum ApiErrorType {
|
||||
RATE_LIMIT = "RATE_LIMIT",
|
||||
OVERLOADED = "OVERLOADED",
|
||||
SERVER_ERROR = "SERVER_ERROR",
|
||||
AUTH_ERROR = "AUTH_ERROR",
|
||||
BAD_REQUEST = "BAD_REQUEST",
|
||||
NETWORK_ERROR = "NETWORK_ERROR",
|
||||
UNKNOWN = "UNKNOWN"
|
||||
}
|
||||
|
||||
export interface ApiErrorInfo {
|
||||
type: ApiErrorType;
|
||||
statusCode?: number;
|
||||
message: string;
|
||||
userMessage: string;
|
||||
isRetryable: boolean;
|
||||
retryAfter?: number; // seconds
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public info: ApiErrorInfo
|
||||
) {
|
||||
super(info.message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
static fromResponse(status: number, statusText: string, responseBody: string): ApiError {
|
||||
let type: ApiErrorType;
|
||||
let userMessage: string;
|
||||
let isRetryable: boolean;
|
||||
|
||||
// Parse response body for provider-specific messages
|
||||
let providerMessage = "";
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody) as { error?: { message?: string }; message?: string };
|
||||
providerMessage = parsed.error?.message || parsed.message || "";
|
||||
} catch {
|
||||
providerMessage = responseBody;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 429:
|
||||
type = ApiErrorType.RATE_LIMIT;
|
||||
userMessage = "Rate limit exceeded. Retrying...";
|
||||
isRetryable = true;
|
||||
break;
|
||||
case 503:
|
||||
type = ApiErrorType.OVERLOADED;
|
||||
userMessage = "Service overloaded. Retrying...";
|
||||
isRetryable = true;
|
||||
break;
|
||||
case 500:
|
||||
case 502:
|
||||
case 504:
|
||||
type = ApiErrorType.SERVER_ERROR;
|
||||
userMessage = "Server error. Retrying...";
|
||||
isRetryable = true;
|
||||
break;
|
||||
case 401:
|
||||
case 403:
|
||||
type = ApiErrorType.AUTH_ERROR;
|
||||
userMessage = "Authentication failed. Please check your API key in settings.";
|
||||
isRetryable = false;
|
||||
break;
|
||||
case 400:
|
||||
type = ApiErrorType.BAD_REQUEST;
|
||||
userMessage = providerMessage || "Invalid request.";
|
||||
isRetryable = false;
|
||||
break;
|
||||
default:
|
||||
type = ApiErrorType.UNKNOWN;
|
||||
userMessage = providerMessage || `Request failed with status ${status}`;
|
||||
isRetryable = false;
|
||||
}
|
||||
|
||||
const message = `API request failed: ${status} - ${statusText}${providerMessage ? ` - ${providerMessage}` : ""}`;
|
||||
|
||||
return new ApiError({
|
||||
type,
|
||||
statusCode: status,
|
||||
message,
|
||||
userMessage,
|
||||
isRetryable
|
||||
});
|
||||
}
|
||||
|
||||
static fromNetworkError(error: Error): ApiError {
|
||||
return new ApiError({
|
||||
type: ApiErrorType.NETWORK_ERROR,
|
||||
message: `Network error: ${error.message}`,
|
||||
userMessage: "Network error. Please check your connection.",
|
||||
isRetryable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -259,16 +259,11 @@ describe('Claude', () => {
|
|||
});
|
||||
|
||||
it('should handle malformed chunk JSON', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = (claude as any).parseStreamChunk('invalid json {{{');
|
||||
|
||||
expect(result.content).toBe('');
|
||||
expect(result.isComplete).toBe(false);
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(result.error).toContain('Failed to parse chunk');
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -248,16 +248,11 @@ describe('Gemini', () => {
|
|||
});
|
||||
|
||||
it('should handle malformed chunk JSON', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = (gemini as any).parseStreamChunk('invalid json {');
|
||||
|
||||
expect(result.content).toBe('');
|
||||
expect(result.isComplete).toBe(false);
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(result.error).toContain('Failed to parse chunk');
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ describe('OpenAI', () => {
|
|||
const result = (openai as any).parseStreamChunk('not valid json {{{');
|
||||
|
||||
expect(result.content).toBe('');
|
||||
expect(result.isComplete).toBe(false);
|
||||
expect(result.isComplete).toBe(true);
|
||||
// The error message comes from Exception.messageFrom which extracts the actual JSON parse error
|
||||
expect(result.error).toBeDefined();
|
||||
expect(exceptionSpy).toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ describe('Conversation', () => {
|
|||
functionCall: '',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: false
|
||||
isFunctionCallResponse: false,
|
||||
errorType: undefined
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -124,7 +124,8 @@ describe('ConversationContent', () => {
|
|||
functionCall: '',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: false
|
||||
isFunctionCallResponse: false,
|
||||
errorType: undefined
|
||||
};
|
||||
|
||||
expect(ConversationContent.isConversationContentData(validData)).toBe(true);
|
||||
|
|
@ -139,7 +140,8 @@ describe('ConversationContent', () => {
|
|||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
isFunctionCall: true,
|
||||
isFunctionCallResponse: false,
|
||||
toolId: 'tool-123'
|
||||
toolId: 'tool-123',
|
||||
errorType: undefined
|
||||
};
|
||||
|
||||
expect(ConversationContent.isConversationContentData(validData)).toBe(true);
|
||||
|
|
@ -326,7 +328,8 @@ describe('ConversationContent', () => {
|
|||
functionCall: '',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: false
|
||||
isFunctionCallResponse: false,
|
||||
errorType: undefined
|
||||
};
|
||||
|
||||
expect(ConversationContent.isConversationContentData(validData)).toBe(true);
|
||||
|
|
@ -340,7 +343,8 @@ describe('ConversationContent', () => {
|
|||
functionCall: '',
|
||||
timestamp: '',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: false
|
||||
isFunctionCallResponse: false,
|
||||
errorType: undefined
|
||||
};
|
||||
|
||||
expect(ConversationContent.isConversationContentData(validData)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -377,7 +377,8 @@ describe('ConversationFileSystemService - Integration Tests', () => {
|
|||
functionCall: '',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: false
|
||||
isFunctionCallResponse: false,
|
||||
errorType: undefined
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
@ -393,7 +394,8 @@ describe('ConversationFileSystemService - Integration Tests', () => {
|
|||
functionCall: '',
|
||||
timestamp: '2024-01-02T10:00:00.000Z',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: false
|
||||
isFunctionCallResponse: false,
|
||||
errorType: undefined
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -426,7 +428,8 @@ describe('ConversationFileSystemService - Integration Tests', () => {
|
|||
functionCall: '',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: false
|
||||
isFunctionCallResponse: false,
|
||||
errorType: undefined
|
||||
},
|
||||
{
|
||||
role: Role.Assistant,
|
||||
|
|
@ -435,7 +438,8 @@ describe('ConversationFileSystemService - Integration Tests', () => {
|
|||
functionCall: '',
|
||||
timestamp: '2024-01-01T10:01:00.000Z',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: false
|
||||
isFunctionCallResponse: false,
|
||||
errorType: undefined
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -502,7 +506,8 @@ describe('ConversationFileSystemService - Integration Tests', () => {
|
|||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
isFunctionCall: true,
|
||||
isFunctionCallResponse: false,
|
||||
toolId: 'tool_1'
|
||||
toolId: 'tool_1',
|
||||
errorType: undefined
|
||||
},
|
||||
{
|
||||
role: Role.User,
|
||||
|
|
@ -512,7 +517,8 @@ describe('ConversationFileSystemService - Integration Tests', () => {
|
|||
timestamp: '2024-01-01T10:01:00.000Z',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: true,
|
||||
toolId: 'tool_1'
|
||||
toolId: 'tool_1',
|
||||
errorType: undefined
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
|
|||
|
|
@ -498,8 +498,7 @@ describe('StreamingService', () => {
|
|||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].isComplete).toBe(true);
|
||||
expect(results[0].error).toContain('404');
|
||||
expect(results[0].error).toContain('Not Found');
|
||||
expect(results[0].error).toBe('Resource not found');
|
||||
});
|
||||
|
||||
it('should handle response with no body', async () => {
|
||||
|
|
@ -553,7 +552,7 @@ describe('StreamingService', () => {
|
|||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].isComplete).toBe(true);
|
||||
expect(results[0].error).toBe('Unknown error');
|
||||
expect(results[0].error).toBe('String error');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@
|
|||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.api-request-aborted {
|
||||
.error-selector {
|
||||
display: inline-block;
|
||||
color: var(--color-red);
|
||||
border: var(--color-red);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ export default defineConfig({
|
|||
"Components": path.resolve(__dirname, "Components"),
|
||||
"Stores": path.resolve(__dirname, "Stores"),
|
||||
"Views": path.resolve(__dirname, "Views"),
|
||||
"Modals": path.resolve(__dirname, "Modals")
|
||||
"Modals": path.resolve(__dirname, "Modals"),
|
||||
"Types": path.resolve(__dirname, "Types")
|
||||
}
|
||||
},
|
||||
test: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue