Merge pull request 'feat: Add typed stuff' (#1) from jank/simple-ai-provider:main into main

Reviewed-on: #1
Reviewed-by: Jan-Marlon Leibl <jleibl@proton.me>
This commit is contained in:
2025-09-04 13:35:24 +00:00
committed by Forgejo Instance
15 changed files with 313 additions and 212 deletions

109
README.md
View File

@@ -86,66 +86,85 @@ const provider = createProvider('claude', { apiKey: 'your-key' });
## 🎨 Structured Response Types ## 🎨 Structured Response Types
Define custom response types for type-safe, structured AI outputs: Define custom response types for type-safe, structured AI outputs. The library automatically parses the AI's response into your desired type.
```typescript ```typescript
import { createResponseType, validateResponseType } from 'simple-ai-provider'; import { createResponseType, createClaudeProvider } from 'simple-ai-provider';
// Define your response type // 1. Define your response type
interface UserProfile { interface ProductAnalysis {
name: string; productName: string;
age: number; priceRange: 'budget' | 'mid-range' | 'premium';
email: string; pros: string[];
preferences: { cons: string[];
theme: 'light' | 'dark'; overallRating: number; // 1-10 scale
notifications: boolean; recommendation: 'buy' | 'consider' | 'avoid';
};
} }
const userProfileType = createResponseType<UserProfile>( // 2. Create a ResponseType object
`{ const productAnalysisType = createResponseType<ProductAnalysis>(
name: string; 'A comprehensive product analysis with pros, cons, rating, and recommendation'
age: number;
email: string;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
}`,
'A user profile with personal information and preferences',
{
name: 'John Doe',
age: 30,
email: 'john@example.com',
preferences: { theme: 'dark', notifications: true }
}
); );
// Use with any provider // 3. Use with any provider
const response = await claude.complete({ const claude = createClaudeProvider({ apiKey: 'your-key' });
await claude.initialize();
const response = await claude.complete<ProductAnalysis>({
messages: [ messages: [
{ role: 'user', content: 'Generate a user profile for a software developer' } { role: 'user', content: 'Analyze the iPhone 15 Pro from a consumer perspective.' }
], ],
responseType: userProfileType, responseType: productAnalysisType,
maxTokens: 500 maxTokens: 800
}); });
// Validate and get typed response // 4. Get the fully typed and parsed response
const validation = validateResponseType(response.content, userProfileType); const analysis = response.content;
if (validation.isValid) { console.log(`Product: ${analysis.productName}`);
const userProfile = validation.data as UserProfile; console.log(`Recommendation: ${analysis.recommendation}`);
console.log(`Name: ${userProfile.name}`); console.log(`Rating: ${analysis.overallRating}/10`);
console.log(`Theme: ${userProfile.preferences.theme}`);
}
``` ```
### Key Benefits ### Key Benefits
- **Type Safety**: Get fully typed responses from AI providers - **Automatic Parsing**: The AI's JSON response is automatically parsed into your specified type.
- **Automatic Prompting**: System prompts are automatically generated - **Type Safety**: Get fully typed responses from AI providers with IntelliSense.
- **Validation**: Built-in response validation and parsing - **Automatic Prompting**: System prompts are automatically generated to guide the AI.
- **Consistency**: Ensures AI outputs match your expected format - **Validation**: Built-in response validation and parsing logic.
- **Developer Experience**: IntelliSense and compile-time type checking - **Consistency**: Ensures AI outputs match your expected format.
- **Developer Experience**: Catch errors at compile-time instead of runtime.
### Streaming with Response Types
You can also use response types with streaming. The raw stream provides real-time text, and you can parse the final string once the stream is complete.
```typescript
import { parseAndValidateResponseType } from 'simple-ai-provider';
const stream = claude.stream({
messages: [{ role: 'user', content: 'Analyze the Tesla Model 3.' }],
responseType: productAnalysisType,
maxTokens: 600
});
let fullResponse = '';
for await (const chunk of stream) {
if (!chunk.isComplete) {
process.stdout.write(chunk.content);
fullResponse += chunk.content;
} else {
console.log('\n\nStream complete!');
// Validate the complete streamed response
try {
const analysis = parseAndValidateResponseType(fullResponse, productAnalysisType);
console.log('Validation successful!');
console.log(`Product: ${analysis.productName}`);
} catch (e) {
console.error('Validation failed:', (e as Error).message);
}
}
}
```
## 📝 Environment Variables ## 📝 Environment Variables

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -9,7 +9,7 @@
import { import {
createClaudeProvider, createClaudeProvider,
createResponseType, createResponseType,
validateResponseType, parseAndValidateResponseType,
AIProviderError, AIProviderError,
AIErrorType AIErrorType
} from '../src/index.js'; } from '../src/index.js';
@@ -31,16 +31,6 @@ interface UserProfile {
} }
const userProfileType = createResponseType<UserProfile>( const userProfileType = createResponseType<UserProfile>(
`{
name: string;
age: number;
email: string;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
skills: string[];
}`,
'A user profile with personal information, preferences, and skills', 'A user profile with personal information, preferences, and skills',
{ {
name: 'John Doe', name: 'John Doe',
@@ -64,16 +54,6 @@ interface ProductAnalysis {
} }
const productAnalysisType = createResponseType<ProductAnalysis>( const productAnalysisType = createResponseType<ProductAnalysis>(
`{
productName: string;
category: string;
priceRange: 'budget' | 'mid-range' | 'premium';
pros: string[];
cons: string[];
overallRating: number;
recommendation: 'buy' | 'consider' | 'avoid';
reasoning: string;
}`,
'A comprehensive product analysis with pros, cons, rating, and recommendation', 'A comprehensive product analysis with pros, cons, rating, and recommendation',
{ {
productName: 'Example Product', productName: 'Example Product',
@@ -102,18 +82,6 @@ interface CodeReview {
} }
const codeReviewType = createResponseType<CodeReview>( const codeReviewType = createResponseType<CodeReview>(
`{
overallScore: number;
issues: Array<{
type: 'error' | 'warning' | 'suggestion';
line?: number;
message: string;
severity: 'low' | 'medium' | 'high';
}>;
strengths: string[];
improvements: string[];
summary: string;
}`,
'A comprehensive code review with scoring, issues, and recommendations', 'A comprehensive code review with scoring, issues, and recommendations',
{ {
overallScore: 8, overallScore: 8,
@@ -137,7 +105,7 @@ async function demonstrateUserProfileGeneration() {
await claude.initialize(); await claude.initialize();
try { try {
const response = await claude.complete({ const response = await claude.complete<UserProfile>({
messages: [ messages: [
{ {
role: 'user', role: 'user',
@@ -149,23 +117,16 @@ async function demonstrateUserProfileGeneration() {
temperature: 0.7 temperature: 0.7
}); });
console.log('Raw response:', response.content); console.log('Raw response:', response.rawContent);
// Validate and parse the response const userProfile = response.content;
const validation = validateResponseType(response.content, userProfileType); console.log('\nParsed User Profile:');
console.log(`Name: ${userProfile.name}`);
if (validation.isValid) { console.log(`Age: ${userProfile.age}`);
const userProfile = validation.data as UserProfile; console.log(`Email: ${userProfile.email}`);
console.log('\nParsed User Profile:'); console.log(`Theme Preference: ${userProfile.preferences.theme}`);
console.log(`Name: ${userProfile.name}`); console.log(`Notifications: ${userProfile.preferences.notifications}`);
console.log(`Age: ${userProfile.age}`); console.log(`Skills: ${userProfile.skills.join(', ')}`);
console.log(`Email: ${userProfile.email}`);
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) { } catch (error) {
if (error instanceof AIProviderError) { if (error instanceof AIProviderError) {
@@ -183,7 +144,7 @@ async function demonstrateProductAnalysis() {
await claude.initialize(); await claude.initialize();
try { try {
const response = await claude.complete({ const response = await claude.complete<ProductAnalysis>({
messages: [ messages: [
{ {
role: 'user', role: 'user',
@@ -195,24 +156,18 @@ async function demonstrateProductAnalysis() {
temperature: 0.5 temperature: 0.5
}); });
console.log('Raw response:', response.content); console.log('Raw response:', response.rawContent);
const validation = validateResponseType(response.content, productAnalysisType); const analysis = response.content;
console.log('\nProduct Analysis:');
if (validation.isValid) { console.log(`Product: ${analysis.productName}`);
const analysis = validation.data as ProductAnalysis; console.log(`Category: ${analysis.category}`);
console.log('\nProduct Analysis:'); console.log(`Price Range: ${analysis.priceRange}`);
console.log(`Product: ${analysis.productName}`); console.log(`Overall Rating: ${analysis.overallRating}/10`);
console.log(`Category: ${analysis.category}`); console.log(`Recommendation: ${analysis.recommendation}`);
console.log(`Price Range: ${analysis.priceRange}`); console.log(`Pros: ${analysis.pros.join(', ')}`);
console.log(`Overall Rating: ${analysis.overallRating}/10`); console.log(`Cons: ${analysis.cons.join(', ')}`);
console.log(`Recommendation: ${analysis.recommendation}`); console.log(`Reasoning: ${analysis.reasoning}`);
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) { } catch (error) {
if (error instanceof AIProviderError) { if (error instanceof AIProviderError) {
@@ -248,39 +203,36 @@ function processPayment(amount, cardNumber) {
`; `;
try { try {
const response = await claude.complete({ const response = await claude.complete<CodeReview>({
messages: [ messages: [
{ {
role: 'user', 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, responseType: codeReviewType,
maxTokens: 1000, maxTokens: 1000,
temperature: 0.3 temperature: 0.3
}); });
console.log('Raw response:', response.content); console.log('Raw response:', response.rawContent);
const validation = validateResponseType(response.content, codeReviewType); const review = response.content;
console.log('\nCode Review:');
if (validation.isValid) { console.log(`Overall Score: ${review.overallScore}/10`);
const review = validation.data as CodeReview; console.log(`Summary: ${review.summary}`);
console.log('\nCode Review:'); console.log(`\nStrengths:`);
console.log(`Overall Score: ${review.overallScore}/10`); review.strengths.forEach(strength => console.log(`${strength}`));
console.log(`Summary: ${review.summary}`); console.log(`\nImprovements:`);
console.log(`\nStrengths:`); review.improvements.forEach(improvement => console.log(`${improvement}`));
review.strengths.forEach(strength => console.log(`${strength}`)); console.log(`\nIssues:`);
console.log(`\nImprovements:`); review.issues.forEach(issue => {
review.improvements.forEach(improvement => console.log(`${improvement}`)); const lineInfo = issue.line ? ` (line ${issue.line})` : '';
console.log(`\nIssues:`); console.log(` • [${issue.severity.toUpperCase()}] ${issue.type}: ${issue.message}${lineInfo}`);
review.issues.forEach(issue => { });
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) { } catch (error) {
if (error instanceof AIProviderError) { if (error instanceof AIProviderError) {
@@ -319,11 +271,12 @@ async function demonstrateStreamingWithResponseType() {
console.log('\n\nStream completed. Usage:', chunk.usage); console.log('\n\nStream completed. Usage:', chunk.usage);
// Validate the complete streamed response // Validate the complete streamed response
const validation = validateResponseType(fullResponse, productAnalysisType); try {
if (validation.isValid) { const analysis = parseAndValidateResponseType(fullResponse, productAnalysisType);
console.log('\nStreamed response validation: SUCCESS'); console.log('\nStreamed response validation: SUCCESS');
} else { console.log(`Product: ${analysis.productName}`);
console.log('\nStreamed response validation: FAILED -', validation.error); } catch (e) {
console.log('\nStreamed response validation: FAILED -', (e as Error).message);
} }
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "simple-ai-provider", "name": "simple-ai-provider",
"version": "1.2.0", "version": "1.3.0",
"description": "A simple and extensible AI provider package for easy integration of multiple AI services", "description": "A simple and extensible AI provider package for easy integration of multiple AI services",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.mjs", "module": "dist/index.mjs",

View File

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

View File

@@ -22,7 +22,7 @@ import type {
ProviderInfo, ProviderInfo,
ResponseType ResponseType
} from '../types/index.js'; } 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 // ABSTRACT BASE PROVIDER CLASS
@@ -148,7 +148,9 @@ export abstract class BaseAIProvider {
* console.log(response.content); * 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 // Ensure provider is ready for use
this.ensureInitialized(); this.ensureInitialized();
@@ -160,7 +162,23 @@ export abstract class BaseAIProvider {
const processedParams = this.processResponseType(params); const processedParams = this.processResponseType(params);
// Delegate to provider-specific implementation // 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) { } catch (error) {
// Normalize error to our standard format // Normalize error to our standard format
throw this.normalizeError(error as Error); throw this.normalizeError(error as Error);
@@ -249,7 +267,7 @@ export abstract class BaseAIProvider {
* @returns Promise resolving to completion response * @returns Promise resolving to completion response
* @throws {Error} If completion fails (will be normalized to AIProviderError) * @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. * Provider-specific streaming implementation.

View File

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

View File

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

View File

@@ -244,7 +244,7 @@ export class OpenAIProvider extends BaseAIProvider {
* @returns Promise resolving to formatted completion response * @returns Promise resolving to formatted completion response
* @throws {Error} If API request fails * @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) { if (!this.client) {
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
} }
@@ -537,7 +537,7 @@ export class OpenAIProvider extends BaseAIProvider {
* @returns Formatted completion response * @returns Formatted completion response
* @throws {AIProviderError} If response format is unexpected * @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]; const choice = response.choices[0];
if (!choice || !choice.message.content) { if (!choice || !choice.message.content) {
throw new AIProviderError( throw new AIProviderError(

View File

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

View File

@@ -41,7 +41,7 @@ export interface AIMessage {
*/ */
export interface ResponseType<T = any> { export interface ResponseType<T = any> {
/** The TypeScript type definition as a string */ /** The TypeScript type definition as a string */
typeDefinition: string; typeDefinition?: string;
/** Human-readable description of the expected response format */ /** Human-readable description of the expected response format */
description: string; description: string;
/** Example of the expected response structure */ /** Example of the expected response structure */
@@ -89,9 +89,11 @@ export interface TokenUsage {
/** /**
* Response from an AI completion request * Response from an AI completion request
*/ */
export interface CompletionResponse { export interface CompletionResponse<T = string> {
/** Generated content */ /** Generated content, either as a string or a typed object */
content: string; content: T;
/** Raw response from the provider, if content is a typed object */
rawContent?: string;
/** Model used for generation */ /** Model used for generation */
model: string; model: string;
/** Token usage statistics */ /** Token usage statistics */
@@ -162,14 +164,13 @@ export interface ProviderInfo {
capabilities?: Record<string, any>; capabilities?: Record<string, any>;
} }
// ============================================================================ // ============================================================================
// RESPONSE TYPE UTILITIES // RESPONSE TYPE UTILITIES
// ============================================================================ // ============================================================================
/** /**
* Creates a response type definition for structured AI outputs * Creates a response type definition for structured AI outputs
* *
* @param typeDefinition - TypeScript type definition as a string
* @param description - Human-readable description of the expected format * @param description - Human-readable description of the expected format
* @param example - Optional example of the expected response structure * @param example - Optional example of the expected response structure
* @param strictJson - Whether to enforce strict JSON formatting (default: true) * @param strictJson - Whether to enforce strict JSON formatting (default: true)
@@ -178,15 +179,6 @@ export interface ProviderInfo {
* @example * @example
* ```typescript * ```typescript
* const userType = createResponseType( * const userType = createResponseType(
* `{
* name: string;
* age: number;
* email: string;
* preferences: {
* theme: 'light' | 'dark';
* notifications: boolean;
* };
* }`,
* 'A user profile with personal information and preferences', * 'A user profile with personal information and preferences',
* { * {
* name: 'John Doe', * name: 'John Doe',
@@ -198,13 +190,11 @@ export interface ProviderInfo {
* ``` * ```
*/ */
export function createResponseType<T = any>( export function createResponseType<T = any>(
typeDefinition: string,
description: string, description: string,
example?: T, example?: T,
strictJson: boolean = true strictJson: boolean = true
): ResponseType<T> { ): ResponseType<T> {
return { return {
typeDefinition: typeDefinition.trim(),
description, description,
example, example,
strictJson strictJson
@@ -220,55 +210,64 @@ export function createResponseType<T = any>(
export function generateResponseTypePrompt(responseType: ResponseType): string { export function generateResponseTypePrompt(responseType: ResponseType): string {
const { typeDefinition, description, example, strictJson } = responseType; const { typeDefinition, description, example, strictJson } = responseType;
let prompt = `You must respond with a JSON object that matches the following TypeScript type definition:\n\n`; let prompt = 'You are an AI assistant that must respond with a JSON object.';
prompt += `Type Definition:\n\`\`\`typescript\n${typeDefinition}\n\`\`\`\n\n`;
prompt += `Description: ${description}\n\n`; if (typeDefinition) {
prompt += ' The JSON object must strictly adhere to the following TypeScript type definition:\n\n';
prompt += 'Type Definition:\n```typescript\n' + typeDefinition + '\n```\n\n';
} else {
prompt += '\n\n';
}
prompt += 'Description: ' + description + '\n\n';
if (example) { if (example) {
prompt += `Example:\n\`\`\`json\n${JSON.stringify(example, null, 2)}\n\`\`\`\n\n`; prompt += 'Example of the expected JSON output:\n```json\n' + JSON.stringify(example, null, 2) + '\n```\n\n';
} }
if (strictJson) { 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. Do not include any additional text, explanations, or markdown formatting before or after the JSON object.';
} else { } 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; 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 response - The response content to validate
* @param responseType - The expected response type * @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, response: string,
responseType: ResponseType<T> responseType: ResponseType<T>
): { isValid: boolean; data?: T; error?: string } { ): T {
try { try {
// Parse JSON response // Attempt to parse the JSON response
const parsed = JSON.parse(response); const parsed = JSON.parse(response);
// Basic validation - in a real implementation, you might want to use // Basic validation: ensure it's a non-null object.
// a schema validation library like zod or ajv for more thorough validation // 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) { if (typeof parsed !== 'object' || parsed === null) {
return { throw new Error('Response must be a JSON object');
isValid: false,
error: 'Response must be a JSON object'
};
} }
return { // Here you could add more sophisticated validation if needed, e.g.,
isValid: true, // checking keys against the responseType.typeDefinition
data: parsed as T
}; return parsed as T;
} catch (error) { } catch (error) {
return { // Wrap parsing/validation errors in a standardized AIProviderError
isValid: false, throw new AIProviderError(
error: `Invalid JSON: ${(error as Error).message}` `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 * Tests for Claude Provider
*/ */
import { describe, it, expect, beforeEach } from 'bun:test'; import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { ClaudeProvider, AIProviderError, AIErrorType } from '../src/index.js'; import { ClaudeProvider, createClaudeProvider, createResponseType, parseAndValidateResponseType, AIProviderError, AIErrorType } from '../src/index.js';
describe('ClaudeProvider', () => { describe('ClaudeProvider', () => {
let provider: ClaudeProvider; let provider: ClaudeProvider;
@@ -135,4 +135,32 @@ describe('ClaudeProvider', () => {
expect(result.messages).toHaveLength(2); 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 * Tests for Gemini Provider
*/ */
import { describe, it, expect, beforeEach } from 'bun:test'; import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { GeminiProvider, AIProviderError, AIErrorType } from '../src/index.js'; import { GeminiProvider, createResponseType, AIProviderError, AIErrorType } from '../src/index.js';
describe('GeminiProvider', () => { describe('GeminiProvider', () => {
let provider: GeminiProvider; let provider: GeminiProvider;
@@ -353,4 +353,32 @@ describe('GeminiProvider', () => {
}).toThrow('No candidates found in Gemini response'); }).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 * Tests for OpenAI Provider
*/ */
import { describe, it, expect, beforeEach } from 'bun:test'; import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { OpenAIProvider, AIProviderError, AIErrorType } from '../src/index.js'; import { OpenAIProvider, createResponseType, AIProviderError, AIErrorType } from '../src/index.js';
describe('OpenAIProvider', () => { describe('OpenAIProvider', () => {
let provider: OpenAIProvider; let provider: OpenAIProvider;
@@ -201,7 +201,7 @@ describe('OpenAIProvider', () => {
it('should handle messages with metadata', () => { it('should handle messages with metadata', () => {
const messages = [ const messages = [
{ {
role: 'user' as const, role: 'user' as const,
content: 'Hello', content: 'Hello',
metadata: { timestamp: '2024-01-01' } metadata: { timestamp: '2024-01-01' }
@@ -258,4 +258,32 @@ describe('OpenAIProvider', () => {
}).toThrow('No content found in OpenAI response'); }).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 * 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 { 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', () => { describe('OpenWebUIProvider', () => {
let provider: OpenWebUIProvider; let provider: OpenWebUIProvider;
@@ -416,4 +416,32 @@ describe('OpenWebUIProvider', () => {
await expect(provider.complete(params)).rejects.toThrow('Provider must be initialized before use'); 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();
});
});
});