From 15f1a01e4a5b6eeb6a00698f5df43da1570b05a4 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Mon, 10 Nov 2025 11:55:19 +0000 Subject: [PATCH] Strengthen type safety across AI provider implementations Replace `any` types with proper TypeScript interfaces and type assertions throughout Claude, Gemini, and OpenAI implementations. Add explicit typing for streaming events, function parameters, and conversation content to improve type checking and reduce runtime errors. --- AIClasses/AIFunctionCall.ts | 4 +- AIClasses/Claude/Claude.ts | 60 +++++++++++-------- .../FunctionDefinitions/AIFunctionTypes.ts | 38 ++++++++++++ .../IAIFunctionDefinition.ts | 4 +- AIClasses/Gemini/Gemini.ts | 22 +++---- AIClasses/Gemini/GeminiInterfaces.ts | 42 +++++++++++++ AIClasses/OpenAI/OpenAI.ts | 18 +++--- AIClasses/OpenAI/OpenAIInterfaces.ts | 37 ++++++++++++ Conversations/ConversationContent.ts | 6 +- 9 files changed, 183 insertions(+), 48 deletions(-) create mode 100644 AIClasses/FunctionDefinitions/AIFunctionTypes.ts create mode 100644 AIClasses/Gemini/GeminiInterfaces.ts create mode 100644 AIClasses/OpenAI/OpenAIInterfaces.ts diff --git a/AIClasses/AIFunctionCall.ts b/AIClasses/AIFunctionCall.ts index 2d3d5e8..4f191ff 100644 --- a/AIClasses/AIFunctionCall.ts +++ b/AIClasses/AIFunctionCall.ts @@ -1,10 +1,10 @@ // platform agnostic function call class used to execute the requested function export class AIFunctionCall { public readonly name: string; - public readonly arguments: Record; + public readonly arguments: Record; public readonly toolId?: string; - constructor(name: string, args: Record, toolId?: string) { + constructor(name: string, args: Record, toolId?: string) { this.name = name; this.arguments = args; this.toolId = toolId; diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index 1e70936..dbc3a89 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -13,6 +13,8 @@ import { isValidJson } from "Helpers/Helpers"; import type { ConversationContent } from "Conversations/ConversationContent"; import { Role } from "Enums/Role"; import type { SettingsService } from "Services/SettingsService"; +import type { RawMessageStreamEvent, ContentBlockParam, Tool } from '@anthropic-ai/sdk/resources/messages'; +import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionTypes"; export class Claude implements IAIClass { @@ -70,7 +72,7 @@ export class Claude implements IAIClass { yield* this.streamingService.streamRequest( AIProviderURL.Claude, requestBody, - this.parseStreamChunk.bind(this), + (chunk: string) => this.parseStreamChunk(chunk), abortSignal, headers ); @@ -78,41 +80,44 @@ export class Claude implements IAIClass { private parseStreamChunk(chunk: string): IStreamChunk { try { - const data = JSON.parse(chunk); + const data = JSON.parse(chunk) as RawMessageStreamEvent; let text = ""; let functionCall: AIFunctionCall | undefined = undefined; let isComplete = false; let shouldContinue = false; - const eventType = data.type; - // Handle content_block_start - detect tool_use blocks - if (eventType === "content_block_start" && data.content_block) { - if (data.content_block.type === "tool_use") { - this.accumulatedFunctionName = data.content_block.name || null; + if (data.type === "content_block_start") { + const startEvent = data; + if (startEvent.content_block.type === "tool_use") { + const toolBlock = startEvent.content_block; + this.accumulatedFunctionName = toolBlock.name || null; this.accumulatedFunctionArgs = ""; - this.accumulatedFunctionId = data.content_block.id || null; + this.accumulatedFunctionId = toolBlock.id || null; } } // Handle content_block_delta - accumulate text or tool arguments - if (eventType === "content_block_delta" && data.delta) { - if (data.delta.type === "text_delta") { - text = data.delta.text || ""; - } else if (data.delta.type === "input_json_delta") { - this.accumulatedFunctionArgs += data.delta.partial_json || ""; + if (data.type === "content_block_delta") { + const deltaEvent = data; + if (deltaEvent.delta.type === "text_delta") { + const textDelta = deltaEvent.delta; + text = textDelta.text || ""; + } else if (deltaEvent.delta.type === "input_json_delta") { + const inputDelta = deltaEvent.delta; + this.accumulatedFunctionArgs += inputDelta.partial_json || ""; } } // Handle content_block_stop - finalize tool calls - if (eventType === "content_block_stop") { + if (data.type === "content_block_stop") { if (this.accumulatedFunctionName && this.accumulatedFunctionArgs) { try { - const args = JSON.parse(this.accumulatedFunctionArgs); + const args = JSON.parse(this.accumulatedFunctionArgs) as Record; functionCall = new AIFunctionCall( this.accumulatedFunctionName, - args, + args as Record, this.accumulatedFunctionId || undefined ); } catch (error) { @@ -126,8 +131,9 @@ export class Claude implements IAIClass { } // Handle message_delta - check for completion - if (eventType === "message_delta" && data.delta) { - const stopReason = data.delta.stop_reason; + if (data.type === "message_delta") { + const deltaEvent = data; + const stopReason = deltaEvent.delta.stop_reason; if (stopReason) { isComplete = true; shouldContinue = stopReason === this.STOP_REASON_TOOL_USE; @@ -135,7 +141,7 @@ export class Claude implements IAIClass { } // Handle message_stop - mark as complete - if (eventType === "message_stop") { + if (data.type === "message_stop") { isComplete = true; } @@ -155,8 +161,8 @@ export class Claude implements IAIClass { private extractContents(conversationContent: ConversationContent[]) { return conversationContent.filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") .map(content => { - const contentBlocks: any[] = []; - const contentToExtract = content.role == Role.User ? content.promptContent : content.content; + const contentBlocks: ContentBlockParam[] = []; + const contentToExtract = content.role === Role.User ? content.promptContent : content.content; if (contentToExtract.trim() !== "" && !content.isFunctionCallResponse) { contentBlocks.push({ @@ -169,7 +175,7 @@ export class Claude implements IAIClass { if (content.isFunctionCall && content.functionCall.trim() !== "") { if (isValidJson(content.functionCall)) { try { - const parsedContent = JSON.parse(content.functionCall); + const parsedContent = JSON.parse(content.functionCall) as StoredFunctionCall; contentBlocks.push({ type: "tool_use", id: parsedContent.functionCall.id, @@ -202,7 +208,7 @@ export class Claude implements IAIClass { if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { if (isValidJson(contentToExtract)) { try { - const parsedContent = JSON.parse(contentToExtract); + const parsedContent = JSON.parse(contentToExtract) as StoredFunctionResponse; contentBlocks.push({ type: "tool_result", tool_use_id: parsedContent.id, @@ -232,11 +238,15 @@ export class Claude implements IAIClass { .filter(message => message.content.length > 0); } - private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object[] { + private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): Tool[] { return aiFunctionDefinitions.map((functionDefinition) => ({ name: functionDefinition.name, description: functionDefinition.description, - input_schema: functionDefinition.parameters + input_schema: { + type: "object" as const, + properties: functionDefinition.parameters.properties, + required: functionDefinition.parameters.required + } })); } } diff --git a/AIClasses/FunctionDefinitions/AIFunctionTypes.ts b/AIClasses/FunctionDefinitions/AIFunctionTypes.ts new file mode 100644 index 0000000..b0283e9 --- /dev/null +++ b/AIClasses/FunctionDefinitions/AIFunctionTypes.ts @@ -0,0 +1,38 @@ +// Shared type definitions for AI function calls and responses across all providers + +/** + * JSON Schema property definition supporting nested structures + * Used for defining function parameters in a provider-agnostic way + */ +export type JSONSchemaProperty = { + type?: string; + description?: string; + items?: JSONSchemaProperty; + properties?: Record; + required?: string[]; + enum?: string[]; + [key: string]: unknown; +}; + +/** + * Stored function call format used across all AI providers + * This is the format saved to conversation history when a function is called + */ +export interface StoredFunctionCall { + functionCall: { + id: string; + name: string; + args: Record; + }; +} + +/** + * Stored function response format used across all AI providers + * This is the format saved to conversation history when a function returns + */ +export interface StoredFunctionResponse { + id: string; + functionResponse: { + response: unknown; + }; +} diff --git a/AIClasses/FunctionDefinitions/IAIFunctionDefinition.ts b/AIClasses/FunctionDefinitions/IAIFunctionDefinition.ts index 22a3590..ecd1a0a 100644 --- a/AIClasses/FunctionDefinitions/IAIFunctionDefinition.ts +++ b/AIClasses/FunctionDefinitions/IAIFunctionDefinition.ts @@ -1,10 +1,12 @@ +import type { JSONSchemaProperty } from "./AIFunctionTypes"; + // platform agnostic function definition used to present function calls in an API call export interface IAIFunctionDefinition { name: string; description: string; parameters: { type: string; - properties: Record; + properties: Record; required?: string[]; }; } \ No newline at end of file diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index d27b5ea..5185c51 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -13,6 +13,8 @@ import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunc import { isValidJson } from "Helpers/Helpers"; import type { ConversationContent } from "Conversations/ConversationContent"; import type { SettingsService } from "Services/SettingsService"; +import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionTypes"; +import type { GeminiStreamResponse, GeminiFunctionDeclaration, GeminiContentPart } from "./GeminiInterfaces"; export class Gemini implements IAIClass { @@ -26,7 +28,7 @@ export class Gemini implements IAIClass { private readonly streamingService: StreamingService = Resolve(Services.StreamingService); private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); private accumulatedFunctionName: string | null = null; - private accumulatedFunctionArgs: Record = {}; + private accumulatedFunctionArgs: Record = {}; public constructor() { this.apiKey = this.settingsService.getApiKeyForProvider(AIProvider.Gemini); @@ -89,14 +91,14 @@ export class Gemini implements IAIClass { yield* this.streamingService.streamRequest( `${AIProviderURL.Gemini}/${this.settingsService.settings.model}:streamGenerateContent?key=${this.apiKey}&alt=sse`, requestBody, - this.parseStreamChunk.bind(this), + (chunk: string) => this.parseStreamChunk(chunk), abortSignal ); } private parseStreamChunk(chunk: string): IStreamChunk { try { - const data = JSON.parse(chunk); + const data = JSON.parse(chunk) as GeminiStreamResponse; let text = ""; let functionCall: AIFunctionCall | undefined = undefined; @@ -140,7 +142,7 @@ export class Gemini implements IAIClass { if (isComplete && this.accumulatedFunctionName) { functionCall = new AIFunctionCall( this.accumulatedFunctionName, - this.accumulatedFunctionArgs + this.accumulatedFunctionArgs as Record ); } @@ -157,17 +159,17 @@ export class Gemini implements IAIClass { } } - private extractContents(conversationContent: ConversationContent[]): { role: Role, parts: any[] }[] { + private extractContents(conversationContent: ConversationContent[]): { role: Role, parts: GeminiContentPart[] }[] { return conversationContent.filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") .map(content => { - const parts: any[] = []; - const contentToExtract = content.role == Role.User ? content.promptContent : content.content; + const parts: GeminiContentPart[] = []; + const contentToExtract = content.role === Role.User ? content.promptContent : content.content; if (contentToExtract.trim() !== "") { if (content.isFunctionCallResponse) { if (isValidJson(contentToExtract)) { try { - const parsedContent = JSON.parse(contentToExtract); + const parsedContent = JSON.parse(contentToExtract) as StoredFunctionResponse; if (parsedContent.functionResponse) { parts.push({ functionResponse: parsedContent.functionResponse @@ -191,7 +193,7 @@ export class Gemini implements IAIClass { if (content.isFunctionCall && content.functionCall.trim() !== "") { if (isValidJson(content.functionCall)) { try { - const parsedContent = JSON.parse(content.functionCall); + const parsedContent = JSON.parse(content.functionCall) as StoredFunctionCall; if (parsedContent.functionCall) { parts.push({ functionCall: { @@ -216,7 +218,7 @@ export class Gemini implements IAIClass { .filter(message => message.parts.length > 0); } - private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object[] { + private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): GeminiFunctionDeclaration[] { return aiFunctionDefinitions.map((functionDefinition) => ({ name: functionDefinition.name, description: functionDefinition.description, diff --git a/AIClasses/Gemini/GeminiInterfaces.ts b/AIClasses/Gemini/GeminiInterfaces.ts new file mode 100644 index 0000000..fd85969 --- /dev/null +++ b/AIClasses/Gemini/GeminiInterfaces.ts @@ -0,0 +1,42 @@ +// Type definitions for Google Gemini API responses + +export interface GeminiCandidate { + content?: { + parts?: GeminiPart[]; + }; + text?: string; + finishReason?: string; +} + +export interface GeminiPart { + text?: string; + functionCall?: { + name?: string; + args?: Record; + }; +} + +export interface GeminiStreamResponse { + candidates?: GeminiCandidate[]; +} + +export interface GeminiFunctionDeclaration { + name: string; + description: string; + parameters: { + type: string; + properties: Record; + required?: string[]; + }; +} + +export interface GeminiContentPart { + text?: string; + functionCall?: { + name: string; + args: Record; + }; + functionResponse?: { + response: unknown; + }; +} diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 4786ecf..e53a344 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -12,6 +12,8 @@ import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunc import { Role } from "Enums/Role"; import { isValidJson } from "Helpers/Helpers"; import type { SettingsService } from "Services/SettingsService"; +import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionTypes"; +import type { OpenAIStreamResponse, OpenAITool } from "./OpenAIInterfaces"; interface IToolCallAccumulator { id: string | null; @@ -56,12 +58,12 @@ export class OpenAI implements IAIClass { ...conversation.contents .filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") .map(content => { - const contentToExtract = content.role == Role.User ? content.promptContent : content.content; + const contentToExtract = content.role === Role.User ? content.promptContent : content.content; // Handle function call if (content.isFunctionCall && content.functionCall.trim() !== "") { if (isValidJson(content.functionCall)) { try { - const parsedContent = JSON.parse(content.functionCall); + const parsedContent = JSON.parse(content.functionCall) as StoredFunctionCall; return { role: content.role, content: contentToExtract.trim() !== "" ? contentToExtract : null, @@ -97,7 +99,7 @@ export class OpenAI implements IAIClass { if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { if (isValidJson(contentToExtract)) { try { - const parsedContent = JSON.parse(contentToExtract); + const parsedContent = JSON.parse(contentToExtract) as StoredFunctionResponse; return { role: "tool", tool_call_id: parsedContent.id, @@ -148,7 +150,7 @@ export class OpenAI implements IAIClass { yield* this.streamingService.streamRequest( AIProviderURL.OpenAI, requestBody, - this.parseStreamChunk.bind(this), + (chunk: string) => this.parseStreamChunk(chunk), abortSignal, headers ); @@ -161,7 +163,7 @@ export class OpenAI implements IAIClass { return { content: "", isComplete: true }; } - const data = JSON.parse(chunk); + const data = JSON.parse(chunk) as OpenAIStreamResponse; let text = ""; let functionCall: AIFunctionCall | undefined = undefined; @@ -221,10 +223,10 @@ export class OpenAI implements IAIClass { const firstToolCall = this.accumulatedToolCalls.get(0); if (firstToolCall && firstToolCall.name && firstToolCall.arguments) { try { - const args = JSON.parse(firstToolCall.arguments); + const args = JSON.parse(firstToolCall.arguments) as Record; functionCall = new AIFunctionCall( firstToolCall.name, - args, + args as Record, firstToolCall.id || undefined ); } catch (error) { @@ -247,7 +249,7 @@ export class OpenAI implements IAIClass { } } - private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object[] { + private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): OpenAITool[] { return aiFunctionDefinitions.map((functionDefinition) => ({ type: "function", function: { diff --git a/AIClasses/OpenAI/OpenAIInterfaces.ts b/AIClasses/OpenAI/OpenAIInterfaces.ts new file mode 100644 index 0000000..6cab676 --- /dev/null +++ b/AIClasses/OpenAI/OpenAIInterfaces.ts @@ -0,0 +1,37 @@ +// Type definitions for OpenAI API responses + +export interface OpenAIStreamResponse { + choices?: OpenAIChoice[]; +} + +export interface OpenAIChoice { + delta?: OpenAIDelta; + finish_reason?: string; +} + +export interface OpenAIDelta { + content?: string; + tool_calls?: OpenAIToolCallDelta[]; +} + +export interface OpenAIToolCallDelta { + index: number; + id?: string; + function?: { + name?: string; + arguments?: string; + }; +} + +export interface OpenAITool { + type: "function"; + function: { + name: string; + description: string; + parameters: { + type: string; + properties: Record; + required?: string[]; + }; + }; +} diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index 2a4a953..a94cfdd 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -1,5 +1,7 @@ +import { Role } from "Enums/Role"; + export class ConversationContent { - role: string; + role: Role; content: string; promptContent: string; functionCall: string; @@ -8,7 +10,7 @@ export class ConversationContent { isFunctionCallResponse: boolean; toolId?: string; - constructor(role: string, 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) { this.role = role; this.content = content; this.promptContent = promptContent;