feat: Add typed stuff

This commit is contained in:
2025-09-04 15:13:07 +02:00
parent 18769c134d
commit 900ed70162
13 changed files with 251 additions and 118 deletions

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -9,7 +9,7 @@
import {
createClaudeProvider,
createResponseType,
validateResponseType,
parseAndValidateResponseType,
AIProviderError,
AIErrorType
} from '../src/index.js';
@@ -137,7 +137,7 @@ async function demonstrateUserProfileGeneration() {
await claude.initialize();
try {
const response = await claude.complete({
const response = await claude.complete<UserProfile>({
messages: [
{
role: 'user',
@@ -149,13 +149,9 @@ async function demonstrateUserProfileGeneration() {
temperature: 0.7
});
console.log('Raw response:', response.content);
console.log('Raw response:', response.rawContent);
// Validate and parse the response
const validation = validateResponseType(response.content, userProfileType);
if (validation.isValid) {
const userProfile = validation.data as UserProfile;
const userProfile = response.content;
console.log('\nParsed User Profile:');
console.log(`Name: ${userProfile.name}`);
console.log(`Age: ${userProfile.age}`);
@@ -163,9 +159,6 @@ async function demonstrateUserProfileGeneration() {
console.log(`Theme Preference: ${userProfile.preferences.theme}`);
console.log(`Notifications: ${userProfile.preferences.notifications}`);
console.log(`Skills: ${userProfile.skills.join(', ')}`);
} else {
console.error('Validation failed:', validation.error);
}
} catch (error) {
if (error instanceof AIProviderError) {
@@ -183,7 +176,7 @@ async function demonstrateProductAnalysis() {
await claude.initialize();
try {
const response = await claude.complete({
const response = await claude.complete<ProductAnalysis>({
messages: [
{
role: 'user',
@@ -195,12 +188,9 @@ async function demonstrateProductAnalysis() {
temperature: 0.5
});
console.log('Raw response:', response.content);
console.log('Raw response:', response.rawContent);
const validation = validateResponseType(response.content, productAnalysisType);
if (validation.isValid) {
const analysis = validation.data as ProductAnalysis;
const analysis = response.content;
console.log('\nProduct Analysis:');
console.log(`Product: ${analysis.productName}`);
console.log(`Category: ${analysis.category}`);
@@ -210,9 +200,6 @@ async function demonstrateProductAnalysis() {
console.log(`Pros: ${analysis.pros.join(', ')}`);
console.log(`Cons: ${analysis.cons.join(', ')}`);
console.log(`Reasoning: ${analysis.reasoning}`);
} else {
console.error('Validation failed:', validation.error);
}
} catch (error) {
if (error instanceof AIProviderError) {
@@ -248,11 +235,14 @@ function processPayment(amount, cardNumber) {
`;
try {
const response = await claude.complete({
const response = await claude.complete<CodeReview>({
messages: [
{
role: 'user',
content: `Please review this JavaScript code and provide a comprehensive analysis:\n\n\`\`\`javascript\n${sampleCode}\n\`\`\``
content: `Please review this JavaScript code and provide a comprehensive analysis:\n\n\
`${sampleCode}
\
``` `
}
],
responseType: codeReviewType,
@@ -260,12 +250,9 @@ function processPayment(amount, cardNumber) {
temperature: 0.3
});
console.log('Raw response:', response.content);
console.log('Raw response:', response.rawContent);
const validation = validateResponseType(response.content, codeReviewType);
if (validation.isValid) {
const review = validation.data as CodeReview;
const review = response.content;
console.log('\nCode Review:');
console.log(`Overall Score: ${review.overallScore}/10`);
console.log(`Summary: ${review.summary}`);
@@ -278,9 +265,6 @@ function processPayment(amount, cardNumber) {
const lineInfo = issue.line ? ` (line ${issue.line})` : '';
console.log(` • [${issue.severity.toUpperCase()}] ${issue.type}: ${issue.message}${lineInfo}`);
});
} else {
console.error('Validation failed:', validation.error);
}
} catch (error) {
if (error instanceof AIProviderError) {
@@ -319,11 +303,12 @@ async function demonstrateStreamingWithResponseType() {
console.log('\n\nStream completed. Usage:', chunk.usage);
// Validate the complete streamed response
const validation = validateResponseType(fullResponse, productAnalysisType);
if (validation.isValid) {
try {
const analysis = parseAndValidateResponseType(fullResponse, productAnalysisType);
console.log('\nStreamed response validation: SUCCESS');
} else {
console.log('\nStreamed response validation: FAILED -', validation.error);
console.log(`Product: ${analysis.productName}`);
} catch (e) {
console.log('\nStreamed response validation: FAILED -', (e as Error).message);
}
}
}

View File

@@ -27,7 +27,7 @@ export { AIProviderError, AIErrorType } from './types/index.js';
export {
createResponseType,
generateResponseTypePrompt,
validateResponseType
parseAndValidateResponseType
} from './types/index.js';
// Base provider

View File

@@ -22,7 +22,7 @@ import type {
ProviderInfo,
ResponseType
} from '../types/index.js';
import { AIProviderError, AIErrorType, generateResponseTypePrompt } from '../types/index.js';
import { AIProviderError, AIErrorType, generateResponseTypePrompt, parseAndValidateResponseType } from '../types/index.js';
// ============================================================================
// ABSTRACT BASE PROVIDER CLASS
@@ -148,7 +148,9 @@ export abstract class BaseAIProvider {
* console.log(response.content);
* ```
*/
public async complete<T = any>(params: CompletionParams<T>): Promise<CompletionResponse> {
public async complete<T>(params: CompletionParams<T>): Promise<CompletionResponse<T>>;
public async complete(params: CompletionParams): Promise<CompletionResponse<string>>;
public async complete<T = any>(params: CompletionParams<T>): Promise<CompletionResponse<T | string>> {
// Ensure provider is ready for use
this.ensureInitialized();
@@ -160,7 +162,23 @@ export abstract class BaseAIProvider {
const processedParams = this.processResponseType(params);
// Delegate to provider-specific implementation
return await this.doComplete(processedParams);
const response = await this.doComplete(processedParams);
// If a responseType is defined, parse and validate the response
if (params.responseType) {
const parsedData = parseAndValidateResponseType<T>(response.content, params.responseType);
return {
...response,
content: parsedData,
rawContent: response.content,
};
}
// Otherwise, return the raw string content
return {
...response,
content: response.content,
};
} catch (error) {
// Normalize error to our standard format
throw this.normalizeError(error as Error);
@@ -249,7 +267,7 @@ export abstract class BaseAIProvider {
* @returns Promise resolving to completion response
* @throws {Error} If completion fails (will be normalized to AIProviderError)
*/
protected abstract doComplete<T = any>(params: CompletionParams<T>): Promise<CompletionResponse>;
protected abstract doComplete(params: CompletionParams): Promise<CompletionResponse<string>>;
/**
* Provider-specific streaming implementation.

View File

@@ -220,7 +220,7 @@ export class ClaudeProvider extends BaseAIProvider {
* @returns Promise resolving to formatted completion response
* @throws {Error} If API request fails
*/
protected async doComplete<T = any>(params: CompletionParams<T>): Promise<CompletionResponse> {
protected async doComplete(params: CompletionParams): Promise<CompletionResponse<string>> {
if (!this.client) {
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
}
@@ -533,7 +533,7 @@ export class ClaudeProvider extends BaseAIProvider {
* @returns Formatted completion response
* @throws {AIProviderError} If response format is unexpected
*/
private formatCompletionResponse(response: any): CompletionResponse {
private formatCompletionResponse(response: any): CompletionResponse<string> {
// Extract text content from response blocks
const content = response.content
?.filter((block: any) => block.type === 'text')

View File

@@ -268,7 +268,7 @@ export class GeminiProvider extends BaseAIProvider {
* @returns Promise resolving to formatted completion response
* @throws {Error} If API request fails
*/
protected async doComplete<T = any>(params: CompletionParams<T>): Promise<CompletionResponse> {
protected async doComplete(params: CompletionParams): Promise<CompletionResponse<string>> {
if (!this.client || !this.model) {
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
}
@@ -617,7 +617,7 @@ export class GeminiProvider extends BaseAIProvider {
* @returns Formatted completion response
* @throws {AIProviderError} If response format is unexpected
*/
private formatCompletionResponse(response: any, model: string): CompletionResponse {
private formatCompletionResponse(response: any, model: string): CompletionResponse<string> {
// Handle multiple text parts in the response
const candidate = response.candidates?.[0];
if (!candidate) {

View File

@@ -244,7 +244,7 @@ export class OpenAIProvider extends BaseAIProvider {
* @returns Promise resolving to formatted completion response
* @throws {Error} If API request fails
*/
protected async doComplete<T = any>(params: CompletionParams<T>): Promise<CompletionResponse> {
protected async doComplete(params: CompletionParams): Promise<CompletionResponse<string>> {
if (!this.client) {
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
}
@@ -537,7 +537,7 @@ export class OpenAIProvider extends BaseAIProvider {
* @returns Formatted completion response
* @throws {AIProviderError} If response format is unexpected
*/
private formatCompletionResponse(response: OpenAI.Chat.Completions.ChatCompletion): CompletionResponse {
private formatCompletionResponse(response: OpenAI.Chat.Completions.ChatCompletion): CompletionResponse<string> {
const choice = response.choices[0];
if (!choice || !choice.message.content) {
throw new AIProviderError(

View File

@@ -524,7 +524,7 @@ export class OpenWebUIProvider extends BaseAIProvider {
* @param response - Raw OpenWebUI response
* @returns Formatted completion response
*/
private formatChatResponse(response: OpenWebUIChatResponse): CompletionResponse {
private formatChatResponse(response: OpenWebUIChatResponse): CompletionResponse<string> {
const choice = response.choices[0];
if (!choice || !choice.message.content) {
throw new AIProviderError(
@@ -556,7 +556,7 @@ export class OpenWebUIProvider extends BaseAIProvider {
* @param response - Raw Ollama response
* @returns Formatted completion response
*/
private formatOllamaResponse(response: OllamaGenerateResponse): CompletionResponse {
private formatOllamaResponse(response: OllamaGenerateResponse): CompletionResponse<string> {
return {
content: response.response,
model: response.model,
@@ -962,7 +962,7 @@ export class OpenWebUIProvider extends BaseAIProvider {
* @returns Promise resolving to formatted completion response
* @throws {Error} If API request fails
*/
protected async doComplete<T = any>(params: CompletionParams<T>): Promise<CompletionResponse> {
protected async doComplete(params: CompletionParams): Promise<CompletionResponse<string>> {
if (this.useOllamaProxy) {
return this.completeWithOllama(params);
} else {

View File

@@ -89,9 +89,11 @@ export interface TokenUsage {
/**
* Response from an AI completion request
*/
export interface CompletionResponse {
/** Generated content */
content: string;
export interface CompletionResponse<T = string> {
/** Generated content, either as a string or a typed object */
content: T;
/** Raw response from the provider, if content is a typed object */
rawContent?: string;
/** Model used for generation */
model: string;
/** Token usage statistics */
@@ -220,55 +222,71 @@ export function createResponseType<T = any>(
export function generateResponseTypePrompt(responseType: ResponseType): string {
const { typeDefinition, description, example, strictJson } = responseType;
let prompt = `You must respond with a JSON object that matches the following TypeScript type definition:\n\n`;
prompt += `Type Definition:\n\`\`\`typescript\n${typeDefinition}\n\`\`\`\n\n`;
prompt += `Description: ${description}\n\n`;
let prompt = `You are an AI assistant that must respond with a JSON object. The JSON object must strictly adhere to the following TypeScript type definition:
`;
prompt += `Type Definition:
typescript
${typeDefinition}

`;
prompt += `Description: ${description}
`;
if (example) {
prompt += `Example:\n\`\`\`json\n${JSON.stringify(example, null, 2)}\n\`\`\`\n\n`;
prompt += `Example of the expected JSON output:
json
${JSON.stringify(example, null, 2)}

`;
}
if (strictJson) {
prompt += `IMPORTANT: Your response must be valid JSON only. Do not include any text before or after the JSON object. Do not use markdown formatting.`;
prompt += `IMPORTANT: Your entire response must be a single, valid JSON object that conforms to the type definition above. Do not include any additional text, explanations, or markdown formatting before or after the JSON object.`;
} else {
prompt += `Your response should follow the structure defined above.`;
prompt += `Your response should contain a JSON object that follows the structure defined above.`;
}
return prompt;
}
/**
* Validates that a response matches the expected type structure
* Parses and validates that a response matches the expected type structure
*
* @param response - The response content to validate
* @param responseType - The expected response type
* @returns Object with validation result and parsed data
* @returns The parsed and validated data object
* @throws {AIProviderError} If parsing or validation fails
*/
export function validateResponseType<T = any>(
export function parseAndValidateResponseType<T = any>(
response: string,
responseType: ResponseType<T>
): { isValid: boolean; data?: T; error?: string } {
): T {
try {
// Parse JSON response
// Attempt to parse the JSON response
const parsed = JSON.parse(response);
// Basic validation - in a real implementation, you might want to use
// a schema validation library like zod or ajv for more thorough validation
// Basic validation: ensure it's a non-null object.
// For more robust validation, a library like Zod or Ajv could be used
// based on the typeDefinition, but that's beyond the current scope.
if (typeof parsed !== 'object' || parsed === null) {
return {
isValid: false,
error: 'Response must be a JSON object'
};
throw new Error('Response must be a JSON object');
}
return {
isValid: true,
data: parsed as T
};
// Here you could add more sophisticated validation if needed, e.g.,
// checking keys against the responseType.typeDefinition
return parsed as T;
} catch (error) {
return {
isValid: false,
error: `Invalid JSON: ${(error as Error).message}`
};
// Wrap parsing/validation errors in a standardized AIProviderError
throw new AIProviderError(
`Failed to parse or validate structured response: ${(error as Error).message}`,
AIErrorType.INVALID_REQUEST,
undefined,
error as Error
);
}
}

View File

@@ -2,8 +2,8 @@
* Tests for Claude Provider
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import { ClaudeProvider, AIProviderError, AIErrorType } from '../src/index.js';
import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { ClaudeProvider, createClaudeProvider, createResponseType, parseAndValidateResponseType, AIProviderError, AIErrorType } from '../src/index.js';
describe('ClaudeProvider', () => {
let provider: ClaudeProvider;
@@ -135,4 +135,32 @@ describe('ClaudeProvider', () => {
expect(result.messages).toHaveLength(2);
});
});
describe('structured responses', () => {
it('should handle structured responses correctly', async () => {
const provider = new ClaudeProvider({ apiKey: 'test-key' });
const mockResponse = {
content: JSON.stringify({ name: 'John Doe', age: 30 }),
model: 'claude-3-5-sonnet-20241022',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
id: 'test-id'
};
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
(provider as any).initialized = true;
const responseType = createResponseType<{ name: string; age: number }>(
`{ name: string; age: number }`,
'A user profile'
);
const response = await provider.complete<{ name: string; age: number }>({
messages: [{ role: 'user', content: 'test' }],
responseType
});
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 }));
expect((provider as any).doComplete).toHaveBeenCalled();
});
});
});

View File

@@ -2,8 +2,8 @@
* Tests for Gemini Provider
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import { GeminiProvider, AIProviderError, AIErrorType } from '../src/index.js';
import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { GeminiProvider, createResponseType, AIProviderError, AIErrorType } from '../src/index.js';
describe('GeminiProvider', () => {
let provider: GeminiProvider;
@@ -353,4 +353,32 @@ describe('GeminiProvider', () => {
}).toThrow('No candidates found in Gemini response');
});
});
describe('structured responses', () => {
it('should handle structured responses correctly', async () => {
const provider = new GeminiProvider({ apiKey: 'test-key' });
const mockResponse = {
content: JSON.stringify({ name: 'John Doe', age: 30 }),
model: 'gemini-1.5-pro',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
id: 'test-id'
};
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
(provider as any).initialized = true;
const responseType = createResponseType<{ name: string; age: number }>
(`{ name: string; age: number }`,
'A user profile'
);
const response = await provider.complete<{ name: string; age: number }>({
messages: [{ role: 'user', content: 'test' }],
responseType
});
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 }));
expect((provider as any).doComplete).toHaveBeenCalled();
});
});
});

View File

@@ -2,8 +2,8 @@
* Tests for OpenAI Provider
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import { OpenAIProvider, AIProviderError, AIErrorType } from '../src/index.js';
import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { OpenAIProvider, createResponseType, AIProviderError, AIErrorType } from '../src/index.js';
describe('OpenAIProvider', () => {
let provider: OpenAIProvider;
@@ -258,4 +258,32 @@ describe('OpenAIProvider', () => {
}).toThrow('No content found in OpenAI response');
});
});
describe('structured responses', () => {
it('should handle structured responses correctly', async () => {
const provider = new OpenAIProvider({ apiKey: 'test-key' });
const mockResponse = {
content: JSON.stringify({ name: 'John Doe', age: 30 }),
model: 'gpt-4',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
id: 'test-id'
};
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
(provider as any).initialized = true;
const responseType = createResponseType<{ name: string; age: number }>
(`{ name: string; age: number }`,
'A user profile'
);
const response = await provider.complete<{ name: string; age: number }>({
messages: [{ role: 'user', content: 'test' }],
responseType
});
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 }));
expect((provider as any).doComplete).toHaveBeenCalled();
});
});
});

