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;