mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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.
This commit is contained in:
parent
cac0db5b07
commit
15f1a01e4a
9 changed files with 183 additions and 48 deletions
|
|
@ -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<string, any>;
|
||||
public readonly arguments: Record<string, object>;
|
||||
public readonly toolId?: string;
|
||||
|
||||
constructor(name: string, args: Record<string, any>, toolId?: string) {
|
||||
constructor(name: string, args: Record<string, object>, toolId?: string) {
|
||||
this.name = name;
|
||||
this.arguments = args;
|
||||
this.toolId = toolId;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
functionCall = new AIFunctionCall(
|
||||
this.accumulatedFunctionName,
|
||||
args,
|
||||
args as Record<string, object>,
|
||||
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
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
38
AIClasses/FunctionDefinitions/AIFunctionTypes.ts
Normal file
38
AIClasses/FunctionDefinitions/AIFunctionTypes.ts
Normal file
|
|
@ -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<string, JSONSchemaProperty>;
|
||||
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<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, any>;
|
||||
properties: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
|
@ -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<StreamingService>(Services.StreamingService);
|
||||
private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve<AIFunctionDefinitions>(Services.AIFunctionDefinitions);
|
||||
private accumulatedFunctionName: string | null = null;
|
||||
private accumulatedFunctionArgs: Record<string, any> = {};
|
||||
private accumulatedFunctionArgs: Record<string, unknown> = {};
|
||||
|
||||
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<string, object>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
42
AIClasses/Gemini/GeminiInterfaces.ts
Normal file
42
AIClasses/Gemini/GeminiInterfaces.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeminiStreamResponse {
|
||||
candidates?: GeminiCandidate[];
|
||||
}
|
||||
|
||||
export interface GeminiFunctionDeclaration {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: string;
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeminiContentPart {
|
||||
text?: string;
|
||||
functionCall?: {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
functionResponse?: {
|
||||
response: unknown;
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
functionCall = new AIFunctionCall(
|
||||
firstToolCall.name,
|
||||
args,
|
||||
args as Record<string, object>,
|
||||
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: {
|
||||
|
|
|
|||
37
AIClasses/OpenAI/OpenAIInterfaces.ts
Normal file
37
AIClasses/OpenAI/OpenAIInterfaces.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue