refactor: extract attachment processing logic to base class

Consolidate duplicate file upload and error handling code from Claude, Gemini, and OpenAI into shared `processAttachments` method in BaseAIClass. Remove unnecessary abort service wrapping from BaseAIFileService retry logic.
This commit is contained in:
Andrew Beal 2025-12-20 10:39:24 +00:00
parent e24a5a02bf
commit 59db78d610
5 changed files with 65 additions and 89 deletions

View file

@ -130,6 +130,32 @@ export abstract class BaseAIClass implements IAIClass {
].filter(s => s).join("\n\n");
}
protected async processAttachments<T>(
attachments: Attachment[],
formatBinaryFiles: (attachments: Attachment[]) => string
): Promise<{ formattedParts: T[], errorMessage?: string }> {
const failedUploads: string[] = [];
for (const attachment of attachments) {
try {
await this.aiFileService.uploadFile(attachment);
} catch (error) {
Exception.log(`Failed to upload ${attachment.fileName}: ${Exception.messageFrom(error)}`);
failedUploads.push(attachment.fileName);
}
}
const formattedContent = formatBinaryFiles(attachments);
const formattedParts = JSON.parse(formattedContent) as T[];
return {
formattedParts,
errorMessage: failedUploads.length > 0
? `[Upload failed for: ${failedUploads.join(', ')}.]`
: undefined
};
}
/**
* Converts a function call to legacy text format for cross-provider compatibility.
* Used when a provider doesn't have the required ID field (e.g., Gemini Claude/OpenAI).

View file

@ -85,44 +85,28 @@ export abstract class BaseAIFileService implements IAIFileService {
// Retries operation on retryable errors (500, 502, 503, 504) with exponential backoff
protected async withRetry<T>(operationName: string, operation: () => Promise<T>, defaultValue: T): Promise<T> {
return await this.abortService.abortableOperation(async () => {
for (let attempt = 1; attempt <= BaseAIFileService.MAX_RETRIES; attempt++) {
try {
return await operation();
} catch (error) {
// Don't retry on abort errors - throw immediately
if (AbortService.isAbortError(error)) {
throw error;
}
if (ApiError.isApiError(error) && error.info.isRetryable) {
if (attempt === BaseAIFileService.MAX_RETRIES) {
Exception.log(`${operationName}: Max retries (${BaseAIFileService.MAX_RETRIES}) exhausted. Returning default value.`);
return defaultValue;
}
const delay = BaseAIFileService.RETRY_DELAYS[attempt];
Exception.warn(`${operationName}: Attempt ${attempt}/${BaseAIFileService.MAX_RETRIES} failed with ${error.info.type} (status ${error.info.statusCode}). Retrying in ${delay}ms...`);
if (this.abortService.signal().aborted) {
this.abortService.throw();
}
await sleep(delay);
if (this.abortService.signal().aborted) {
this.abortService.throw();
}
} else {
// Non-retryable error - return default value
Exception.log(`${operationName}: Non-retryable error. Returning default value.`);
for (let attempt = 1; attempt <= BaseAIFileService.MAX_RETRIES; attempt++) {
try {
return await operation();
} catch (error) {
if (ApiError.isApiError(error) && error.info.isRetryable) {
if (attempt === BaseAIFileService.MAX_RETRIES) {
Exception.log(`${operationName}: Max retries (${BaseAIFileService.MAX_RETRIES}) exhausted. Returning default value.`);
return defaultValue;
}
const delay = BaseAIFileService.RETRY_DELAYS[attempt];
Exception.warn(`${operationName}: Attempt ${attempt}/${BaseAIFileService.MAX_RETRIES} failed with ${error.info.type} (status ${error.info.statusCode}). Retrying in ${delay}ms...`);
await sleep(delay);
} else {
// Non-retryable error - return default value
Exception.log(`${operationName}: Non-retryable error. Returning default value.`);
return defaultValue;
}
}
return defaultValue;
});
}
return defaultValue;
}
protected createBoundary(): string {

View file

@ -194,28 +194,17 @@ export class Claude extends BaseAIClass {
// Add binary file attachments if present
if (content.attachments && content.attachments.length > 0) {
// Upload all attachments and track failures
const failedUploads: string[] = [];
const { formattedParts, errorMessage } = await this.processAttachments<ContentBlockParam>(
content.attachments,
(attachments) => this.formatBinaryFiles(attachments)
);
for (const attachment of content.attachments) {
try {
await this.aiFileService.uploadFile(attachment);
} catch (error) {
Exception.log(`Failed to upload ${attachment.fileName}: ${Exception.messageFrom(error)}`);
failedUploads.push(attachment.fileName);
}
}
contentBlocks.push(...formattedParts);
// Format successfully uploaded files
const formattedContent = this.formatBinaryFiles(content.attachments);
const rawContent = JSON.parse(formattedContent) as ContentBlockParam[];
contentBlocks.push(...rawContent);
// Add error messages for failed uploads
if (failedUploads.length > 0) {
if (errorMessage) {
contentBlocks.push({
type: "text",
text: `[Upload failed for: ${failedUploads.join(', ')}.]`
text: errorMessage
});
}
}

View file

@ -10,7 +10,6 @@ import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFun
import type { ConversationContent } from "Conversations/ConversationContent";
import type { Candidate, Part, FunctionDeclaration } from "@google/genai";
import { FinishReason } from "@google/genai";
import { Exception } from "Helpers/Exception";
export class Gemini extends BaseAIClass {
@ -200,27 +199,16 @@ export class Gemini extends BaseAIClass {
// Add binary file attachments if present
if (content.attachments && content.attachments.length > 0) {
// Upload all attachments and track failures
const failedUploads: string[] = [];
const { formattedParts, errorMessage } = await this.processAttachments<Part>(
content.attachments,
(attachments) => this.formatBinaryFiles(attachments)
);
for (const attachment of content.attachments) {
try {
await this.aiFileService.uploadFile(attachment);
} catch (error) {
Exception.log(`Failed to upload ${attachment.fileName}: ${Exception.messageFrom(error)}`);
failedUploads.push(attachment.fileName);
}
}
parts.push(...formattedParts);
// Format successfully uploaded files
const formattedContent = this.formatBinaryFiles(content.attachments);
const rawContent = JSON.parse(formattedContent) as Part[];
parts.push(...rawContent);
// Add error messages for failed uploads
if (failedUploads.length > 0) {
if (errorMessage) {
parts.push({
text: `[Upload failed for: ${failedUploads.join(', ')}.]`
text: errorMessage
});
}
}

View file

@ -237,29 +237,18 @@ export class OpenAI extends BaseAIClass {
// Case 2: Binary file attachments
if (content.attachments && content.attachments.length > 0) {
// Upload all attachments and track failures
const failedUploads: string[] = [];
const { formattedParts, errorMessage } = await this.processAttachments<ResponsesAPIInput>(
content.attachments,
(attachments) => this.formatBinaryFiles(attachments)
);
for (const attachment of content.attachments) {
try {
await this.aiFileService.uploadFile(attachment);
} catch (error) {
Exception.log(`Failed to upload ${attachment.fileName}: ${Exception.messageFrom(error)}`);
failedUploads.push(attachment.fileName);
}
}
results.push(...formattedParts);
// Format successfully uploaded files
const formattedContent = this.formatBinaryFiles(content.attachments);
const rawContent = JSON.parse(formattedContent) as ResponsesAPIInput[];
results.push(...rawContent);
// Add error messages for failed uploads
if (failedUploads.length > 0) {
if (errorMessage) {
// OpenAI formatBinaryFiles returns array with role wrapper, so add as separate message
results.push({
role: "user",
content: `[Upload failed for: ${failedUploads.join(', ')}.]`
content: errorMessage
});
}
continue;