View File

@@ -2,9 +2,9 @@
* Tests for OpenWebUI provider implementation
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { OpenWebUIProvider, type OpenWebUIConfig } from '../src/providers/openwebui.js';
import { AIProviderError, AIErrorType, type CompletionParams } from '../src/types/index.js';
import { AIProviderError, AIErrorType, type CompletionParams, createResponseType } from '../src/types/index.js';
describe('OpenWebUIProvider', () => {
let provider: OpenWebUIProvider;
@@ -416,4 +416,32 @@ describe('OpenWebUIProvider', () => {
await expect(provider.complete(params)).rejects.toThrow('Provider must be initialized before use');
});
});
describe('structured responses', () => {
it('should handle structured responses correctly', async () => {
const provider = new OpenWebUIProvider({ apiKey: 'test-key' });
const mockResponse = {
content: JSON.stringify({ name: 'John Doe', age: 30 }),
model: 'llama3.1',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
id: 'test-id'
};
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
(provider as any).initialized = true;
const responseType = createResponseType<{ name: string; age: number }>
(`{ name: string; age: number }`,
'A user profile'
);
const response = await provider.complete<{ name: string; age: number }>({
messages: [{ role: 'user', content: 'test' }],
responseType
});
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 }));
expect((provider as any).doComplete).toHaveBeenCalled();
});
});
});