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:
109
README.md
109
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +117,9 @@ 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);
|
|
||||||
|
|
||||||
if (validation.isValid) {
|
|
||||||
const userProfile = validation.data as UserProfile;
|
|
||||||
console.log('\nParsed User Profile:');
|
console.log('\nParsed User Profile:');
|
||||||
console.log(`Name: ${userProfile.name}`);
|
console.log(`Name: ${userProfile.name}`);
|
||||||
console.log(`Age: ${userProfile.age}`);
|
console.log(`Age: ${userProfile.age}`);
|
||||||
@@ -163,9 +127,6 @@ async function demonstrateUserProfileGeneration() {
|
|||||||
console.log(`Theme Preference: ${userProfile.preferences.theme}`);
|
console.log(`Theme Preference: ${userProfile.preferences.theme}`);
|
||||||
console.log(`Notifications: ${userProfile.preferences.notifications}`);
|
console.log(`Notifications: ${userProfile.preferences.notifications}`);
|
||||||
console.log(`Skills: ${userProfile.skills.join(', ')}`);
|
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,12 +156,9 @@ 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;
|
||||||
|
|
||||||
if (validation.isValid) {
|
|
||||||
const analysis = validation.data as ProductAnalysis;
|
|
||||||
console.log('\nProduct Analysis:');
|
console.log('\nProduct Analysis:');
|
||||||
console.log(`Product: ${analysis.productName}`);
|
console.log(`Product: ${analysis.productName}`);
|
||||||
console.log(`Category: ${analysis.category}`);
|
console.log(`Category: ${analysis.category}`);
|
||||||
@@ -210,9 +168,6 @@ async function demonstrateProductAnalysis() {
|
|||||||
console.log(`Pros: ${analysis.pros.join(', ')}`);
|
console.log(`Pros: ${analysis.pros.join(', ')}`);
|
||||||
console.log(`Cons: ${analysis.cons.join(', ')}`);
|
console.log(`Cons: ${analysis.cons.join(', ')}`);
|
||||||
console.log(`Reasoning: ${analysis.reasoning}`);
|
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,11 +203,14 @@ 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,
|
||||||
@@ -260,12 +218,9 @@ function processPayment(amount, cardNumber) {
|
|||||||
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;
|
||||||
|
|
||||||
if (validation.isValid) {
|
|
||||||
const review = validation.data as CodeReview;
|
|
||||||
console.log('\nCode Review:');
|
console.log('\nCode Review:');
|
||||||
console.log(`Overall Score: ${review.overallScore}/10`);
|
console.log(`Overall Score: ${review.overallScore}/10`);
|
||||||
console.log(`Summary: ${review.summary}`);
|
console.log(`Summary: ${review.summary}`);
|
||||||
@@ -278,9 +233,6 @@ function processPayment(amount, cardNumber) {
|
|||||||
const lineInfo = issue.line ? ` (line ${issue.line})` : '';
|
const lineInfo = issue.line ? ` (line ${issue.line})` : '';
|
||||||
console.log(` • [${issue.severity.toUpperCase()}] ${issue.type}: ${issue.message}${lineInfo}`);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 */
|
||||||
@@ -169,7 +171,6 @@ export interface ProviderInfo {
|
|||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user