Compare commits

...
9 Commits
Author SHA1 Message Date
Mervin Praison 307df443ef Release v2.8.1 2025-12-30 17:47:16 +00:00
Mervin Praison 6434a87b60 chore: remove completed AI SDK verification doc and bump version to 1.3.6
- Remove ai-sdk-backbone-verification.md (all tasks completed)
- Update package.json version from 1.3.5 to 1.3.6
- Refactor agent classes to use lazy-loaded AI SDK backend via resolveBackend()
- Convert direct createProvider() calls to async getProvider() pattern
- Add provider promise caching to prevent duplicate backend initialization
- Update ContextAgent, EnhancedAgent, ImageAgent, and PromptExpanderAgent classes
2025-12-30 17:15:40 +00:00
Mervin Praison 97d5b6db0d feat: add AI SDK backend support and embedding capabilities to Agent class
- Add AI SDK backend resolution with lazy initialization via getBackend()
- Add embed() method supporting single/batch text embeddings
- Add getBackendSource() to check backend type (ai-sdk/native/custom/legacy)
- Add getModel() to retrieve agent's model string
- Parse model string to extract provider/model ID for backend selection
- Use AI SDK backend for non-OpenAI providers (streaming and non-streaming)
- Export backend resolver an
2025-12-30 16:52:46 +00:00
Mervin Praison 4996a9f9fa Merge remote-tracking branch 'origin/pr-1097-fixes' 2025-12-30 16:15:49 +00:00
Mervin Praison 54f4a96cba test: add comprehensive ImportError tests for MCP optional import
- Add test for MCP class ImportError when mcp package unavailable
- Add test for SSEMCPClient ImportError when mcp unavailable
- Add test for HTTPStreamMCPClient ImportError when mcp unavailable
- Add test for HTTPStreamMCPClient ImportError when aiohttp unavailable
- Fix unused import lint warnings (remove sys, add noqa for MCP)

These tests verify that actionable error messages with install instructions
are shown when users try to use MCP functionality without the mcp package.
2025-12-30 16:14:15 +00:00
Mervin Praison e59be8f0fd Merge pull request #1096 from majiayu000/fix/issue-1091-mcp-optional-import
fix: make MCP package import optional to prevent errors when not installed
2025-12-30 16:09:06 +00:00
Mervin Praison b52708f3f6 Release v1.3.4 2025-12-30 15:10:46 +00:00
Mervin Praison ba37ce39d3 Release v2.8.0 2025-12-30 15:08:22 +00:00
majiayu000 98c5e8f175 fix: make MCP package import optional to prevent errors when not installed
Wrap mcp imports in try/except blocks in mcp.py, mcp_sse.py, and
mcp_http_stream.py. Add MCP_AVAILABLE flag to check availability at runtime.
When MCP class is instantiated without the mcp package installed, raise a
clear ImportError with installation instructions.

Closes #1091

Signed-off-by: majiayu000 <[email protected]>
2025-12-30 19:53:45 +08:00
62 changed files with 7853 additions and 72 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.9" \
"praisonai>=2.8.1" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.9" \
"praisonai>=2.8.1" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.9" \
"praisonai>=2.8.1" \
"praisonai[ui]" \
"praisonai[crewai]"
+41
View File
@@ -0,0 +1,41 @@
# AI SDK Examples
Examples demonstrating the AI SDK integration in PraisonAI TypeScript.
## Prerequisites
```bash
npm install praisonai ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google zod
```
## Environment Variables
```bash
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...
export GOOGLE_API_KEY=AIza...
```
## Examples
| File | Description |
|------|-------------|
| `basic-chat.ts` | Simple text generation |
| `streaming.ts` | Streaming text output |
| `tool-calling.ts` | Function/tool calling |
| `multi-provider.ts` | Using multiple providers |
| `structured-output.ts` | JSON schema output |
| `multi-agent.ts` | Multi-agent attribution |
## Running Examples
```bash
npx ts-node basic-chat.ts
npx ts-node streaming.ts
npx ts-node tool-calling.ts
```
## More Information
- [AI SDK Documentation](https://docs.praisonai.com/docs/js/ai-sdk)
- [AI SDK CLI](https://docs.praisonai.com/docs/js/ai-sdk-cli)
+39
View File
@@ -0,0 +1,39 @@
/**
* AI SDK Basic Chat Example
*
* Demonstrates simple text generation using the AI SDK backend.
*
* Usage:
* npx ts-node basic-chat.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from 'praisonai';
async function main() {
// Create backend with model string
const backend = createAISDKBackend('openai/gpt-4o-mini');
console.log('Sending message to OpenAI...\n');
// Generate text
const result = await backend.generateText({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'What are the three primary colors?' }
],
maxTokens: 200,
temperature: 0.7,
});
console.log('Response:', result.text);
console.log('\nFinish reason:', result.finishReason);
if (result.usage) {
console.log('Token usage:', result.usage);
}
}
main().catch(console.error);
+96
View File
@@ -0,0 +1,96 @@
/**
* AI SDK Multi-Agent Attribution Example
*
* Demonstrates how to propagate attribution context (agentId, runId, traceId)
* to LLM calls for multi-agent observability and safety.
*
* Usage:
* npx ts-node multi-agent.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from 'praisonai';
// Simulate a multi-agent system with attribution
interface AgentContext {
agentId: string;
runId: string;
traceId: string;
sessionId?: string;
}
async function runAgent(name: string, task: string, context: AgentContext) {
console.log(`\n[${name}] Starting task: ${task}`);
console.log(` Attribution: agentId=${context.agentId}, runId=${context.runId}`);
const backend = createAISDKBackend('openai/gpt-4o-mini', {
attribution: context,
timeout: 30000,
});
const result = await backend.generateText({
messages: [
{ role: 'system', content: `You are ${name}, a specialized AI agent.` },
{ role: 'user', content: task }
],
maxTokens: 100,
});
console.log(` Response: ${result.text.slice(0, 100)}...`);
return result.text;
}
async function main() {
// Create a shared trace ID for the entire workflow
const traceId = `trace-${Date.now().toString(36)}`;
const sessionId = 'session-user123';
console.log('Multi-Agent Workflow with Attribution');
console.log('=====================================');
console.log(`Trace ID: ${traceId}`);
console.log(`Session ID: ${sessionId}`);
// Agent 1: Research Agent
const researchResult = await runAgent(
'ResearchAgent',
'What are the key benefits of TypeScript?',
{
agentId: 'agent-research',
runId: `run-${Date.now().toString(36)}-1`,
traceId,
sessionId,
}
);
// Agent 2: Summary Agent
const summaryResult = await runAgent(
'SummaryAgent',
`Summarize this in one sentence: ${researchResult}`,
{
agentId: 'agent-summary',
runId: `run-${Date.now().toString(36)}-2`,
traceId,
sessionId,
}
);
// Agent 3: Review Agent
await runAgent(
'ReviewAgent',
`Review this summary for accuracy: ${summaryResult}`,
{
agentId: 'agent-review',
runId: `run-${Date.now().toString(36)}-3`,
traceId,
sessionId,
}
);
console.log('\n\nWorkflow complete!');
console.log('All requests were tagged with the same trace ID for observability.');
console.log('Check your LLM provider dashboard to see the correlated requests.');
}
main().catch(console.error);
+59
View File
@@ -0,0 +1,59 @@
/**
* AI SDK Multi-Provider Example
*
* Demonstrates using multiple LLM providers with the same interface.
*
* Usage:
* npx ts-node multi-provider.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
* ANTHROPIC_API_KEY - Your Anthropic API key
* GOOGLE_API_KEY - Your Google API key
*/
import { createAISDKBackend } from 'praisonai';
const PROMPT = 'Explain what an API is in one sentence.';
async function testProvider(modelString: string) {
try {
const backend = createAISDKBackend(modelString, {
timeout: 30000,
});
const startTime = Date.now();
const result = await backend.generateText({
messages: [{ role: 'user', content: PROMPT }],
maxTokens: 100,
});
const duration = Date.now() - startTime;
console.log(`\n${modelString}:`);
console.log(` Response: ${result.text}`);
console.log(` Duration: ${duration}ms`);
return true;
} catch (error: any) {
console.log(`\n${modelString}:`);
console.log(` Error: ${error.message}`);
return false;
}
}
async function main() {
console.log('Testing multiple providers with the same prompt...');
console.log(`Prompt: "${PROMPT}"`);
const providers = [
'openai/gpt-4o-mini',
'anthropic/claude-3-haiku-20240307',
'google/gemini-1.5-flash',
];
const results = await Promise.all(providers.map(testProvider));
const successful = results.filter(Boolean).length;
console.log(`\n\nSummary: ${successful}/${providers.length} providers succeeded`);
}
main().catch(console.error);
+39
View File
@@ -0,0 +1,39 @@
/**
* AI SDK Streaming Example
*
* Demonstrates streaming text generation with real-time output.
*
* Usage:
* npx ts-node streaming.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from 'praisonai';
async function main() {
const backend = createAISDKBackend('openai/gpt-4o-mini');
console.log('Streaming response from OpenAI...\n');
const stream = await backend.streamText({
messages: [
{ role: 'user', content: 'Write a short poem about coding.' }
],
maxTokens: 200,
});
// Stream chunks as they arrive
for await (const chunk of stream) {
if (chunk.text) {
process.stdout.write(chunk.text);
}
if (chunk.finishReason) {
console.log('\n\nFinish reason:', chunk.finishReason);
}
}
}
main().catch(console.error);
+72
View File
@@ -0,0 +1,72 @@
/**
* AI SDK Structured Output Example
*
* Demonstrates generating typed JSON objects with Zod schemas.
*
* Usage:
* npx ts-node structured-output.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// Define schema for a person profile
const PersonSchema = z.object({
name: z.string().describe('Full name'),
age: z.number().describe('Age in years'),
occupation: z.string().describe('Job title'),
city: z.string().describe('City of residence'),
interests: z.array(z.string()).describe('List of hobbies/interests'),
bio: z.string().describe('Short biography'),
});
// Define schema for a recipe
const RecipeSchema = z.object({
name: z.string().describe('Recipe name'),
cuisine: z.string().describe('Type of cuisine'),
prepTime: z.number().describe('Preparation time in minutes'),
cookTime: z.number().describe('Cooking time in minutes'),
servings: z.number().describe('Number of servings'),
ingredients: z.array(z.object({
item: z.string(),
amount: z.string(),
})).describe('List of ingredients'),
instructions: z.array(z.string()).describe('Step-by-step instructions'),
});
async function main() {
console.log('Generating structured output with Zod schemas...\n');
// Generate a person profile
console.log('1. Generating a fictional person profile...');
const personResult = await generateObject({
model: openai('gpt-4o-mini'),
schema: PersonSchema,
prompt: 'Generate a fictional software engineer profile.',
});
console.log('Person:', JSON.stringify(personResult.object, null, 2));
// Generate a recipe
console.log('\n2. Generating a recipe...');
const recipeResult = await generateObject({
model: openai('gpt-4o-mini'),
schema: RecipeSchema,
prompt: 'Generate a simple pasta recipe.',
});
console.log('Recipe:', JSON.stringify(recipeResult.object, null, 2));
// Type-safe access
console.log('\n3. Type-safe access:');
console.log(` Person name: ${personResult.object.name}`);
console.log(` Person age: ${personResult.object.age}`);
console.log(` Recipe name: ${recipeResult.object.name}`);
console.log(` Total time: ${recipeResult.object.prepTime + recipeResult.object.cookTime} minutes`);
}
main().catch(console.error);
+75
View File
@@ -0,0 +1,75 @@
/**
* AI SDK Tool Calling Example
*
* Demonstrates function/tool calling with Zod schemas.
*
* Usage:
* npx ts-node tool-calling.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { generateText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// Define tools with Zod schemas
const tools = {
get_weather: tool({
description: 'Get the current weather for a city',
inputSchema: z.object({
city: z.string().describe('The city name'),
unit: z.enum(['celsius', 'fahrenheit']).optional().describe('Temperature unit'),
}),
execute: async ({ city, unit = 'celsius' }) => {
// Mock weather data
const weather = {
city,
temperature: unit === 'celsius' ? 22 : 72,
unit,
condition: 'sunny',
humidity: 45,
};
return weather;
},
}),
get_time: tool({
description: 'Get the current time in a timezone',
inputSchema: z.object({
timezone: z.string().describe('Timezone like "America/New_York"'),
}),
execute: async ({ timezone }) => {
const time = new Date().toLocaleString('en-US', { timeZone: timezone });
return { timezone, time };
},
}),
};
async function main() {
console.log('Asking about weather with tool calling...\n');
const result = await generateText({
model: openai('gpt-4o-mini'),
messages: [
{ role: 'user', content: 'What is the weather in Paris and what time is it in New York?' }
],
tools,
maxSteps: 5, // Allow multiple tool calls
});
console.log('Tool Calls:');
for (const toolCall of result.toolCalls) {
console.log(` - ${toolCall.toolName}:`, toolCall.args);
}
console.log('\nTool Results:');
for (const toolResult of result.toolResults) {
console.log(` - ${toolResult.toolName}:`, toolResult.result);
}
console.log('\nFinal Response:', result.text);
}
main().catch(console.error);
+65
View File
@@ -0,0 +1,65 @@
/**
* Basic Embedding Example
*
* Demonstrates using Agent.embed() for text embeddings
*
* Run: npx ts-node examples/js/embeddings/embed-basic.ts
*/
import { Agent } from '../../../src/praisonai-ts/dist/agent/simple';
import { embed, embedMany, cosineSimilarity } from '../../../src/praisonai-ts/dist/llm/embeddings';
async function main() {
console.log('=== Basic Embedding Example ===\n');
// Method 1: Using Agent.embed()
console.log('1. Agent.embed() - Single text');
const agent = new Agent({
instructions: 'You are a helpful assistant',
llm: 'openai/gpt-4o-mini',
verbose: false
});
const singleEmbedding = await agent.embed('Hello world');
console.log(` Dimensions: ${(singleEmbedding as number[]).length}`);
console.log(` First 5 values: [${(singleEmbedding as number[]).slice(0, 5).map(v => v.toFixed(4)).join(', ')}...]`);
// Method 2: Using Agent.embed() with multiple texts
console.log('\n2. Agent.embed() - Multiple texts');
const multiEmbeddings = await agent.embed(['Hello', 'World', 'How are you?']);
console.log(` Count: ${(multiEmbeddings as number[][]).length}`);
console.log(` Each has ${(multiEmbeddings as number[][])[0].length} dimensions`);
// Method 3: Direct embed function
console.log('\n3. Direct embed() function');
const result = await embed('PraisonAI is an AI agent framework');
console.log(` Dimensions: ${result.embedding.length}`);
console.log(` Tokens used: ${result.usage?.tokens || 'N/A'}`);
// Method 4: Batch embeddings
console.log('\n4. Batch embedMany() function');
const texts = [
'Machine learning is a subset of AI',
'Deep learning uses neural networks',
'Natural language processing handles text'
];
const batchResult = await embedMany(texts);
console.log(` Embedded ${batchResult.embeddings.length} texts`);
console.log(` Total tokens: ${batchResult.usage?.tokens || 'N/A'}`);
// Method 5: Similarity comparison
console.log('\n5. Cosine Similarity');
const emb1 = await embed('I love programming');
const emb2 = await embed('Coding is my passion');
const emb3 = await embed('The weather is nice today');
const sim12 = cosineSimilarity(emb1.embedding, emb2.embedding);
const sim13 = cosineSimilarity(emb1.embedding, emb3.embedding);
console.log(` "I love programming" vs "Coding is my passion": ${(sim12 * 100).toFixed(1)}%`);
console.log(` "I love programming" vs "The weather is nice": ${(sim13 * 100).toFixed(1)}%`);
console.log('\n=== Complete ===');
}
main().catch(console.error);
+119
View File
@@ -0,0 +1,119 @@
/**
* Document Embedding Example
*
* Demonstrates chunking and embedding documents for retrieval
*
* Run: npx ts-node examples/js/embeddings/embed-docs.ts
*/
import { embedMany, cosineSimilarity } from '../../../src/praisonai-ts/dist/llm/embeddings';
// Sample documents
const documents = [
{
id: 'doc1',
title: 'Introduction to AI',
content: 'Artificial Intelligence (AI) is the simulation of human intelligence by machines. It includes machine learning, natural language processing, and computer vision.'
},
{
id: 'doc2',
title: 'Machine Learning Basics',
content: 'Machine learning is a subset of AI that enables systems to learn from data. It includes supervised learning, unsupervised learning, and reinforcement learning.'
},
{
id: 'doc3',
title: 'Deep Learning',
content: 'Deep learning uses neural networks with multiple layers. It excels at tasks like image recognition, speech processing, and natural language understanding.'
},
{
id: 'doc4',
title: 'Natural Language Processing',
content: 'NLP enables computers to understand human language. Applications include chatbots, translation, sentiment analysis, and text summarization.'
},
{
id: 'doc5',
title: 'Computer Vision',
content: 'Computer vision allows machines to interpret visual information. It powers applications like facial recognition, autonomous vehicles, and medical imaging.'
}
];
interface EmbeddedDocument {
id: string;
title: string;
content: string;
embedding: number[];
}
async function main() {
console.log('=== Document Embedding Example ===\n');
// Step 1: Embed all documents
console.log('1. Embedding documents...');
const contents = documents.map(d => d.content);
const { embeddings } = await embedMany(contents);
const embeddedDocs: EmbeddedDocument[] = documents.map((doc, i) => ({
...doc,
embedding: embeddings[i]
}));
console.log(` Embedded ${embeddedDocs.length} documents`);
console.log(` Embedding dimensions: ${embeddings[0].length}`);
// Step 2: Search function
async function search(query: string, topK: number = 3): Promise<Array<{ doc: EmbeddedDocument; score: number }>> {
const { embedding: queryEmbedding } = await (await import('../../../src/praisonai-ts/dist/llm/embeddings')).embed(query);
const results = embeddedDocs.map(doc => ({
doc,
score: cosineSimilarity(queryEmbedding, doc.embedding)
}));
return results
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
// Step 3: Test queries
console.log('\n2. Testing search queries...\n');
const queries = [
'How do machines learn from data?',
'What is image recognition?',
'Tell me about chatbots and translation'
];
for (const query of queries) {
console.log(`Query: "${query}"`);
const results = await search(query);
console.log('Top results:');
results.forEach((r, i) => {
console.log(` ${i + 1}. [${(r.score * 100).toFixed(1)}%] ${r.doc.title}`);
});
console.log('');
}
// Step 4: Show document similarity matrix
console.log('3. Document Similarity Matrix');
console.log('');
// Header
process.stdout.write(' ');
documents.forEach((_, i) => process.stdout.write(`Doc${i + 1} `));
console.log('');
// Matrix
for (let i = 0; i < embeddedDocs.length; i++) {
process.stdout.write(`Doc${i + 1} `);
for (let j = 0; j < embeddedDocs.length; j++) {
const sim = cosineSimilarity(embeddedDocs[i].embedding, embeddedDocs[j].embedding);
process.stdout.write(`${(sim * 100).toFixed(0).padStart(3)}% `);
}
console.log('');
}
console.log('\n=== Complete ===');
}
main().catch(console.error);
+131
View File
@@ -0,0 +1,131 @@
/**
* Retrieval Example - Simple Vector Search
*
* Demonstrates building a simple retrieval system with embeddings
*
* Run: npx ts-node examples/js/embeddings/retrieval.ts
*/
import { embed, embedMany, cosineSimilarity } from '../../../src/praisonai-ts/dist/llm/embeddings';
interface Document {
id: string;
content: string;
embedding?: number[];
}
interface SearchResult {
document: Document;
score: number;
}
// Simple in-memory vector store
class SimpleVectorStore {
private documents: Document[] = [];
async add(doc: Document): Promise<void> {
const { embedding } = await embed(doc.content);
this.documents.push({ ...doc, embedding });
}
async addBatch(docs: Document[]): Promise<void> {
const contents = docs.map(d => d.content);
const { embeddings } = await embedMany(contents);
docs.forEach((doc, i) => {
this.documents.push({ ...doc, embedding: embeddings[i] });
});
}
async search(query: string, topK: number = 3): Promise<SearchResult[]> {
const { embedding: queryEmb } = await embed(query);
const results = this.documents
.filter(doc => doc.embedding)
.map(doc => ({
document: doc,
score: cosineSimilarity(queryEmb, doc.embedding!)
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
return results;
}
get(id: string): Document | undefined {
return this.documents.find(d => d.id === id);
}
delete(id: string): boolean {
const idx = this.documents.findIndex(d => d.id === id);
if (idx >= 0) {
this.documents.splice(idx, 1);
return true;
}
return false;
}
}
async function main() {
console.log('=== Simple Vector Retrieval ===\n');
// Create vector store
console.log('1. Creating vector store...');
const store = new SimpleVectorStore();
// Add documents
console.log('2. Adding documents...');
const docs: Document[] = [
{ id: '1', content: 'PraisonAI is an AI agent framework for building intelligent applications.' },
{ id: '2', content: 'Agents can use tools to interact with external systems and APIs.' },
{ id: '3', content: 'Workflows allow orchestrating multiple agents in sequence or parallel.' },
{ id: '4', content: 'Memory systems help agents maintain context across conversations.' },
{ id: '5', content: 'The AI SDK provides a unified interface for multiple LLM providers.' },
{ id: '6', content: 'Embeddings enable semantic search and similarity matching.' },
{ id: '7', content: 'Guardrails ensure agent outputs meet safety and quality standards.' },
{ id: '8', content: 'Sessions persist conversation state for multi-turn interactions.' }
];
await store.addBatch(docs);
console.log(` Added ${docs.length} documents`);
// Search queries
console.log('\n3. Running search queries...\n');
const queries = [
'How do I build an AI application?',
'Can agents call external APIs?',
'How to maintain conversation context?',
'What providers are supported?'
];
for (const query of queries) {
console.log(`Query: "${query}"`);
const results = await store.search(query);
if (results.length > 0) {
console.log('Results:');
results.forEach((r, i) => {
console.log(` ${i + 1}. [${(r.score * 100).toFixed(1)}%] ${r.document.content.substring(0, 60)}...`);
});
} else {
console.log(' No results found');
}
console.log('');
}
// Get specific document
console.log('4. Get document by ID...');
const doc = store.get('5');
if (doc) {
console.log(` Found: ${doc.content}`);
}
// Delete document
console.log('\n5. Delete document...');
const deleted = store.delete('8');
console.log(` Deleted doc 8: ${deleted}`);
console.log('\n=== Complete ===');
}
main().catch(console.error);
@@ -0,0 +1,197 @@
"""
Isolated Registry Example - Multi-Agent Safety
This example demonstrates how to use isolated registries to prevent
provider registration collisions in multi-agent scenarios.
Key concepts:
- Each agent/context can have its own registry
- No global state mutation
- Safe for concurrent/parallel runs
- Different agents can use different provider implementations
Requirements:
- pip install praisonai
"""
import sys
sys.path.insert(0, '../../src/praisonai')
from praisonai.llm import (
LLMProviderRegistry,
create_llm_provider,
register_llm_provider,
get_default_llm_registry,
_reset_default_registry
)
# Define two different provider implementations with the same name
class Agent1Provider:
"""Provider implementation for Agent 1."""
provider_id = "custom"
def __init__(self, model_id, config=None):
self.model_id = model_id
self.config = config or {}
self.agent_name = "Agent1"
def generate(self, prompt):
return f"[{self.agent_name}] Response to: {prompt}"
class Agent2Provider:
"""Provider implementation for Agent 2 - different behavior."""
provider_id = "custom"
def __init__(self, model_id, config=None):
self.model_id = model_id
self.config = config or {}
self.agent_name = "Agent2"
def generate(self, prompt):
return f"[{self.agent_name}] Different response to: {prompt}"
def demonstrate_collision_problem():
"""Show what happens with global registry collisions."""
print("=== Problem: Global Registry Collision ===\n")
# Reset to clean state
_reset_default_registry()
# Agent 1 registers "custom" provider
register_llm_provider("custom", Agent1Provider)
print("Agent 1 registered 'custom' provider")
# Agent 2 tries to register same name - ERROR!
try:
register_llm_provider("custom", Agent2Provider)
print("Agent 2 registered 'custom' provider")
except ValueError as e:
print(f"Agent 2 FAILED: {e}")
print()
print("Problem: Both agents want to use 'custom' but with different implementations.")
print("Solution: Use isolated registries.\n")
def demonstrate_isolated_solution():
"""Show how isolated registries solve the collision problem."""
print("=== Solution: Isolated Registries ===\n")
# Create isolated registries for each agent
agent1_registry = LLMProviderRegistry()
agent2_registry = LLMProviderRegistry()
# Each agent registers its own "custom" provider
agent1_registry.register("custom", Agent1Provider)
agent2_registry.register("custom", Agent2Provider)
print("Agent 1 registry providers:", agent1_registry.list())
print("Agent 2 registry providers:", agent2_registry.list())
print()
# Create providers from each registry
provider1 = create_llm_provider("custom/model-v1", registry=agent1_registry)
provider2 = create_llm_provider("custom/model-v1", registry=agent2_registry)
# They have different implementations!
print("Provider 1 response:", provider1.generate("Hello"))
print("Provider 2 response:", provider2.generate("Hello"))
print()
# Verify isolation
print("Verification:")
print(f" provider1.agent_name = {provider1.agent_name}")
print(f" provider2.agent_name = {provider2.agent_name}")
print(f" Same class? {type(provider1) == type(provider2)}")
print()
def demonstrate_parallel_agents():
"""Simulate parallel agent execution with isolated registries."""
print("=== Parallel Agent Simulation ===\n")
def run_agent(agent_id, registry):
"""Simulate an agent run with its own registry."""
provider = create_llm_provider("custom/model", registry=registry)
result = provider.generate(f"Query from agent {agent_id}")
return result
# Setup registries
registries = {}
for i in range(3):
reg = LLMProviderRegistry()
# Each agent gets a slightly different provider
class DynamicProvider:
provider_id = "custom"
def __init__(self, model_id, config=None):
self.model_id = model_id
self.agent_id = i # Capture agent ID
def generate(self, prompt):
return f"Agent-{self.agent_id} processed: {prompt}"
reg.register("custom", DynamicProvider)
registries[i] = reg
# Simulate parallel execution
print("Simulating parallel agent runs:")
for agent_id, registry in registries.items():
result = run_agent(agent_id, registry)
print(f" {result}")
print()
print("Each agent used its own isolated registry - no collisions!")
print()
def demonstrate_shared_vs_isolated():
"""Compare shared (global) vs isolated registry patterns."""
print("=== Shared vs Isolated Patterns ===\n")
# Reset global registry
_reset_default_registry()
# Pattern 1: Shared (global) registry
print("Pattern 1: Shared Registry (use for app-wide providers)")
register_llm_provider("shared-provider", Agent1Provider)
# All code sees the same provider
global_reg = get_default_llm_registry()
print(f" Global registry has 'shared-provider': {global_reg.has('shared-provider')}")
# Pattern 2: Isolated registry
print("\nPattern 2: Isolated Registry (use for per-agent/per-run providers)")
isolated = LLMProviderRegistry()
isolated.register("isolated-provider", Agent2Provider)
# Only code with reference to this registry sees it
print(f" Isolated registry has 'isolated-provider': {isolated.has('isolated-provider')}")
print(f" Global registry has 'isolated-provider': {global_reg.has('isolated-provider')}")
print()
print("Recommendation:")
print(" - Use global registry for app-wide, stable providers")
print(" - Use isolated registries for per-agent or per-run customization")
print()
def main():
print("=" * 60)
print("Isolated Registry Example - Multi-Agent Safety")
print("=" * 60)
print()
demonstrate_collision_problem()
demonstrate_isolated_solution()
demonstrate_parallel_agents()
demonstrate_shared_vs_isolated()
print("=" * 60)
print("Example Complete")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -11,8 +11,15 @@ import platform
from typing import Any, List, Optional, Callable, Iterable, Union
from functools import wraps, partial
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
try:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
ClientSession = None
StdioServerParameters = None
stdio_client = None
class MCPToolRunner(threading.Thread):
"""A dedicated thread for running MCP operations."""
@@ -196,10 +203,17 @@ class MCP:
debug: Enable debug logging for MCP operations (default: False)
**kwargs: Additional parameters for StdioServerParameters
"""
# Check if MCP is available
if not MCP_AVAILABLE:
raise ImportError(
"MCP (Model Context Protocol) package is not installed. "
"Install it with: pip install praisonaiagents[mcp]"
)
# Handle backward compatibility with named parameter 'command'
if command_or_string is None and command is not None:
command_or_string = command
# Set up logging - default to WARNING level to hide INFO messages
if debug:
logging.getLogger("mcp-wrapper").setLevel(logging.DEBUG)
@@ -16,11 +16,17 @@ import weakref
from typing import List, Dict, Any, Optional, Callable, Iterable, Union
from urllib.parse import urlparse, urljoin
from mcp import ClientSession
try:
from mcp import ClientSession
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
ClientSession = None
try:
import aiohttp
except ImportError:
raise ImportError("aiohttp is required for HTTP Stream transport. Install with: pip install praisonaiagents[mcp]")
aiohttp = None
logger = logging.getLogger("mcp-http-stream")
@@ -507,9 +513,23 @@ class HTTPStreamMCPClient:
timeout: Timeout in seconds for operations (default: 60)
options: Additional configuration options for the transport
"""
# Check if MCP is available
if not MCP_AVAILABLE:
raise ImportError(
"MCP (Model Context Protocol) package is not installed. "
"Install it with: pip install praisonaiagents[mcp]"
)
# Check if aiohttp is available
if aiohttp is None:
raise ImportError(
"aiohttp is required for HTTP Stream transport. "
"Install it with: pip install praisonaiagents[mcp]"
)
# Parse URL to extract base URL and endpoint
parsed = urlparse(server_url)
# If the URL already has a path, use it; otherwise use default /mcp endpoint
if parsed.path and parsed.path != '/':
self.base_url = server_url
@@ -11,8 +11,14 @@ import inspect
import json
from typing import List, Dict, Any, Optional, Callable, Iterable
from mcp import ClientSession
from mcp.client.sse import sse_client
try:
from mcp import ClientSession
from mcp.client.sse import sse_client
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
ClientSession = None
sse_client = None
logger = logging.getLogger("mcp-sse")
@@ -169,12 +175,19 @@ class SSEMCPClient:
debug: Whether to enable debug logging
timeout: Timeout in seconds for operations (default: 60)
"""
# Check if MCP is available
if not MCP_AVAILABLE:
raise ImportError(
"MCP (Model Context Protocol) package is not installed. "
"Install it with: pip install praisonaiagents[mcp]"
)
self.server_url = server_url
self.debug = debug
self.timeout = timeout
self.session = None
self.tools = []
# Set up logging
if debug:
logger.setLevel(logging.DEBUG)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.5.1"
version = "0.5.2"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,149 @@
"""
Test that MCP module imports are optional and don't break basic functionality.
This test verifies the fix for issue #1091.
"""
import pytest
class TestMCPOptionalImport:
"""Test that MCP is properly optional."""
def test_praisonaiagents_import_without_mcp(self):
"""Test that praisonaiagents can be imported without mcp installed."""
# This should not raise any ImportError
import praisonaiagents
assert praisonaiagents is not None
def test_agent_import_without_mcp(self):
"""Test that Agent can be imported without mcp installed."""
from praisonaiagents import Agent
assert Agent is not None
def test_mcp_lazy_loading(self):
"""Test that MCP is lazily loaded."""
from praisonaiagents import MCP # noqa: F401
# MCP should either be available or None (not raise ImportError on access)
# The actual ImportError should only be raised when trying to instantiate
# if mcp package is not installed
def test_mcp_module_import_no_crash(self):
"""Test that mcp module can be imported without crashing."""
# This tests that the try/except in mcp.py works correctly
try:
from praisonaiagents.mcp import mcp as mcp_module
assert hasattr(mcp_module, 'MCP_AVAILABLE')
except ImportError:
# If import fails, it should be because of missing mcp,
# not because of a syntax error
pass
def test_mcp_sse_module_import_no_crash(self):
"""Test that mcp_sse module can be imported without crashing."""
try:
from praisonaiagents.mcp import mcp_sse
assert hasattr(mcp_sse, 'MCP_AVAILABLE')
except ImportError:
pass
def test_mcp_http_stream_module_import_no_crash(self):
"""Test that mcp_http_stream module can be imported without crashing."""
try:
from praisonaiagents.mcp import mcp_http_stream
assert hasattr(mcp_http_stream, 'MCP_AVAILABLE')
except ImportError:
pass
def test_basic_agent_creation_without_mcp_tools(self):
"""Test that agents can be created without MCP tools."""
from praisonaiagents import Agent
# Creating an agent without MCP tools should work
agent = Agent(
name="Test Agent",
instructions="You are a test agent.",
llm="gpt-4o-mini"
)
assert agent is not None
def test_mcp_raises_import_error_when_unavailable(self):
"""Test that MCP raises ImportError with install instructions when mcp package unavailable."""
import praisonaiagents.mcp.mcp as mcp_module
# Save original value
original_available = mcp_module.MCP_AVAILABLE
# Simulate MCP not installed
mcp_module.MCP_AVAILABLE = False
try:
from praisonaiagents.mcp.mcp import MCP
with pytest.raises(ImportError) as exc_info:
MCP('test-command')
error_msg = str(exc_info.value)
assert 'pip install praisonaiagents[mcp]' in error_msg
assert 'MCP' in error_msg or 'Model Context Protocol' in error_msg
finally:
# Restore original value
mcp_module.MCP_AVAILABLE = original_available
def test_sse_client_raises_import_error_when_unavailable(self):
"""Test that SSEMCPClient raises ImportError with install instructions when mcp unavailable."""
import praisonaiagents.mcp.mcp_sse as sse_module
original_available = sse_module.MCP_AVAILABLE
sse_module.MCP_AVAILABLE = False
try:
from praisonaiagents.mcp.mcp_sse import SSEMCPClient
with pytest.raises(ImportError) as exc_info:
SSEMCPClient('http://test.com/sse')
error_msg = str(exc_info.value)
assert 'pip install praisonaiagents[mcp]' in error_msg
finally:
sse_module.MCP_AVAILABLE = original_available
def test_http_stream_client_raises_import_error_when_mcp_unavailable(self):
"""Test that HTTPStreamMCPClient raises ImportError when mcp unavailable."""
import praisonaiagents.mcp.mcp_http_stream as http_module
original_available = http_module.MCP_AVAILABLE
http_module.MCP_AVAILABLE = False
try:
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamMCPClient
with pytest.raises(ImportError) as exc_info:
HTTPStreamMCPClient('http://test.com/mcp')
error_msg = str(exc_info.value)
assert 'pip install praisonaiagents[mcp]' in error_msg
finally:
http_module.MCP_AVAILABLE = original_available
def test_http_stream_client_raises_import_error_when_aiohttp_unavailable(self):
"""Test that HTTPStreamMCPClient raises ImportError when aiohttp unavailable."""
import praisonaiagents.mcp.mcp_http_stream as http_module
# Ensure MCP is available but aiohttp is not
original_available = http_module.MCP_AVAILABLE
original_aiohttp = http_module.aiohttp
http_module.MCP_AVAILABLE = True
http_module.aiohttp = None
try:
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamMCPClient
with pytest.raises(ImportError) as exc_info:
HTTPStreamMCPClient('http://test.com/mcp')
error_msg = str(exc_info.value)
assert 'aiohttp' in error_msg.lower()
assert 'pip install praisonaiagents[mcp]' in error_msg
finally:
http_module.MCP_AVAILABLE = original_available
http_module.aiohttp = original_aiohttp
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,42 @@
/**
* AI SDK Basic Chat Example
*
* Demonstrates basic text generation using the AI SDK backend.
*
* Usage:
* npx ts-node examples/ai-sdk/basic-chat.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
async function main() {
console.log('AI SDK Basic Chat Example\n');
// Create a backend for OpenAI
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 30000,
maxRetries: 2,
});
console.log(`Provider: ${backend.providerId}`);
console.log(`Model: ${backend.modelId}\n`);
// Generate text
const result = await backend.generateText({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'What is the capital of France? Answer in one sentence.' }
],
temperature: 0.7,
maxTokens: 100,
});
console.log('Response:', result.text);
console.log('\nUsage:', result.usage);
console.log('Finish Reason:', result.finishReason);
}
main().catch(console.error);
@@ -0,0 +1,123 @@
/**
* AI SDK Multi-Agent Example
*
* Demonstrates multi-agent safety with attribution context.
* Each agent has its own ID and the context is propagated to LLM calls.
*
* Usage:
* npx ts-node examples/ai-sdk/multi-agent.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
import type { AttributionContext } from '../../src/llm/providers/ai-sdk/types';
// Simulate multiple agents running in parallel
async function runAgent(
agentId: string,
task: string,
attribution: AttributionContext
): Promise<{ agentId: string; result: string; duration: number }> {
console.log(`[${agentId}] Starting task: "${task}"`);
// Create backend with attribution context
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 30000,
attribution,
});
const startTime = Date.now();
const result = await backend.generateText({
messages: [
{ role: 'system', content: `You are agent ${agentId}. Be concise.` },
{ role: 'user', content: task }
],
temperature: 0.7,
maxTokens: 100,
});
const duration = Date.now() - startTime;
console.log(`[${agentId}] Completed in ${duration}ms`);
return {
agentId,
result: result.text,
duration
};
}
async function main() {
console.log('AI SDK Multi-Agent Example\n');
console.log('Running multiple agents in parallel with attribution tracking...\n');
// Shared run context
const runId = `run-${Date.now()}`;
const traceId = `trace-${Math.random().toString(36).slice(2, 10)}`;
const sessionId = 'session-demo';
console.log(`Run ID: ${runId}`);
console.log(`Trace ID: ${traceId}`);
console.log(`Session ID: ${sessionId}\n`);
console.log('---\n');
// Define agents with their tasks
const agents = [
{
id: 'researcher',
task: 'What is the population of Tokyo? Just the number.',
attribution: {
agentId: 'researcher',
runId,
traceId,
sessionId,
}
},
{
id: 'analyst',
task: 'What is 15% of 1000? Just the number.',
attribution: {
agentId: 'analyst',
runId,
traceId,
sessionId,
}
},
{
id: 'writer',
task: 'Write a one-sentence tagline for a coffee shop.',
attribution: {
agentId: 'writer',
runId,
traceId,
sessionId,
}
}
];
// Run all agents in parallel
const startTime = Date.now();
const results = await Promise.all(
agents.map(agent => runAgent(agent.id, agent.task, agent.attribution))
);
const totalDuration = Date.now() - startTime;
console.log('\n---\n');
console.log('Results:\n');
for (const result of results) {
console.log(`[${result.agentId}] (${result.duration}ms)`);
console.log(` ${result.result.trim()}\n`);
}
console.log('---');
console.log(`Total parallel execution time: ${totalDuration}ms`);
console.log(`Sum of individual times: ${results.reduce((sum, r) => sum + r.duration, 0)}ms`);
console.log('\nNote: Attribution headers (X-Agent-Id, X-Run-Id, X-Trace-Id, X-Session-Id)');
console.log('are automatically injected into each LLM request for tracing and debugging.');
}
main().catch(console.error);
@@ -0,0 +1,68 @@
/**
* AI SDK Multi-Provider Example
*
* Demonstrates using multiple AI providers with the same interface.
*
* Usage:
* npx ts-node examples/ai-sdk/multi-provider.ts
*
* Required environment variables (set at least one):
* OPENAI_API_KEY - Your OpenAI API key
* ANTHROPIC_API_KEY - Your Anthropic API key
* GOOGLE_API_KEY - Your Google API key
*/
import { createAISDKBackend, validateProviderApiKey } from '../../src/llm/providers/ai-sdk';
const PROVIDERS = [
{ id: 'openai', model: 'gpt-4o-mini' },
{ id: 'anthropic', model: 'claude-3-haiku-20240307' },
{ id: 'google', model: 'gemini-1.5-flash' },
];
async function main() {
console.log('AI SDK Multi-Provider Example\n');
console.log('Testing multiple providers with the same prompt...\n');
const prompt = 'What is 2 + 2? Answer with just the number.';
console.log(`Prompt: "${prompt}"\n`);
console.log('---\n');
for (const provider of PROVIDERS) {
// Check if API key is available
if (!validateProviderApiKey(provider.id)) {
console.log(`${provider.id}: Skipped (no API key)`);
continue;
}
try {
const modelString = `${provider.id}/${provider.model}`;
console.log(`${provider.id} (${provider.model}):`);
const backend = createAISDKBackend(modelString, {
timeout: 30000,
maxRetries: 1,
});
const startTime = Date.now();
const result = await backend.generateText({
messages: [
{ role: 'user', content: prompt }
],
temperature: 0,
maxTokens: 10,
});
const duration = Date.now() - startTime;
console.log(` Response: ${result.text.trim()}`);
console.log(` Duration: ${duration}ms`);
console.log(` Tokens: ${result.usage?.totalTokens || 'N/A'}`);
console.log();
} catch (error) {
console.log(` Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
console.log();
}
}
}
main().catch(console.error);
@@ -0,0 +1,56 @@
/**
* AI SDK Streaming Example
*
* Demonstrates streaming text generation using the AI SDK backend.
*
* Usage:
* npx ts-node examples/ai-sdk/streaming.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
async function main() {
console.log('AI SDK Streaming Example\n');
// Create a backend for OpenAI
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 60000,
});
console.log(`Provider: ${backend.providerId}`);
console.log(`Model: ${backend.modelId}\n`);
console.log('Streaming response:\n');
// Stream text
const stream = await backend.streamText({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Write a short poem about programming.' }
],
temperature: 0.8,
maxTokens: 200,
onToken: (token) => {
// This callback is called for each token
process.stdout.write(token);
},
});
// Consume the stream
let totalTokens = 0;
for await (const chunk of stream) {
if (chunk.text) {
// Text is already printed via onToken callback
}
if (chunk.usage) {
totalTokens = chunk.usage.totalTokens || 0;
}
}
console.log('\n\n---');
console.log(`Total tokens: ${totalTokens}`);
}
main().catch(console.error);
@@ -0,0 +1,129 @@
/**
* AI SDK Structured Output Example
*
* Demonstrates generating structured JSON output using schemas.
*
* Usage:
* npx ts-node examples/ai-sdk/structured-output.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
// Define the schema for structured output
const recipeSchema = {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name of the recipe'
},
ingredients: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
amount: { type: 'string' },
unit: { type: 'string' }
},
required: ['name', 'amount']
},
description: 'List of ingredients'
},
steps: {
type: 'array',
items: { type: 'string' },
description: 'Cooking steps'
},
prepTime: {
type: 'number',
description: 'Preparation time in minutes'
},
cookTime: {
type: 'number',
description: 'Cooking time in minutes'
},
servings: {
type: 'number',
description: 'Number of servings'
}
},
required: ['name', 'ingredients', 'steps']
};
interface Recipe {
name: string;
ingredients: Array<{
name: string;
amount: string;
unit?: string;
}>;
steps: string[];
prepTime?: number;
cookTime?: number;
servings?: number;
}
async function main() {
console.log('AI SDK Structured Output Example\n');
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 30000,
});
console.log(`Provider: ${backend.providerId}`);
console.log(`Model: ${backend.modelId}\n`);
console.log('Generating a recipe in structured JSON format...\n');
const result = await backend.generateObject<Recipe>({
messages: [
{
role: 'system',
content: 'You are a helpful cooking assistant. Generate recipes in the requested format.'
},
{
role: 'user',
content: 'Give me a simple recipe for chocolate chip cookies.'
}
],
schema: recipeSchema,
temperature: 0.7,
});
console.log('Generated Recipe:');
console.log('================\n');
const recipe = result.object;
console.log(`📝 ${recipe.name}`);
console.log();
if (recipe.prepTime || recipe.cookTime) {
console.log(`⏱️ Prep: ${recipe.prepTime || 'N/A'} min | Cook: ${recipe.cookTime || 'N/A'} min`);
}
if (recipe.servings) {
console.log(`🍽️ Servings: ${recipe.servings}`);
}
console.log();
console.log('Ingredients:');
for (const ing of recipe.ingredients) {
const unit = ing.unit ? ` ${ing.unit}` : '';
console.log(` • ${ing.amount}${unit} ${ing.name}`);
}
console.log();
console.log('Steps:');
recipe.steps.forEach((step, i) => {
console.log(` ${i + 1}. ${step}`);
});
console.log('\n---');
console.log('Usage:', result.usage);
}
main().catch(console.error);
@@ -0,0 +1,144 @@
/**
* AI SDK Tool Calling Example
*
* Demonstrates tool calling using the AI SDK backend.
*
* Usage:
* npx ts-node examples/ai-sdk/tool-calling.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
import type { ToolDefinition, Message } from '../../src/llm/providers/types';
// Define tools
const tools: ToolDefinition[] = [
{
name: 'get_weather',
description: 'Get the current weather for a location',
parameters: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'The city and country, e.g., "London, UK"'
},
unit: {
type: 'string',
enum: ['celsius', 'fahrenheit'],
description: 'Temperature unit'
}
},
required: ['location']
}
},
{
name: 'get_time',
description: 'Get the current time for a timezone',
parameters: {
type: 'object',
properties: {
timezone: {
type: 'string',
description: 'The timezone, e.g., "America/New_York"'
}
},
required: ['timezone']
}
}
];
// Mock tool execution
function executeTool(name: string, args: Record<string, unknown>): string {
switch (name) {
case 'get_weather':
return JSON.stringify({
location: args.location,
temperature: 22,
unit: args.unit || 'celsius',
condition: 'Partly cloudy'
});
case 'get_time':
return JSON.stringify({
timezone: args.timezone,
time: new Date().toLocaleTimeString('en-US', {
timeZone: args.timezone as string
})
});
default:
return JSON.stringify({ error: 'Unknown tool' });
}
}
async function main() {
console.log('AI SDK Tool Calling Example\n');
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 30000,
});
console.log(`Provider: ${backend.providerId}`);
console.log(`Model: ${backend.modelId}\n`);
// Initial conversation
const messages: Message[] = [
{ role: 'system', content: 'You are a helpful assistant with access to weather and time tools.' },
{ role: 'user', content: 'What is the weather in London and what time is it in New York?' }
];
console.log('User:', messages[1].content);
console.log('\n--- First LLM Call ---\n');
// First call - model should request tool calls
let result = await backend.generateText({
messages,
tools,
toolChoice: 'auto',
});
console.log('Finish Reason:', result.finishReason);
// Handle tool calls
if (result.toolCalls && result.toolCalls.length > 0) {
console.log(`\nTool Calls: ${result.toolCalls.length}`);
// Add assistant message with tool calls
messages.push({
role: 'assistant',
content: null,
tool_calls: result.toolCalls
});
// Execute each tool and add results
for (const toolCall of result.toolCalls) {
const args = JSON.parse(toolCall.function.arguments);
console.log(`\n Executing: ${toolCall.function.name}`);
console.log(` Arguments: ${JSON.stringify(args)}`);
const toolResult = executeTool(toolCall.function.name, args);
console.log(` Result: ${toolResult}`);
messages.push({
role: 'tool',
content: toolResult,
tool_call_id: toolCall.id,
name: toolCall.function.name
});
}
console.log('\n--- Second LLM Call (with tool results) ---\n');
// Second call with tool results
result = await backend.generateText({
messages,
tools,
});
}
console.log('Final Response:', result.text);
console.log('\nUsage:', result.usage);
}
main().catch(console.error);
+27 -3
View File
@@ -1,6 +1,6 @@
{
"name": "praisonai",
"version": "1.3.2",
"version": "1.3.6",
"description": "PraisonAI TypeScript AI Agents Framework - Node.js, npm, and Javascript AI Agents Framework",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -46,11 +46,15 @@
"author": "Mervin Praison",
"license": "MIT",
"devDependencies": {
"@ai-sdk/anthropic": "^3.0.1",
"@ai-sdk/google": "^3.0.1",
"@ai-sdk/openai": "^3.0.1",
"@types/figlet": "^1.7.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.12.0",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"ai": "^6.0.3",
"eslint": "^9.19.0",
"jest": "^29.7.0",
"rimraf": "^5.0.5",
@@ -60,12 +64,12 @@
"typescript": "^5.7.3"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"axios": "^1.7.9",
"dotenv": "^16.4.7",
"fast-xml-parser": "^4.5.1",
"node-fetch": "^2.6.9",
"openai": "^4.81.0",
"@modelcontextprotocol/sdk": "^1.12.1"
"openai": "^4.81.0"
},
"optionalDependencies": {
"boxen": "^7.1.1",
@@ -74,6 +78,26 @@
"figlet": "^1.7.0",
"ora": "^5.4.1"
},
"peerDependencies": {
"@ai-sdk/anthropic": ">=1.0.0",
"@ai-sdk/google": ">=1.0.0",
"@ai-sdk/openai": ">=1.0.0",
"ai": ">=4.0.0"
},
"peerDependenciesMeta": {
"ai": {
"optional": true
},
"@ai-sdk/openai": {
"optional": true
},
"@ai-sdk/anthropic": {
"optional": true
},
"@ai-sdk/google": {
"optional": true
}
},
"overrides": {
"whatwg-url": "^14.1.0",
"node-fetch": "^2.6.9"
+29 -4
View File
@@ -2,7 +2,8 @@
* Context Agent - Agent with enhanced context management
*/
import { createProvider, type LLMProvider, type Message } from '../llm/providers';
import { type LLMProvider, type Message } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
import { Session } from '../session';
import { KnowledgeBase, type SearchResult } from '../knowledge/rag';
@@ -30,7 +31,9 @@ export class ContextAgent {
readonly name: string;
readonly instructions: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private knowledgeBase?: KnowledgeBase;
private contextWindow: number;
private maxContextTokens: number;
@@ -40,13 +43,34 @@ export class ContextAgent {
constructor(config: ContextAgentConfig) {
this.name = config.name || `ContextAgent_${Math.random().toString(36).substr(2, 9)}`;
this.instructions = config.instructions;
this.provider = createProvider(config.llm || 'gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.knowledgeBase = config.knowledgeBase;
this.contextWindow = config.contextWindow ?? 10;
this.maxContextTokens = config.maxContextTokens ?? 4000;
this.verbose = config.verbose ?? false;
}
/**
* Get the LLM provider (lazy initialization with AI SDK backend)
*/
private async getProvider(): Promise<LLMProvider> {
if (this.provider) {
return this.provider;
}
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, {
attribution: { agentId: this.name },
});
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Chat with context awareness
*/
@@ -83,7 +107,8 @@ export class ContextAgent {
];
// Generate response
const result = await this.provider.generateText({ messages });
const provider = await this.getProvider();
const result = await provider.generateText({ messages });
// Add assistant message
this.messages.push({
+37 -7
View File
@@ -2,7 +2,8 @@
* Enhanced Agent - Agent with session management, provider abstraction, and tool support
*/
import { createProvider, type LLMProvider, type Message, type ToolCall, type GenerateTextResult } from '../llm/providers';
import { type LLMProvider, type Message, type ToolCall, type GenerateTextResult } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
import { Session, Run, Trace, getSessionManager } from '../session';
import { FunctionTool, ToolRegistry, tool } from '../tools/decorator';
import { Logger } from '../utils/logger';
@@ -51,7 +52,9 @@ export class EnhancedAgent {
readonly instructions: string;
readonly sessionId: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private session: Session;
private toolRegistry: ToolRegistry;
private verbose: boolean;
@@ -71,8 +74,8 @@ export class EnhancedAgent {
this.maxTokens = config.maxTokens;
this.outputSchema = config.outputSchema;
// Initialize provider
this.provider = createProvider(config.llm || 'gpt-4o-mini');
// Store model string for lazy provider initialization
this.llmModel = config.llm || 'openai/gpt-4o-mini';
// Initialize session
if (config.session) {
@@ -93,6 +96,30 @@ export class EnhancedAgent {
Logger.setVerbose(this.verbose);
}
/**
* Get the LLM provider (lazy initialization with AI SDK backend)
*/
private async getProvider(): Promise<LLMProvider> {
if (this.provider) {
return this.provider;
}
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, {
attribution: {
agentId: this.name,
sessionId: this.sessionId,
},
});
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
private registerTools(tools: EnhancedAgentConfig['tools']): void {
if (!tools) return;
@@ -176,7 +203,8 @@ export class EnhancedAgent {
if (options.stream && !tools && options.onToken) {
// Streaming without tools
const stream = await this.provider.streamText({
const provider = await this.getProvider();
const stream = await provider.streamText({
messages,
temperature: options.temperature ?? this.temperature,
maxTokens: options.maxTokens ?? this.maxTokens,
@@ -197,7 +225,8 @@ export class EnhancedAgent {
break;
} else if (options.outputSchema) {
// Structured output
const objResult = await this.provider.generateObject({
const provider = await this.getProvider();
const objResult = await provider.generateObject({
messages,
schema: options.outputSchema ?? this.outputSchema,
temperature: options.temperature ?? this.temperature,
@@ -213,7 +242,8 @@ export class EnhancedAgent {
break;
} else {
// Regular generation with potential tool calls
result = await this.provider.generateText({
const provider = await this.getProvider();
result = await provider.generateText({
messages,
temperature: options.temperature ?? this.temperature,
maxTokens: options.maxTokens ?? this.maxTokens,
+33 -6
View File
@@ -2,7 +2,8 @@
* ImageAgent - Agent for image generation and analysis
*/
import { createProvider, type LLMProvider, type Message } from '../llm/providers';
import { type LLMProvider, type Message } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export interface ImageGenerationConfig {
prompt: string;
@@ -30,17 +31,40 @@ export interface ImageAgentConfig {
*/
export class ImageAgent {
readonly name: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private imageModel: string;
private verbose: boolean;
constructor(config: ImageAgentConfig = {}) {
this.name = config.name || `ImageAgent_${Math.random().toString(36).substr(2, 9)}`;
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.imageModel = config.imageModel || 'dall-e-3';
this.verbose = config.verbose ?? false;
}
/**
* Get the LLM provider (lazy initialization with AI SDK backend)
*/
private async getProvider(): Promise<LLMProvider> {
if (this.provider) {
return this.provider;
}
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, {
attribution: { agentId: this.name },
});
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Analyze an image
*/
@@ -63,7 +87,8 @@ export class ImageAgent {
}
];
const result = await this.provider.generateText({ messages });
const provider = await this.getProvider();
const result = await provider.generateText({ messages });
if (this.verbose) {
console.log(`[ImageAgent] Analysis: ${result.text.substring(0, 100)}...`);
@@ -93,7 +118,8 @@ export class ImageAgent {
return this.analyze({ imageUrl, prompt });
}
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [{ role: 'user', content: prompt }]
});
@@ -117,7 +143,8 @@ export class ImageAgent {
}
];
const result = await this.provider.generateText({ messages });
const provider = await this.getProvider();
const result = await provider.generateText({ messages });
return result.text;
}
}
+20 -4
View File
@@ -2,7 +2,8 @@
* PromptExpanderAgent - Expand and enhance prompts
*/
import { createProvider, type LLMProvider } from '../llm/providers';
import { type LLMProvider } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export type ExpandStrategy = 'detail' | 'context' | 'examples' | 'constraints' | 'auto';
@@ -25,17 +26,31 @@ export interface PromptExpanderConfig {
*/
export class PromptExpanderAgent {
readonly name: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private defaultStrategy: ExpandStrategy;
private verbose: boolean;
constructor(config: PromptExpanderConfig = {}) {
this.name = config.name || `PromptExpander_${Math.random().toString(36).substr(2, 9)}`;
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.defaultStrategy = config.defaultStrategy || 'auto';
this.verbose = config.verbose ?? false;
}
private async getProvider(): Promise<LLMProvider> {
if (this.provider) return this.provider;
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, { attribution: { agentId: this.name } });
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Expand a prompt
*/
@@ -45,7 +60,8 @@ export class PromptExpanderAgent {
const systemPrompt = this.getSystemPrompt(actualStrategy);
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Expand this prompt: ${prompt}` }
+20 -4
View File
@@ -2,7 +2,8 @@
* QueryRewriterAgent - Rewrite and optimize queries
*/
import { createProvider, type LLMProvider } from '../llm/providers';
import { type LLMProvider } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export type RewriteStrategy = 'expand' | 'simplify' | 'decompose' | 'rephrase' | 'auto';
@@ -25,17 +26,31 @@ export interface QueryRewriterConfig {
*/
export class QueryRewriterAgent {
readonly name: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private defaultStrategy: RewriteStrategy;
private verbose: boolean;
constructor(config: QueryRewriterConfig = {}) {
this.name = config.name || `QueryRewriter_${Math.random().toString(36).substr(2, 9)}`;
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.defaultStrategy = config.defaultStrategy || 'auto';
this.verbose = config.verbose ?? false;
}
private async getProvider(): Promise<LLMProvider> {
if (this.provider) return this.provider;
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, { attribution: { agentId: this.name } });
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Rewrite a query
*/
@@ -45,7 +60,8 @@ export class QueryRewriterAgent {
const prompt = this.buildPrompt(query, actualStrategy);
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{ role: 'system', content: this.getSystemPrompt(actualStrategy) },
{ role: 'user', content: prompt }
+22 -5
View File
@@ -2,7 +2,8 @@
* DeepResearchAgent - Agent for comprehensive research tasks
*/
import { createProvider, type LLMProvider, type Message } from '../llm/providers';
import { type LLMProvider, type Message } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export interface Citation {
title: string;
@@ -37,19 +38,33 @@ export interface DeepResearchConfig {
*/
export class DeepResearchAgent {
readonly name: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private maxIterations: number;
private searchTool?: (query: string) => Promise<Citation[]>;
private verbose: boolean;
constructor(config: DeepResearchConfig = {}) {
this.name = config.name || `ResearchAgent_${Math.random().toString(36).substr(2, 9)}`;
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.maxIterations = config.maxIterations ?? 5;
this.searchTool = config.searchTool;
this.verbose = config.verbose ?? false;
}
private async getProvider(): Promise<LLMProvider> {
if (this.provider) return this.provider;
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, { attribution: { agentId: this.name } });
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Conduct deep research on a topic
*/
@@ -115,7 +130,8 @@ export class DeepResearchAgent {
}
private async generateSearchQueries(query: string): Promise<string[]> {
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{
role: 'system',
@@ -133,7 +149,8 @@ export class DeepResearchAgent {
? `\n\nRelevant sources:\n${citations.map(c => `- ${c.title}: ${c.snippet || ''}`).join('\n')}`
: '';
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{
role: 'system',
+123 -5
View File
@@ -3,6 +3,8 @@ import { Logger } from '../utils/logger';
import type { ChatCompletionTool } from 'openai/resources/chat/completions';
import type { DbAdapter, DbMessage, DbRun } from '../db/types';
import { randomUUID } from 'crypto';
import type { LLMProvider } from '../llm/providers/types';
import type { BackendResolutionResult } from '../llm/backend-resolver';
/**
* Agent Configuration
@@ -116,6 +118,12 @@ export class Agent {
private cacheTTL: number;
private responseCache: Map<string, { response: string; timestamp: number }> = new Map();
private telemetryEnabled: boolean;
// AI SDK backend support
private _backend: LLMProvider | null = null;
private _backendPromise: Promise<BackendResolutionResult> | null = null;
private _backendSource: 'ai-sdk' | 'native' | 'custom' | 'legacy' = 'legacy';
private _useAISDKBackend: boolean = false;
constructor(config: SimpleAgentConfig) {
// Build instructions from either simple or advanced mode
@@ -148,7 +156,16 @@ export class Agent {
this.cache = config.cache ?? false;
this.cacheTTL = config.cacheTTL ?? 3600;
this.telemetryEnabled = config.telemetry ?? false;
this.llmService = new OpenAIService(this.llm);
// Parse model string to extract provider and model ID
// Format: "provider/model" or just "model"
const providerId = this.llm.includes('/') ? this.llm.split('/')[0] : 'openai';
const modelId = this.llm.includes('/') ? this.llm.split('/').slice(1).join('/') : this.llm;
// For OpenAI, use OpenAIService directly for backward compatibility
// For other providers, we'll use the AI SDK backend via getBackend()
this._useAISDKBackend = providerId !== 'openai';
this.llmService = new OpenAIService(modelId);
// Configure logging
Logger.setVerbose(this.verbose);
@@ -207,13 +224,14 @@ export class Agent {
}
/**
* Generate a session ID based on current hour and agent name (like Python SDK)
* Generate a unique session ID based on current hour, agent name, and random suffix
*/
private generateSessionId(): string {
const now = new Date();
const hourStr = now.toISOString().slice(0, 13).replace(/[-T:]/g, '');
const hash = this.name ? this.name.slice(0, 6) : 'agent';
return `${hourStr}-${hash}`;
const randomSuffix = randomUUID().slice(0, 8);
return `${hourStr}-${hash}-${randomSuffix}`;
}
/**
@@ -432,8 +450,35 @@ export class Agent {
let finalResponse = '';
if (this.stream && !this.tools) {
// Use streaming with full conversation history
// Use AI SDK backend for non-OpenAI providers
if (this._useAISDKBackend) {
const backend = await this.getBackend();
if (this.stream && !this.tools) {
// Streaming with AI SDK backend
const stream = await backend.streamText({
messages,
temperature: 0.7
});
let accumulated = '';
for await (const chunk of stream) {
if (chunk.text) {
process.stdout.write(chunk.text);
accumulated += chunk.text;
}
}
finalResponse = accumulated;
} else {
// Non-streaming with AI SDK backend
const result = await backend.generateText({
messages,
temperature: 0.7
});
finalResponse = result.text;
}
} else if (this.stream && !this.tools) {
// Use streaming with full conversation history (OpenAI)
finalResponse = await this.llmService.streamChat(
messages,
0.7,
@@ -606,6 +651,79 @@ export class Agent {
clearCache(): void {
this.responseCache.clear();
}
/**
* Get the resolved backend (AI SDK preferred, native fallback)
* Lazy initialization - backend is only resolved on first use
*/
async getBackend(): Promise<LLMProvider> {
if (this._backend) {
return this._backend;
}
if (!this._backendPromise) {
this._backendPromise = (async () => {
const { resolveBackend } = await import('../llm/backend-resolver');
const result = await resolveBackend(this.llm, {
attribution: {
agentId: this.name,
runId: this.runId,
sessionId: this.sessionId,
},
});
this._backend = result.provider;
this._backendSource = result.source;
Logger.debug(`Agent ${this.name} using ${result.source} backend for ${this.llm}`);
return result;
})();
}
const result = await this._backendPromise;
return result.provider;
}
/**
* Get the backend source (ai-sdk, native, custom, or legacy)
*/
getBackendSource(): 'ai-sdk' | 'native' | 'custom' | 'legacy' {
return this._backendSource;
}
/**
* Embed text using AI SDK (preferred) or native provider
*
* @param text - Text to embed (string or array of strings)
* @param options - Embedding options
* @returns Embedding vector(s)
*
* @example Single text
* ```typescript
* const embedding = await agent.embed("Hello world");
* ```
*
* @example Multiple texts
* ```typescript
* const embeddings = await agent.embed(["Hello", "World"]);
* ```
*/
async embed(text: string | string[], options?: { model?: string }): Promise<number[] | number[][]> {
const { embed, embedMany } = await import('../llm/embeddings');
if (Array.isArray(text)) {
const result = await embedMany(text, { model: options?.model });
return result.embeddings;
} else {
const result = await embed(text, { model: options?.model });
return result.embedding;
}
}
/**
* Get the model string for this agent
*/
getModel(): string {
return this.llm;
}
}
/**
+29 -5
View File
@@ -2,7 +2,8 @@
* AutoAgents - Automatic agent generation from task descriptions
*/
import { createProvider, type LLMProvider, type Message } from '../llm/providers';
import { type LLMProvider, type Message } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export interface AgentConfig {
name: string;
@@ -36,25 +37,48 @@ export interface AutoAgentsConfig {
* AutoAgents - Generate agent configurations from task descriptions
*/
export class AutoAgents {
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private pattern: string;
private singleAgent: boolean;
private verbose: boolean;
constructor(config: AutoAgentsConfig = {}) {
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.pattern = config.pattern || 'sequential';
this.singleAgent = config.singleAgent ?? false;
this.verbose = config.verbose ?? false;
}
/**
* Get the LLM provider (lazy initialization with AI SDK backend)
*/
private async getProvider(): Promise<LLMProvider> {
if (this.provider) {
return this.provider;
}
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, {
attribution: { agentId: 'AutoAgents' },
});
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Generate agent configuration from task description
*/
async generate(taskDescription: string): Promise<TeamStructure> {
const prompt = this.buildPrompt(taskDescription);
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{ role: 'system', content: this.getSystemPrompt() },
{ role: 'user', content: prompt }
@@ -0,0 +1,551 @@
/**
* Benchmark Command - Performance benchmarks for AI SDK vs Native backends
*
* Measures:
* - Import time (cold start)
* - Memory usage
* - First-call latency
* - Streaming throughput
* - Embedding throughput
*/
import { outputJson, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { ERROR_CODES } from '../output/errors';
export interface BenchmarkOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
iterations?: number;
backend?: 'ai-sdk' | 'native' | 'both';
real?: boolean;
}
interface BenchmarkResult {
name: string;
iterations: number;
mean: number;
min: number;
max: number;
p95: number;
stdDev: number;
unit: string;
}
interface BenchmarkReport {
timestamp: string;
results: BenchmarkResult[];
comparison?: {
aiSdk: BenchmarkResult;
native: BenchmarkResult;
difference: number;
winner: string;
}[];
}
const EXIT_CODES = {
SUCCESS: 0,
GENERAL_ERROR: 1,
INVALID_ARGS: 2,
} as const;
function formatBenchmarkSuccess(data: any, meta?: any): { success: true; data: any; meta?: any } {
return {
success: true as const,
data,
meta,
};
}
function calculateStats(values: number[]): { mean: number; min: number; max: number; p95: number; stdDev: number } {
if (values.length === 0) return { mean: 0, min: 0, max: 0, p95: 0, stdDev: 0 };
const sorted = [...values].sort((a, b) => a - b);
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const min = sorted[0];
const max = sorted[sorted.length - 1];
const p95Index = Math.floor(sorted.length * 0.95);
const p95 = sorted[p95Index] || max;
const squaredDiffs = values.map(v => Math.pow(v - mean, 2));
const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / values.length;
const stdDev = Math.sqrt(avgSquaredDiff);
return { mean, min, max, p95, stdDev };
}
export async function execute(args: string[], options: BenchmarkOptions): Promise<void> {
const subcommand = args[0] || 'run';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
switch (subcommand) {
case 'run':
await runAllBenchmarks(args.slice(1), options, outputFormat);
break;
case 'import':
await importBenchmark(args.slice(1), options, outputFormat);
break;
case 'memory':
await memoryBenchmark(args.slice(1), options, outputFormat);
break;
case 'latency':
await latencyBenchmark(args.slice(1), options, outputFormat);
break;
case 'streaming':
await streamingBenchmark(args.slice(1), options, outputFormat);
break;
case 'embedding':
await embeddingBenchmark(args.slice(1), options, outputFormat);
break;
default:
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown subcommand: ${subcommand}`));
} else {
await pretty.error(`Unknown subcommand: ${subcommand}`);
await pretty.info('Available subcommands: run, import, memory, latency, streaming, embedding');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
}
/**
* Run all benchmarks
*/
async function runAllBenchmarks(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 5;
const results: BenchmarkResult[] = [];
if (outputFormat !== 'json') {
await pretty.success('Running All Benchmarks');
console.log(`Iterations: ${iterations}`);
console.log('');
}
// Import benchmark
const importResult = await measureImportTime(iterations);
results.push(importResult);
// Memory benchmark
const memoryResult = await measureMemory(iterations);
results.push(memoryResult);
// Latency benchmark (mock mode unless --real)
if (options.real) {
const latencyResult = await measureLatency(iterations, true);
results.push(latencyResult);
} else {
const latencyResult = await measureLatency(iterations, false);
results.push(latencyResult);
}
const report: BenchmarkReport = {
timestamp: new Date().toISOString(),
results,
};
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess(report));
} else {
printBenchmarkTable(results);
console.log('');
await pretty.info('Use --real flag to run benchmarks with real API calls');
}
}
/**
* Benchmark import time
*/
async function importBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 10;
if (outputFormat !== 'json') {
await pretty.success('Import Time Benchmark');
console.log(`Iterations: ${iterations}`);
console.log('');
}
const results: BenchmarkResult[] = [];
// Measure core import (without AI SDK)
const coreResult = await measureCoreImportTime(iterations);
results.push(coreResult);
// Measure AI SDK import
const aiSdkResult = await measureAISDKImportTime(iterations);
results.push(aiSdkResult);
// Measure full import (with AI SDK loaded)
const fullResult = await measureImportTime(iterations);
results.push(fullResult);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ results }));
} else {
printBenchmarkTable(results);
console.log('');
// Show comparison
const overhead = aiSdkResult.mean;
console.log(`AI SDK import overhead: ${overhead.toFixed(2)}ms`);
console.log(`Core import (no AI SDK): ${coreResult.mean.toFixed(2)}ms`);
}
}
/**
* Benchmark memory usage
*/
async function memoryBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 5;
if (outputFormat !== 'json') {
await pretty.success('Memory Usage Benchmark');
console.log(`Iterations: ${iterations}`);
console.log('');
}
const result = await measureMemory(iterations);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ result }));
} else {
printBenchmarkTable([result]);
}
}
/**
* Benchmark first-call latency
*/
async function latencyBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 3;
const useReal = options.real || false;
if (outputFormat !== 'json') {
await pretty.success('First-Call Latency Benchmark');
console.log(`Iterations: ${iterations}`);
console.log(`Mode: ${useReal ? 'Real API' : 'Mock'}`);
console.log('');
}
const result = await measureLatency(iterations, useReal);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ result }));
} else {
printBenchmarkTable([result]);
if (!useReal) {
console.log('');
await pretty.info('Use --real flag to measure with real API calls');
}
}
}
/**
* Benchmark streaming throughput
*/
async function streamingBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 3;
const useReal = options.real || false;
if (outputFormat !== 'json') {
await pretty.success('Streaming Throughput Benchmark');
console.log(`Iterations: ${iterations}`);
console.log(`Mode: ${useReal ? 'Real API' : 'Mock'}`);
console.log('');
}
if (!useReal) {
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({
message: 'Streaming benchmark requires --real flag',
result: null
}));
} else {
await pretty.info('Streaming benchmark requires --real flag for meaningful results');
}
return;
}
const result = await measureStreaming(iterations);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ result }));
} else {
printBenchmarkTable([result]);
}
}
/**
* Benchmark embedding throughput
*/
async function embeddingBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 3;
const useReal = options.real || false;
if (outputFormat !== 'json') {
await pretty.success('Embedding Throughput Benchmark');
console.log(`Iterations: ${iterations}`);
console.log(`Mode: ${useReal ? 'Real API' : 'Mock'}`);
console.log('');
}
if (!useReal) {
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({
message: 'Embedding benchmark requires --real flag',
result: null
}));
} else {
await pretty.info('Embedding benchmark requires --real flag for meaningful results');
}
return;
}
const result = await measureEmbedding(iterations);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ result }));
} else {
printBenchmarkTable([result]);
}
}
// Measurement functions
async function measureCoreImportTime(iterations: number): Promise<BenchmarkResult> {
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
// Clear require cache for accurate measurement
const cacheKeys = Object.keys(require.cache).filter(k =>
k.includes('praisonai-ts') && !k.includes('node_modules')
);
cacheKeys.forEach(k => delete require.cache[k]);
const start = performance.now();
// Import just the Agent class
await import('../../agent/simple');
const end = performance.now();
times.push(end - start);
}
const stats = calculateStats(times);
return {
name: 'Core Import (Agent)',
iterations,
...stats,
unit: 'ms',
};
}
async function measureAISDKImportTime(iterations: number): Promise<BenchmarkResult> {
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
// Clear AI SDK from cache
const cacheKeys = Object.keys(require.cache).filter(k => k.includes('ai'));
cacheKeys.forEach(k => delete require.cache[k]);
const start = performance.now();
try {
await import('ai');
} catch {
// AI SDK not installed
}
const end = performance.now();
times.push(end - start);
}
const stats = calculateStats(times);
return {
name: 'AI SDK Import',
iterations,
...stats,
unit: 'ms',
};
}
async function measureImportTime(iterations: number): Promise<BenchmarkResult> {
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
// Import the main module
await import('../../index');
const end = performance.now();
times.push(end - start);
}
const stats = calculateStats(times);
return {
name: 'Full Import',
iterations,
...stats,
unit: 'ms',
};
}
async function measureMemory(iterations: number): Promise<BenchmarkResult> {
const measurements: number[] = [];
for (let i = 0; i < iterations; i++) {
// Force GC if available
if (global.gc) global.gc();
const before = process.memoryUsage().heapUsed;
// Import and create an agent
const { Agent } = await import('../../agent/simple');
const agent = new Agent({ instructions: 'Test agent' });
const after = process.memoryUsage().heapUsed;
measurements.push((after - before) / 1024 / 1024); // Convert to MB
// Keep reference to prevent GC
void agent;
}
const stats = calculateStats(measurements);
return {
name: 'Memory (Agent creation)',
iterations,
...stats,
unit: 'MB',
};
}
async function measureLatency(iterations: number, useReal: boolean): Promise<BenchmarkResult> {
const times: number[] = [];
if (useReal) {
const { Agent } = await import('../../agent/simple');
for (let i = 0; i < iterations; i++) {
const agent = new Agent({
instructions: 'Say "OK" and nothing else.',
stream: false,
});
const start = performance.now();
try {
await agent.chat('Hi');
} catch {
// API error, skip
}
const end = performance.now();
times.push(end - start);
}
} else {
// Mock latency measurement
for (let i = 0; i < iterations; i++) {
const start = performance.now();
// Simulate backend resolution
const { resolveBackend } = await import('../../llm/backend-resolver');
await resolveBackend('openai/gpt-4o-mini');
const end = performance.now();
times.push(end - start);
}
}
const stats = calculateStats(times);
return {
name: useReal ? 'First-Call Latency (Real)' : 'Backend Resolution',
iterations,
...stats,
unit: 'ms',
};
}
async function measureStreaming(iterations: number): Promise<BenchmarkResult> {
const throughputs: number[] = [];
const { Agent } = await import('../../agent/simple');
for (let i = 0; i < iterations; i++) {
const agent = new Agent({
instructions: 'Count from 1 to 20.',
stream: true,
});
let tokenCount = 0;
const start = performance.now();
try {
// Note: This is a simplified measurement
const response = await agent.chat('Count');
tokenCount = response.split(/\s+/).length;
} catch {
// API error
}
const end = performance.now();
const duration = (end - start) / 1000; // seconds
throughputs.push(tokenCount / duration);
}
const stats = calculateStats(throughputs);
return {
name: 'Streaming Throughput',
iterations,
...stats,
unit: 'tokens/sec',
};
}
async function measureEmbedding(iterations: number): Promise<BenchmarkResult> {
const throughputs: number[] = [];
const { embedMany } = await import('../../llm/embeddings');
// Test texts
const texts = Array(10).fill(0).map((_, i) => `This is test text number ${i + 1} for embedding benchmark.`);
for (let i = 0; i < iterations; i++) {
const start = performance.now();
try {
await embedMany(texts);
} catch {
// API error
}
const end = performance.now();
const duration = (end - start) / 1000; // seconds
throughputs.push(texts.length / duration);
}
const stats = calculateStats(throughputs);
return {
name: 'Embedding Throughput',
iterations,
...stats,
unit: 'vectors/sec',
};
}
function printBenchmarkTable(results: BenchmarkResult[]): void {
console.log('Benchmark Results');
console.log('─'.repeat(80));
console.log(
'Name'.padEnd(30) +
'Mean'.padStart(10) +
'Min'.padStart(10) +
'Max'.padStart(10) +
'P95'.padStart(10) +
'Unit'.padStart(10)
);
console.log('─'.repeat(80));
for (const r of results) {
console.log(
r.name.padEnd(30) +
r.mean.toFixed(2).padStart(10) +
r.min.toFixed(2).padStart(10) +
r.max.toFixed(2).padStart(10) +
r.p95.toFixed(2).padStart(10) +
r.unit.padStart(10)
);
}
console.log('─'.repeat(80));
}
+2 -2
View File
@@ -2,7 +2,7 @@
* Chat command - Chat with an AI agent
*/
import { createProvider } from '../../llm/providers';
import { resolveBackend } from '../../llm/backend-resolver';
import { Session } from '../../session';
import { resolveConfig } from '../config/resolve';
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
@@ -46,7 +46,7 @@ export async function execute(args: string[], options: ChatOptions): Promise<voi
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
const provider = createProvider(config.model);
const { provider } = await resolveBackend(config.model);
const session = new Session({ id: options.session });
session.addMessage({ role: 'user', content: prompt });
+362
View File
@@ -0,0 +1,362 @@
/**
* Embed Command - Generate embeddings using AI SDK (preferred) or native provider
*
* Subcommands:
* - text: Embed a single text or multiple texts
* - file: Embed contents of a file
* - query: Find similar texts using embeddings
*/
import { outputJson, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { ERROR_CODES } from '../output/errors';
// Simple success formatter for embed command
function formatEmbedSuccess(data: any, meta?: any): { success: true; data: any; meta?: any } {
return {
success: true as const,
data,
meta,
};
}
import * as fs from 'fs';
import * as path from 'path';
export interface EmbedOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
model?: string;
provider?: string;
backend?: 'ai-sdk' | 'native' | 'auto';
dimensions?: number;
file?: string;
save?: string;
}
const EXIT_CODES = {
SUCCESS: 0,
GENERAL_ERROR: 1,
INVALID_ARGS: 2,
} as const;
export async function execute(args: string[], options: EmbedOptions): Promise<void> {
const subcommand = args[0] || 'text';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
switch (subcommand) {
case 'text':
await textCommand(args.slice(1), options, outputFormat);
break;
case 'file':
await fileCommand(args.slice(1), options, outputFormat);
break;
case 'query':
await queryCommand(args.slice(1), options, outputFormat);
break;
case 'models':
await modelsCommand(args.slice(1), options, outputFormat);
break;
default:
// If no subcommand, treat args[0] as text to embed
if (args[0] && !['text', 'file', 'query', 'models'].includes(args[0])) {
await textCommand(args, options, outputFormat);
} else {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown subcommand: ${subcommand}`));
} else {
await pretty.error(`Unknown subcommand: ${subcommand}`);
await pretty.info('Available subcommands: text, file, query, models');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
}
}
/**
* Embed text(s)
*/
async function textCommand(args: string[], options: EmbedOptions, outputFormat: string): Promise<void> {
const texts = args.filter(a => !a.startsWith('-'));
if (texts.length === 0) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide text to embed'));
} else {
await pretty.error('Please provide text to embed');
await pretty.info('Usage: praisonai-ts embed text "Hello world"');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
try {
const { embed, embedMany, getDefaultEmbeddingModel, isAISDKAvailable } = await import('../../llm');
const startTime = Date.now();
const model = options.model || getDefaultEmbeddingModel(options.provider);
const aiSdkAvailable = await isAISDKAvailable();
let result: { embeddings: number[][]; usage?: { tokens: number } };
if (texts.length === 1) {
const single = await embed(texts[0], {
model,
backend: options.backend
});
result = {
embeddings: [single.embedding],
usage: single.usage
};
} else {
result = await embedMany(texts, {
model,
backend: options.backend
});
}
const duration = Date.now() - startTime;
// Save to file if requested
if (options.save) {
const saveData = {
texts,
embeddings: result.embeddings,
model,
backend: aiSdkAvailable ? 'ai-sdk' : 'native',
timestamp: new Date().toISOString(),
};
fs.writeFileSync(options.save, JSON.stringify(saveData, null, 2));
}
if (outputFormat === 'json') {
outputJson(formatEmbedSuccess({
texts,
embeddings: result.embeddings,
dimensions: result.embeddings[0]?.length || 0,
count: result.embeddings.length,
}, {
model,
backend: aiSdkAvailable ? 'ai-sdk' : 'native',
duration_ms: duration,
tokens: result.usage?.tokens,
saved_to: options.save,
}));
} else {
await pretty.success(`Embedded ${texts.length} text(s)`);
await pretty.info(`Model: ${model}`);
await pretty.info(`Backend: ${aiSdkAvailable ? 'AI SDK' : 'Native'}`);
await pretty.info(`Dimensions: ${result.embeddings[0]?.length || 0}`);
await pretty.info(`Duration: ${duration}ms`);
if (result.usage?.tokens) {
await pretty.info(`Tokens: ${result.usage.tokens}`);
}
if (options.save) {
await pretty.info(`Saved to: ${options.save}`);
}
if (options.verbose) {
console.log('\nEmbeddings (first 5 values each):');
result.embeddings.forEach((emb, i) => {
console.log(` [${i}]: [${emb.slice(0, 5).join(', ')}...]`);
});
}
}
} catch (error: any) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error.message));
} else {
await pretty.error(`Embedding failed: ${error.message}`);
}
process.exit(EXIT_CODES.GENERAL_ERROR);
}
}
/**
* Embed file contents
*/
async function fileCommand(args: string[], options: EmbedOptions, outputFormat: string): Promise<void> {
const filePath = args[0];
if (!filePath) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a file path'));
} else {
await pretty.error('Please provide a file path');
await pretty.info('Usage: praisonai-ts embed file ./document.txt');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
try {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`File not found: ${absolutePath}`);
}
const content = fs.readFileSync(absolutePath, 'utf-8');
const { embed, getDefaultEmbeddingModel, isAISDKAvailable } = await import('../../llm');
const startTime = Date.now();
const model = options.model || getDefaultEmbeddingModel(options.provider);
const aiSdkAvailable = await isAISDKAvailable();
const result = await embed(content, {
model,
backend: options.backend
});
const duration = Date.now() - startTime;
// Save to file if requested
if (options.save) {
const saveData = {
file: absolutePath,
embedding: result.embedding,
model,
backend: aiSdkAvailable ? 'ai-sdk' : 'native',
timestamp: new Date().toISOString(),
};
fs.writeFileSync(options.save, JSON.stringify(saveData, null, 2));
}
if (outputFormat === 'json') {
outputJson(formatEmbedSuccess({
file: absolutePath,
embedding: result.embedding,
dimensions: result.embedding.length,
content_length: content.length,
}, {
model,
backend: aiSdkAvailable ? 'ai-sdk' : 'native',
duration_ms: duration,
tokens: result.usage?.tokens,
saved_to: options.save,
}));
} else {
await pretty.success(`Embedded file: ${filePath}`);
await pretty.info(`Model: ${model}`);
await pretty.info(`Backend: ${aiSdkAvailable ? 'AI SDK' : 'Native'}`);
await pretty.info(`Dimensions: ${result.embedding.length}`);
await pretty.info(`Content length: ${content.length} chars`);
await pretty.info(`Duration: ${duration}ms`);
if (options.save) {
await pretty.info(`Saved to: ${options.save}`);
}
}
} catch (error: any) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error.message));
} else {
await pretty.error(`Embedding failed: ${error.message}`);
}
process.exit(EXIT_CODES.GENERAL_ERROR);
}
}
/**
* Query similar texts
*/
async function queryCommand(args: string[], options: EmbedOptions, outputFormat: string): Promise<void> {
const query = args[0];
const dataFile = options.file;
if (!query) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a query'));
} else {
await pretty.error('Please provide a query');
await pretty.info('Usage: praisonai-ts embed query "search text" --file embeddings.json');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
if (!dataFile) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide --file with embeddings data'));
} else {
await pretty.error('Please provide --file with embeddings data');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
try {
const { embed, cosineSimilarity, getDefaultEmbeddingModel } = await import('../../llm');
// Load embeddings data
const data = JSON.parse(fs.readFileSync(dataFile, 'utf-8'));
const texts: string[] = data.texts || [];
const embeddings: number[][] = data.embeddings || [];
if (embeddings.length === 0) {
throw new Error('No embeddings found in data file');
}
// Embed the query
const model = options.model || data.model || getDefaultEmbeddingModel(options.provider);
const queryResult = await embed(query, { model, backend: options.backend });
// Calculate similarities
const similarities = embeddings.map((emb, i) => ({
index: i,
text: texts[i] || `[${i}]`,
score: cosineSimilarity(queryResult.embedding, emb),
}));
// Sort by similarity (descending)
similarities.sort((a, b) => b.score - a.score);
// Take top 5
const topResults = similarities.slice(0, 5);
if (outputFormat === 'json') {
outputJson(formatEmbedSuccess({
query,
results: topResults,
}, {
model,
total_embeddings: embeddings.length,
}));
} else {
await pretty.success(`Query: "${query}"`);
console.log('\nTop results:');
topResults.forEach((r, i) => {
console.log(` ${i + 1}. [${(r.score * 100).toFixed(1)}%] ${r.text.substring(0, 80)}${r.text.length > 80 ? '...' : ''}`);
});
}
} catch (error: any) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error.message));
} else {
await pretty.error(`Query failed: ${error.message}`);
}
process.exit(EXIT_CODES.GENERAL_ERROR);
}
}
/**
* List available embedding models
*/
async function modelsCommand(args: string[], options: EmbedOptions, outputFormat: string): Promise<void> {
const models = [
{ provider: 'openai', model: 'text-embedding-3-small', dimensions: 1536, description: 'Fast, cost-effective' },
{ provider: 'openai', model: 'text-embedding-3-large', dimensions: 3072, description: 'Higher quality' },
{ provider: 'openai', model: 'text-embedding-ada-002', dimensions: 1536, description: 'Legacy model' },
{ provider: 'google', model: 'text-embedding-004', dimensions: 768, description: 'Google embedding' },
{ provider: 'cohere', model: 'embed-english-v3.0', dimensions: 1024, description: 'Cohere English' },
{ provider: 'cohere', model: 'embed-multilingual-v3.0', dimensions: 1024, description: 'Cohere Multilingual' },
];
if (outputFormat === 'json') {
outputJson(formatEmbedSuccess({ models }));
} else {
await pretty.success('Available Embedding Models');
console.log('');
console.log('Provider Model Dimensions Description');
console.log('─'.repeat(75));
models.forEach(m => {
console.log(`${m.provider.padEnd(12)}${m.model.padEnd(27)}${String(m.dimensions).padEnd(12)}${m.description}`);
});
console.log('');
await pretty.info('Usage: praisonai-ts embed text "Hello" --model openai/text-embedding-3-small');
}
}
+777
View File
@@ -0,0 +1,777 @@
/**
* LLM Command - AI SDK provider management and testing
*
* Subcommands:
* - providers: List available AI SDK providers
* - test: Test connectivity to a provider
* - validate: Validate provider configuration
* - run: Run a prompt with a specific model
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { ERROR_CODES } from '../output/errors';
export interface LLMOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
model?: string;
stream?: boolean;
timeout?: number;
provider?: string;
schema?: string;
tool?: string;
}
// Exit codes specific to LLM commands
const LLM_EXIT_CODES = {
SUCCESS: 0,
GENERAL_ERROR: 1,
INVALID_ARGS: 2,
AUTH_ERROR: 3,
PROVIDER_NOT_FOUND: 4,
NETWORK_ERROR: 5,
TIMEOUT: 6,
} as const;
export async function execute(args: string[], options: LLMOptions): Promise<void> {
const subcommand = args[0] || 'providers';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
switch (subcommand) {
case 'providers':
await providersCommand(args.slice(1), options, outputFormat);
break;
case 'test':
await testCommand(args.slice(1), options, outputFormat);
break;
case 'validate':
await validateCommand(args.slice(1), options, outputFormat);
break;
case 'run':
await runCommand(args.slice(1), options, outputFormat);
break;
case 'models':
await modelsCommand(args.slice(1), options, outputFormat);
break;
case 'config':
await configCommand(args.slice(1), options, outputFormat);
break;
case 'trace':
await traceCommand(args.slice(1), options, outputFormat);
break;
case 'tools':
await toolsCommand(args.slice(1), options, outputFormat);
break;
case 'json':
await jsonCommand(args.slice(1), options, outputFormat);
break;
default:
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown subcommand: ${subcommand}`));
} else {
await pretty.error(`Unknown subcommand: ${subcommand}`);
await pretty.info('Available subcommands: providers, test, validate, run, models, config, trace, tools, json');
}
process.exit(LLM_EXIT_CODES.INVALID_ARGS);
}
}
/**
* List available AI SDK providers
*/
async function providersCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const {
listSupportedProviders,
AISDK_PROVIDERS,
validateProviderApiKey,
isAISDKAvailable,
getAISDKVersion
} = await import('../../llm/providers/ai-sdk');
const providers = listSupportedProviders();
const aiSdkAvailable = await isAISDKAvailable();
const aiSdkVersion = await getAISDKVersion();
const providerDetails = providers.map((id: string) => {
const info = AISDK_PROVIDERS[id as keyof typeof AISDK_PROVIDERS];
const hasApiKey = validateProviderApiKey(id);
return {
id,
package: info?.package || 'custom',
envKey: info?.envKey || 'N/A',
hasApiKey,
status: hasApiKey ? 'ready' : 'missing_key'
};
});
if (outputFormat === 'json') {
outputJson(formatSuccess({
ai_sdk_available: aiSdkAvailable,
ai_sdk_version: aiSdkVersion,
providers: providerDetails,
total: providers.length
}));
} else {
await pretty.heading('AI SDK Providers');
if (aiSdkAvailable) {
await pretty.success(`AI SDK: ${aiSdkVersion || 'installed'}`);
} else {
await pretty.warn('AI SDK not installed. Run: npm install ai');
}
await pretty.newline();
await pretty.plain(' Available Providers:');
for (const p of providerDetails) {
const status = p.hasApiKey ? '✅' : '⚠️';
const keyInfo = p.hasApiKey ? '🔑' : `Missing ${p.envKey}`;
await pretty.plain(` ${status} ${p.id.padEnd(20)} ${keyInfo}`);
if (options.verbose) {
await pretty.dim(` Package: ${p.package}`);
}
}
await pretty.newline();
await pretty.info('Set API keys via environment variables');
await pretty.dim(' Example: export OPENAI_API_KEY=sk-...');
}
} catch (error) {
handleError(error, outputFormat, 'Failed to list providers');
}
}
/**
* Test connectivity to a provider
*/
async function testCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
const providerId = args[0] || options.provider;
if (!providerId) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Provider ID required'));
} else {
await pretty.error('Provider ID required. Usage: llm test <provider>');
await pretty.dim(' Example: praisonai-ts llm test openai');
}
process.exit(LLM_EXIT_CODES.INVALID_ARGS);
return;
}
try {
const {
createAISDKBackend,
validateProviderApiKey,
getMissingApiKeyMessage,
isProviderSupported
} = await import('../../llm/providers/ai-sdk');
// Check if provider is supported
if (!isProviderSupported(providerId)) {
if (outputFormat === 'json') {
outputJson(formatError('PROVIDER_NOT_FOUND', `Provider '${providerId}' is not supported`));
} else {
await pretty.error(`Provider '${providerId}' is not supported`);
}
process.exit(LLM_EXIT_CODES.PROVIDER_NOT_FOUND);
return;
}
// Check API key
if (!validateProviderApiKey(providerId)) {
const msg = getMissingApiKeyMessage(providerId);
if (outputFormat === 'json') {
outputJson(formatError('AUTH_ERROR', msg));
} else {
await pretty.error(msg);
}
process.exit(LLM_EXIT_CODES.AUTH_ERROR);
return;
}
// Determine test model based on provider
const testModels: Record<string, string> = {
openai: 'gpt-4o-mini',
anthropic: 'claude-3-haiku-20240307',
google: 'gemini-1.5-flash',
groq: 'llama-3.1-8b-instant',
mistral: 'mistral-small-latest',
cohere: 'command-light',
deepseek: 'deepseek-chat',
};
const modelId = options.model || testModels[providerId] || 'default';
const modelString = `${providerId}/${modelId}`;
if (outputFormat !== 'json') {
await pretty.info(`Testing ${modelString}...`);
}
const startTime = Date.now();
const backend = createAISDKBackend(modelString, {
timeout: options.timeout || 30000,
maxRetries: 0, // No retries for test
});
const result = await backend.generateText({
messages: [
{ role: 'user', content: 'Say "OK" and nothing else.' }
],
maxTokens: 10,
temperature: 0,
});
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
provider: providerId,
model: modelId,
status: 'success',
duration_ms: duration,
response: result.text,
usage: result.usage
}));
} else {
await pretty.success(`Connection successful! (${duration}ms)`);
await pretty.plain(` Provider: ${providerId}`);
await pretty.plain(` Model: ${modelId}`);
await pretty.plain(` Response: ${result.text.trim()}`);
if (result.usage) {
await pretty.dim(` Tokens: ${result.usage.totalTokens}`);
}
}
} catch (error) {
handleError(error, outputFormat, `Failed to test provider '${providerId}'`);
}
}
/**
* Validate provider configuration
*/
async function validateCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
const providerId = args[0] || options.provider;
if (!providerId) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Provider ID required'));
} else {
await pretty.error('Provider ID required. Usage: llm validate --provider <id>');
}
process.exit(LLM_EXIT_CODES.INVALID_ARGS);
return;
}
try {
const {
isProviderSupported,
validateProviderApiKey,
getProviderEnvKey,
getProviderPackage,
isAISDKAvailable
} = await import('../../llm/providers/ai-sdk');
const checks: Array<{ name: string; passed: boolean; message: string }> = [];
// Check AI SDK availability
const aiSdkAvailable = await isAISDKAvailable();
checks.push({
name: 'AI SDK Installed',
passed: aiSdkAvailable,
message: aiSdkAvailable ? 'AI SDK is installed' : 'AI SDK not found. Run: npm install ai'
});
// Check provider support
const isSupported = isProviderSupported(providerId);
checks.push({
name: 'Provider Supported',
passed: isSupported,
message: isSupported ? `Provider '${providerId}' is supported` : `Provider '${providerId}' is not supported`
});
if (isSupported) {
// Check provider package
const pkg = getProviderPackage(providerId);
if (pkg) {
let pkgInstalled = false;
try {
require.resolve(pkg);
pkgInstalled = true;
} catch {
pkgInstalled = false;
}
checks.push({
name: 'Provider Package',
passed: pkgInstalled,
message: pkgInstalled ? `${pkg} is installed` : `${pkg} not found. Run: npm install ${pkg}`
});
}
// Check API key
const hasApiKey = validateProviderApiKey(providerId);
const envKey = getProviderEnvKey(providerId);
checks.push({
name: 'API Key',
passed: hasApiKey,
message: hasApiKey
? `${envKey} is set`
: `${envKey} is not set`
});
}
const allPassed = checks.every(c => c.passed);
if (outputFormat === 'json') {
outputJson(formatSuccess({
provider: providerId,
valid: allPassed,
checks
}));
} else {
await pretty.heading(`Validation: ${providerId}`);
for (const check of checks) {
const icon = check.passed ? '✅' : '❌';
await pretty.plain(` ${icon} ${check.name}: ${check.message}`);
}
await pretty.newline();
if (allPassed) {
await pretty.success('All checks passed!');
} else {
await pretty.warn('Some checks failed. Fix the issues above.');
process.exit(LLM_EXIT_CODES.GENERAL_ERROR);
}
}
} catch (error) {
handleError(error, outputFormat, `Failed to validate provider '${providerId}'`);
}
}
/**
* Run a prompt with a specific model
*/
async function runCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
const prompt = args.join(' ');
if (!prompt) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Prompt required'));
} else {
await pretty.error('Prompt required. Usage: llm run "<prompt>" --model <provider/model>');
await pretty.dim(' Example: praisonai-ts llm run "Hello!" --model openai/gpt-4o-mini');
}
process.exit(LLM_EXIT_CODES.INVALID_ARGS);
return;
}
const modelString = options.model || 'openai/gpt-4o-mini';
try {
const { createAISDKBackend } = await import('../../llm/providers/ai-sdk');
const backend = createAISDKBackend(modelString, {
timeout: options.timeout || 60000,
});
if (options.stream) {
// Streaming mode
if (outputFormat === 'json') {
// JSON streaming - output each chunk as JSON line
const stream = await backend.streamText({
messages: [{ role: 'user', content: prompt }],
});
process.stdout.write('{"type":"stream_start"}\n');
let fullText = '';
for await (const chunk of stream) {
if (chunk.text) {
fullText += chunk.text;
process.stdout.write(JSON.stringify({ type: 'text', text: chunk.text }) + '\n');
}
if (chunk.finishReason) {
process.stdout.write(JSON.stringify({
type: 'finish',
finishReason: chunk.finishReason,
usage: chunk.usage
}) + '\n');
}
}
process.stdout.write(JSON.stringify({ type: 'stream_end', fullText }) + '\n');
} else {
// Pretty streaming - output text directly
const stream = await backend.streamText({
messages: [{ role: 'user', content: prompt }],
});
for await (const chunk of stream) {
if (chunk.text) {
process.stdout.write(chunk.text);
}
}
process.stdout.write('\n');
}
} else {
// Non-streaming mode
const startTime = Date.now();
const result = await backend.generateText({
messages: [{ role: 'user', content: prompt }],
});
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
model: modelString,
text: result.text,
usage: result.usage,
finishReason: result.finishReason,
duration_ms: duration
}));
} else {
await pretty.plain(result.text);
if (options.verbose) {
await pretty.newline();
await pretty.dim(`Model: ${modelString}`);
await pretty.dim(`Duration: ${duration}ms`);
if (result.usage) {
await pretty.dim(`Tokens: ${result.usage.totalTokens} (prompt: ${result.usage.promptTokens}, completion: ${result.usage.completionTokens})`);
}
}
}
}
} catch (error) {
handleError(error, outputFormat, `Failed to run prompt with model '${modelString}'`);
}
}
/**
* Handle errors with proper exit codes and formatting
*/
/**
* List common models for each provider
*/
async function modelsCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const { listSupportedProviders, AISDK_PROVIDERS } = await import('../../llm/providers/ai-sdk');
// Common models per provider (not exhaustive)
const commonModels: Record<string, string[]> = {
openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo', 'o1', 'o1-mini'],
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
google: ['gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'],
cohere: ['command-r-plus', 'command-r', 'command-light'],
deepseek: ['deepseek-chat', 'deepseek-coder'],
xai: ['grok-2', 'grok-2-mini'],
};
const providers = listSupportedProviders();
const modelsData = providers.map((id: string) => ({
provider: id,
models: commonModels[id] || ['(check provider docs)'],
package: AISDK_PROVIDERS[id as keyof typeof AISDK_PROVIDERS]?.package || 'custom'
}));
if (outputFormat === 'json') {
outputJson(formatSuccess({
note: 'This is not an exhaustive list. Check provider documentation for all available models.',
providers: modelsData
}));
} else {
await pretty.heading('Common Models by Provider');
await pretty.dim('Note: This is not exhaustive. Check provider docs for all models.\n');
for (const p of modelsData) {
await pretty.plain(` ${p.provider}:`);
for (const model of p.models) {
await pretty.dim(` - ${model}`);
}
}
await pretty.newline();
await pretty.info('Usage: praisonai-ts llm run "prompt" --model provider/model');
}
} catch (error) {
handleError(error, outputFormat, 'Failed to list models');
}
}
/**
* Show resolved configuration (redacted)
*/
async function configCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const { SAFE_DEFAULTS, AISDK_PROVIDERS, PROVIDER_ALIASES } = await import('../../llm/providers/ai-sdk');
// Gather environment config (redacted)
const envConfig: Record<string, string> = {};
for (const [id, info] of Object.entries(AISDK_PROVIDERS)) {
const envKey = (info as any).envKey;
if (envKey && process.env[envKey]) {
envConfig[envKey] = '****' + process.env[envKey]!.slice(-4);
}
}
const config = {
defaults: SAFE_DEFAULTS,
aliases: PROVIDER_ALIASES,
environment: envConfig,
model: options.model || 'openai/gpt-4o-mini',
timeout: options.timeout || SAFE_DEFAULTS.timeout,
};
if (outputFormat === 'json') {
outputJson(formatSuccess(config));
} else {
await pretty.heading('AI SDK Configuration');
await pretty.plain(' Defaults:');
await pretty.dim(` timeout: ${SAFE_DEFAULTS.timeout}ms`);
await pretty.dim(` maxRetries: ${SAFE_DEFAULTS.maxRetries}`);
await pretty.dim(` maxOutputTokens: ${SAFE_DEFAULTS.maxOutputTokens}`);
await pretty.dim(` redactLogs: ${SAFE_DEFAULTS.redactLogs}`);
await pretty.newline();
await pretty.plain(' Provider Aliases:');
for (const [alias, target] of Object.entries(PROVIDER_ALIASES)) {
await pretty.dim(` ${alias} → ${target}`);
}
await pretty.newline();
await pretty.plain(' Environment (redacted):');
if (Object.keys(envConfig).length === 0) {
await pretty.dim(' No API keys detected');
} else {
for (const [key, value] of Object.entries(envConfig)) {
await pretty.dim(` ${key}: ${value}`);
}
}
}
} catch (error) {
handleError(error, outputFormat, 'Failed to show config');
}
}
/**
* Demo trace/attribution headers
*/
async function traceCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const { createAISDKBackend, createAttributionMiddleware } = await import('../../llm/providers/ai-sdk');
const modelString = options.model || 'openai/gpt-4o-mini';
// Generate demo attribution context
const attribution = {
agentId: `agent-${Date.now().toString(36)}`,
runId: `run-${Math.random().toString(36).slice(2, 10)}`,
traceId: `trace-${Math.random().toString(36).slice(2, 10)}`,
sessionId: options.verbose ? `session-demo` : undefined,
};
if (outputFormat !== 'json') {
await pretty.heading('Attribution Trace Demo');
await pretty.info(`Running with model: ${modelString}`);
await pretty.newline();
await pretty.plain(' Attribution Context:');
await pretty.dim(` agentId: ${attribution.agentId}`);
await pretty.dim(` runId: ${attribution.runId}`);
await pretty.dim(` traceId: ${attribution.traceId}`);
if (attribution.sessionId) {
await pretty.dim(` sessionId: ${attribution.sessionId}`);
}
await pretty.newline();
await pretty.plain(' Headers injected:');
await pretty.dim(` X-Agent-Id: ${attribution.agentId}`);
await pretty.dim(` X-Run-Id: ${attribution.runId}`);
await pretty.dim(` X-Trace-Id: ${attribution.traceId}`);
await pretty.newline();
}
const backend = createAISDKBackend(modelString, {
timeout: options.timeout || 30000,
attribution,
});
const startTime = Date.now();
const result = await backend.generateText({
messages: [{ role: 'user', content: 'Say "Trace OK" and nothing else.' }],
maxTokens: 10,
});
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
attribution,
headers: {
'X-Agent-Id': attribution.agentId,
'X-Run-Id': attribution.runId,
'X-Trace-Id': attribution.traceId,
},
response: result.text,
duration_ms: duration,
}));
} else {
await pretty.success(`Response: ${result.text.trim()} (${duration}ms)`);
await pretty.newline();
await pretty.info('Attribution headers are automatically injected into LLM requests.');
await pretty.dim('Use --verbose for session ID.');
}
} catch (error) {
handleError(error, outputFormat, 'Failed to run trace demo');
}
}
/**
* Demo tool calling - shows how tools work with AI SDK
* Note: AI SDK requires Zod schemas for tools, which requires runtime setup
*/
async function toolsCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const modelString = options.model || 'openai/gpt-4o-mini';
if (outputFormat !== 'json') {
await pretty.heading('Tool Calling Demo');
await pretty.info(`Model: ${modelString}`);
await pretty.newline();
await pretty.plain(' AI SDK Tool Calling requires Zod schemas.');
await pretty.newline();
await pretty.plain(' Example code:');
await pretty.dim(` import { generateText, tool } from 'ai';`);
await pretty.dim(` import { openai } from '@ai-sdk/openai';`);
await pretty.dim(` import { z } from 'zod';`);
await pretty.newline();
await pretty.dim(` const tools = {`);
await pretty.dim(` get_weather: tool({`);
await pretty.dim(` description: 'Get weather for a city',`);
await pretty.dim(` inputSchema: z.object({`);
await pretty.dim(` city: z.string().describe('City name'),`);
await pretty.dim(` }),`);
await pretty.dim(` }),`);
await pretty.dim(` };`);
await pretty.newline();
await pretty.dim(` const result = await generateText({`);
await pretty.dim(` model: openai('gpt-4o-mini'),`);
await pretty.dim(` messages: [{ role: 'user', content: 'Weather in Paris?' }],`);
await pretty.dim(` tools,`);
await pretty.dim(` });`);
await pretty.newline();
await pretty.info('See examples/ai-sdk/tool-calling.ts for a complete example.');
} else {
outputJson(formatSuccess({
model: modelString,
note: 'AI SDK tool calling requires Zod schemas at runtime',
example: {
import: "import { generateText, tool } from 'ai'; import { z } from 'zod';",
tool: "tool({ description: '...', inputSchema: z.object({ city: z.string() }) })",
},
docs: 'See examples/ai-sdk/tool-calling.ts',
}));
}
} catch (error) {
handleError(error, outputFormat, 'Failed to run tools demo');
}
}
/**
* Demo structured output (JSON schema) - shows how to use generateObject
* Note: AI SDK requires Zod schemas for structured output
*/
async function jsonCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const modelString = options.model || 'openai/gpt-4o-mini';
if (outputFormat !== 'json') {
await pretty.heading('Structured Output (JSON) Demo');
await pretty.info(`Model: ${modelString}`);
await pretty.newline();
await pretty.plain(' AI SDK Structured Output requires Zod schemas.');
await pretty.newline();
await pretty.plain(' Example code:');
await pretty.dim(` import { generateObject } from 'ai';`);
await pretty.dim(` import { openai } from '@ai-sdk/openai';`);
await pretty.dim(` import { z } from 'zod';`);
await pretty.newline();
await pretty.dim(` const schema = z.object({`);
await pretty.dim(` name: z.string(),`);
await pretty.dim(` age: z.number(),`);
await pretty.dim(` city: z.string(),`);
await pretty.dim(` });`);
await pretty.newline();
await pretty.dim(` const result = await generateObject({`);
await pretty.dim(` model: openai('gpt-4o-mini'),`);
await pretty.dim(` schema,`);
await pretty.dim(` prompt: 'Generate a fictional person profile.',`);
await pretty.dim(` });`);
await pretty.newline();
await pretty.dim(` console.log(result.object); // { name: '...', age: 25, city: '...' }`);
await pretty.newline();
await pretty.info('See examples/ai-sdk/structured-output.ts for a complete example.');
} else {
outputJson(formatSuccess({
model: modelString,
note: 'AI SDK structured output requires Zod schemas at runtime',
example: {
import: "import { generateObject } from 'ai'; import { z } from 'zod';",
schema: "z.object({ name: z.string(), age: z.number(), city: z.string() })",
},
docs: 'See examples/ai-sdk/structured-output.ts',
}));
}
} catch (error) {
handleError(error, outputFormat, 'Failed to run JSON demo');
}
}
/**
* Handle errors with proper exit codes and formatting
*/
async function handleError(error: unknown, outputFormat: string, context: string): Promise<never> {
const { AISDKError } = await import('../../llm/providers/ai-sdk');
let exitCode: number = LLM_EXIT_CODES.GENERAL_ERROR;
let errorCode = 'UNKNOWN_ERROR';
let message = context;
if (error instanceof AISDKError) {
message = `${context}: ${error.message}`;
errorCode = error.code;
switch (error.code) {
case 'AUTHENTICATION':
exitCode = 3; // AUTH_ERROR
break;
case 'PROVIDER_NOT_FOUND':
case 'MODEL_NOT_FOUND':
exitCode = 4; // PROVIDER_NOT_FOUND
break;
case 'NETWORK':
exitCode = 5; // NETWORK_ERROR
break;
case 'TIMEOUT':
exitCode = 6; // TIMEOUT
break;
case 'INVALID_REQUEST':
exitCode = 2; // INVALID_ARGS
break;
}
} else if (error instanceof Error) {
message = `${context}: ${error.message}`;
}
if (outputFormat === 'json') {
outputJson(formatError(errorCode, message));
} else {
await pretty.error(message);
}
process.exit(exitCode);
}
+56
View File
@@ -97,6 +97,30 @@ export const COMMANDS: Record<string, Command> = {
description: 'List available LLM providers',
flags: []
},
llm: {
description: 'AI SDK provider management and testing',
subcommands: {
providers: { description: 'List available AI SDK providers' },
test: {
description: 'Test connectivity to a provider',
args: [{ name: 'provider', type: 'string', required: false, position: 0 }]
},
validate: {
description: 'Validate provider configuration',
args: [{ name: 'provider', type: 'string', required: false, position: 0 }]
},
run: {
description: 'Run a prompt with a specific model',
args: [{ name: 'prompt', type: 'string', required: true, position: 0 }]
}
},
flags: [
{ name: 'model', short: 'm', type: 'string', description: 'Model to use (provider/model format)' },
{ name: 'stream', short: 's', type: 'boolean', default: false, description: 'Enable streaming output' },
{ name: 'timeout', type: 'integer', description: 'Request timeout in milliseconds' },
{ name: 'provider', short: 'p', type: 'string', description: 'Provider ID' }
]
},
tools: {
description: 'List or manage tools',
subcommands: {
@@ -465,6 +489,38 @@ export const COMMANDS: Record<string, Command> = {
args: [
{ name: 'command', type: 'string', required: false, position: 0 }
]
},
embed: {
description: 'Generate embeddings using AI SDK',
subcommands: {
text: { description: 'Embed text(s)', args: [{ name: 'text', type: 'string', required: true, position: 0 }] },
file: { description: 'Embed file contents', args: [{ name: 'path', type: 'string', required: true, position: 0 }] },
query: { description: 'Query similar texts', args: [{ name: 'query', type: 'string', required: true, position: 0 }] },
models: { description: 'List available embedding models' }
},
flags: [
{ name: 'model', short: 'm', type: 'string', description: 'Embedding model to use' },
{ name: 'provider', type: 'string', description: 'Provider (openai, google, cohere)' },
{ name: 'backend', type: 'string', description: 'Backend (ai-sdk, native, auto)' },
{ name: 'save', type: 'string', description: 'Save embeddings to file' },
{ name: 'file', type: 'string', description: 'Embeddings file for query' }
]
},
benchmark: {
description: 'Run performance benchmarks',
subcommands: {
run: { description: 'Run all benchmarks' },
import: { description: 'Benchmark import time' },
memory: { description: 'Benchmark memory usage' },
latency: { description: 'Benchmark first-call latency' },
streaming: { description: 'Benchmark streaming throughput' },
embedding: { description: 'Benchmark embedding throughput' }
},
flags: [
{ name: 'iterations', type: 'integer', description: 'Number of iterations' },
{ name: 'backend', type: 'string', description: 'Backend to test (ai-sdk, native, both)' },
{ name: 'real', type: 'boolean', description: 'Use real API calls (requires keys)' }
]
}
};
@@ -2,7 +2,8 @@
* LLMGuardrail - LLM-based content validation
*/
import { createProvider, type LLMProvider } from '../llm/providers';
import { type LLMProvider } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export interface LLMGuardrailConfig {
name: string;
@@ -25,18 +26,41 @@ export interface LLMGuardrailResult {
export class LLMGuardrail {
readonly name: string;
readonly criteria: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private threshold: number;
private verbose: boolean;
constructor(config: LLMGuardrailConfig) {
this.name = config.name;
this.criteria = config.criteria;
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.threshold = config.threshold ?? 0.7;
this.verbose = config.verbose ?? false;
}
/**
* Get the LLM provider (lazy initialization with AI SDK backend)
*/
private async getProvider(): Promise<LLMProvider> {
if (this.provider) {
return this.provider;
}
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, {
attribution: { agentId: `LLMGuardrail:${this.name}` },
});
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Check content against criteria
*/
@@ -55,7 +79,8 @@ Respond with a JSON object containing:
JSON response:`;
try {
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [{ role: 'user', content: prompt }]
});
@@ -0,0 +1,256 @@
/**
* Backend Resolver - Unified LLM backend resolution with AI SDK preference
*
* Resolution order:
* 1. AI SDK backend (if installed and enabled)
* 2. Native provider registry (OpenAI, Anthropic, Google)
* 3. Error with actionable guidance
*
* Environment variables:
* - PRAISONAI_BACKEND: 'ai-sdk' | 'native' | 'auto' (default: 'auto')
*/
import type { LLMProvider, ProviderConfig } from './providers/types';
export type BackendSource = 'ai-sdk' | 'native' | 'custom' | 'legacy';
export interface BackendResolutionResult {
provider: LLMProvider;
source: BackendSource;
providerId: string;
modelId: string;
warnings?: string[];
}
export interface ResolveBackendOptions {
/** Force a specific backend: 'ai-sdk' | 'native' | 'auto' (default: 'auto') */
backend?: 'ai-sdk' | 'native' | 'auto';
/** Provider configuration (API keys, timeouts, etc.) */
config?: ProviderConfig;
/** Attribution context for multi-agent tracing */
attribution?: {
agentId?: string;
runId?: string;
sessionId?: string;
};
}
// Cached availability check
let _aiSdkAvailable: boolean | null = null;
let _aiSdkCheckPromise: Promise<boolean> | null = null;
/**
* Check if AI SDK is available (installed)
* Result is cached after first check
*/
export async function isAISDKAvailable(): Promise<boolean> {
if (_aiSdkAvailable !== null) {
return _aiSdkAvailable;
}
if (_aiSdkCheckPromise) {
return _aiSdkCheckPromise;
}
_aiSdkCheckPromise = (async () => {
try {
// Dynamic import to avoid loading if not needed
const moduleName = 'ai';
await import(moduleName);
_aiSdkAvailable = true;
return true;
} catch {
_aiSdkAvailable = false;
return false;
}
})();
return _aiSdkCheckPromise;
}
/**
* Reset the AI SDK availability cache (for testing)
*/
export function resetAISDKAvailabilityCache(): void {
_aiSdkAvailable = null;
_aiSdkCheckPromise = null;
}
/**
* Get the preferred backend from environment
*/
export function getPreferredBackend(): 'ai-sdk' | 'native' | 'auto' {
const env = process.env.PRAISONAI_BACKEND?.toLowerCase();
if (env === 'ai-sdk' || env === 'aisdk') return 'ai-sdk';
if (env === 'native' || env === 'legacy') return 'native';
return 'auto';
}
/**
* Parse model string into provider and model ID
*/
export function parseModelString(model: string): { providerId: string; modelId: string } {
if (model.includes('/')) {
const [providerId, ...rest] = model.split('/');
return { providerId: providerId.toLowerCase(), modelId: rest.join('/') };
}
// Default to OpenAI for common model prefixes
if (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3')) {
return { providerId: 'openai', modelId: model };
}
if (model.startsWith('claude-')) {
return { providerId: 'anthropic', modelId: model };
}
if (model.startsWith('gemini-')) {
return { providerId: 'google', modelId: model };
}
// Default to OpenAI
return { providerId: 'openai', modelId: model };
}
/**
* Resolve the best available backend for a model
*
* @param modelString - Model string in format "provider/model" or just "model"
* @param options - Resolution options
* @returns Backend resolution result with provider instance
*
* @example
* ```typescript
* const { provider, source } = await resolveBackend('openai/gpt-4o-mini');
* const result = await provider.generateText({ messages: [...] });
* ```
*/
export async function resolveBackend(
modelString: string,
options: ResolveBackendOptions = {}
): Promise<BackendResolutionResult> {
const { providerId, modelId } = parseModelString(modelString);
const preferredBackend = options.backend || getPreferredBackend();
const warnings: string[] = [];
// Try AI SDK first if preferred or auto
if (preferredBackend === 'ai-sdk' || preferredBackend === 'auto') {
const aiSdkAvailable = await isAISDKAvailable();
if (aiSdkAvailable) {
try {
// Lazy import AI SDK backend
const { createAISDKBackend } = await import('./providers/ai-sdk');
const backend = createAISDKBackend(modelString, {
...options.config,
attribution: options.attribution ? {
agentId: options.attribution.agentId,
runId: options.attribution.runId,
sessionId: options.attribution.sessionId,
} : undefined,
});
return {
provider: backend,
source: 'ai-sdk',
providerId,
modelId,
warnings: warnings.length > 0 ? warnings : undefined,
};
} catch (error: any) {
if (preferredBackend === 'ai-sdk') {
// User explicitly requested AI SDK, throw error
throw new Error(
`AI SDK backend failed for '${modelString}': ${error.message}. ` +
`Set PRAISONAI_BACKEND=native to use native providers.`
);
}
// Auto mode: fall through to native
warnings.push(`AI SDK failed: ${error.message}, falling back to native provider`);
}
} else if (preferredBackend === 'ai-sdk') {
throw new Error(
`AI SDK is not installed but PRAISONAI_BACKEND=ai-sdk is set. ` +
`Install with: npm install ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google`
);
}
}
// Try native provider registry
try {
const { createProvider } = await import('./providers');
const provider = createProvider(modelString, options.config);
return {
provider,
source: 'native',
providerId,
modelId,
warnings: warnings.length > 0 ? warnings : undefined,
};
} catch (error: any) {
throw new Error(
`Cannot resolve backend for '${modelString}'. ` +
`Neither AI SDK nor native provider available. ` +
`Install AI SDK: npm install ai @ai-sdk/${providerId} ` +
`or check your provider configuration. ` +
`Original error: ${error.message}`
);
}
}
/**
* Resolve backend synchronously using cached availability
* Only works if isAISDKAvailable() has been called before
* Falls back to native if cache is not populated
*/
export function resolveBackendSync(
modelString: string,
options: ResolveBackendOptions = {}
): BackendResolutionResult {
const { providerId, modelId } = parseModelString(modelString);
const preferredBackend = options.backend || getPreferredBackend();
// If AI SDK availability is cached and available, try it first
if (_aiSdkAvailable === true && (preferredBackend === 'ai-sdk' || preferredBackend === 'auto')) {
try {
// This will throw if AI SDK module is not already loaded
const aiSdk = require('./providers/ai-sdk');
const backend = aiSdk.createAISDKBackend(modelString, {
...options.config,
attribution: options.attribution,
});
return {
provider: backend,
source: 'ai-sdk',
providerId,
modelId,
};
} catch {
// Fall through to native
}
}
// Use native provider
const { createProvider } = require('./providers');
const provider = createProvider(modelString, options.config);
return {
provider,
source: 'native',
providerId,
modelId,
};
}
/**
* Get default model string
*/
export function getDefaultModel(): string {
return process.env.OPENAI_MODEL_NAME ||
process.env.PRAISONAI_MODEL ||
'openai/gpt-4o-mini';
}
// Re-export types
export type { LLMProvider, ProviderConfig };
+350
View File
@@ -0,0 +1,350 @@
/**
* Embeddings - AI SDK-backed embedding utilities
*
* Provides embedding functionality with AI SDK preference and native fallback.
* Integrates with existing EmbeddingProvider interface in knowledge/rag.ts and memory/memory.ts
*/
import type { EmbeddingProvider } from '../knowledge/rag';
import { isAISDKAvailable, getPreferredBackend } from './backend-resolver';
export interface EmbeddingOptions {
/** Model to use for embeddings (default: text-embedding-3-small) */
model?: string;
/** Provider to use (default: openai) */
provider?: string;
/** Maximum retries (default: 2) */
maxRetries?: number;
/** Abort signal for cancellation */
abortSignal?: AbortSignal;
/** Additional headers */
headers?: Record<string, string>;
/** Force specific backend: 'ai-sdk' | 'native' | 'auto' */
backend?: 'ai-sdk' | 'native' | 'auto';
}
export interface EmbeddingResult {
embedding: number[];
usage?: {
tokens: number;
};
}
export interface EmbeddingBatchResult {
embeddings: number[][];
usage?: {
tokens: number;
};
}
// Default embedding models per provider
const DEFAULT_EMBEDDING_MODELS: Record<string, string> = {
openai: 'text-embedding-3-small',
anthropic: 'voyage-3', // Anthropic uses Voyage for embeddings
google: 'text-embedding-004',
cohere: 'embed-english-v3.0',
};
/**
* Get the default embedding model for a provider
*/
export function getDefaultEmbeddingModel(provider: string = 'openai'): string {
return DEFAULT_EMBEDDING_MODELS[provider.toLowerCase()] || DEFAULT_EMBEDDING_MODELS.openai;
}
/**
* Parse embedding model string into provider and model
*/
export function parseEmbeddingModel(model: string): { provider: string; model: string } {
if (model.includes('/')) {
const [provider, ...rest] = model.split('/');
return { provider: provider.toLowerCase(), model: rest.join('/') };
}
// Infer provider from model name
if (model.startsWith('text-embedding-') || model.startsWith('ada')) {
return { provider: 'openai', model };
}
if (model.startsWith('voyage-')) {
return { provider: 'anthropic', model };
}
if (model.startsWith('embed-')) {
return { provider: 'cohere', model };
}
// Default to OpenAI
return { provider: 'openai', model };
}
/**
* Embed a single text using AI SDK (preferred) or native provider
*/
export async function embed(
text: string,
options: EmbeddingOptions = {}
): Promise<EmbeddingResult> {
const modelString = options.model || getDefaultEmbeddingModel(options.provider);
const { provider } = parseEmbeddingModel(modelString);
const preferredBackend = options.backend || getPreferredBackend();
// Try AI SDK first
if (preferredBackend === 'ai-sdk' || preferredBackend === 'auto') {
const aiSdkAvailable = await isAISDKAvailable();
if (aiSdkAvailable) {
try {
return await embedWithAISDK(text, modelString, options);
} catch (error: any) {
if (preferredBackend === 'ai-sdk') {
throw error;
}
// Fall through to native
}
}
}
// Fall back to native OpenAI
return await embedWithNative(text, modelString, options);
}
/**
* Embed multiple texts using AI SDK (preferred) or native provider
*/
export async function embedMany(
texts: string[],
options: EmbeddingOptions = {}
): Promise<EmbeddingBatchResult> {
if (texts.length === 0) {
return { embeddings: [] };
}
const modelString = options.model || getDefaultEmbeddingModel(options.provider);
const preferredBackend = options.backend || getPreferredBackend();
// Try AI SDK first
if (preferredBackend === 'ai-sdk' || preferredBackend === 'auto') {
const aiSdkAvailable = await isAISDKAvailable();
if (aiSdkAvailable) {
try {
return await embedManyWithAISDK(texts, modelString, options);
} catch (error: any) {
if (preferredBackend === 'ai-sdk') {
throw error;
}
// Fall through to native
}
}
}
// Fall back to native OpenAI
return await embedManyWithNative(texts, modelString, options);
}
/**
* Embed using AI SDK
*/
async function embedWithAISDK(
text: string,
model: string,
options: EmbeddingOptions
): Promise<EmbeddingResult> {
const { provider, model: modelId } = parseEmbeddingModel(model);
// Dynamic import AI SDK
const ai = await import('ai');
// Get the embedding model from the appropriate provider
const embeddingModel = await getAISDKEmbeddingModel(provider, modelId);
const result = await ai.embed({
model: embeddingModel,
value: text,
maxRetries: options.maxRetries ?? 2,
abortSignal: options.abortSignal,
headers: options.headers,
});
return {
embedding: result.embedding,
usage: result.usage ? { tokens: result.usage.tokens } : undefined,
};
}
/**
* Embed many using AI SDK
*/
async function embedManyWithAISDK(
texts: string[],
model: string,
options: EmbeddingOptions
): Promise<EmbeddingBatchResult> {
const { provider, model: modelId } = parseEmbeddingModel(model);
// Dynamic import AI SDK
const ai = await import('ai');
// Get the embedding model from the appropriate provider
const embeddingModel = await getAISDKEmbeddingModel(provider, modelId);
const result = await ai.embedMany({
model: embeddingModel,
values: texts,
maxRetries: options.maxRetries ?? 2,
abortSignal: options.abortSignal,
headers: options.headers,
});
return {
embeddings: result.embeddings,
usage: result.usage ? { tokens: result.usage.tokens } : undefined,
};
}
/**
* Get AI SDK embedding model for a provider
*/
async function getAISDKEmbeddingModel(provider: string, modelId: string): Promise<any> {
switch (provider.toLowerCase()) {
case 'openai':
case 'oai': {
const { createOpenAI } = await import('@ai-sdk/openai');
const openai = createOpenAI({});
return openai.embedding(modelId);
}
case 'google':
case 'gemini': {
const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
const google = createGoogleGenerativeAI({});
return google.textEmbeddingModel(modelId);
}
case 'cohere': {
try {
// Dynamic import - cohere is optional
const cohereModule = await import('@ai-sdk/cohere' as string);
const cohere = cohereModule.createCohere({});
return cohere.embedding(modelId);
} catch {
throw new Error(`Cohere provider not installed. Install with: npm install @ai-sdk/cohere`);
}
}
default:
throw new Error(
`Embedding provider '${provider}' not supported via AI SDK. ` +
`Supported: openai, google, cohere`
);
}
}
/**
* Embed using native OpenAI client
*/
async function embedWithNative(
text: string,
model: string,
options: EmbeddingOptions
): Promise<EmbeddingResult> {
const { model: modelId } = parseEmbeddingModel(model);
const OpenAI = (await import('openai')).default;
const client = new OpenAI();
const response = await client.embeddings.create({
model: modelId,
input: text,
});
return {
embedding: response.data[0].embedding,
usage: response.usage ? { tokens: response.usage.total_tokens } : undefined,
};
}
/**
* Embed many using native OpenAI client
*/
async function embedManyWithNative(
texts: string[],
model: string,
options: EmbeddingOptions
): Promise<EmbeddingBatchResult> {
const { model: modelId } = parseEmbeddingModel(model);
const OpenAI = (await import('openai')).default;
const client = new OpenAI();
const response = await client.embeddings.create({
model: modelId,
input: texts,
});
// Sort by index to ensure correct order
const sorted = response.data.sort((a, b) => a.index - b.index);
return {
embeddings: sorted.map(d => d.embedding),
usage: response.usage ? { tokens: response.usage.total_tokens } : undefined,
};
}
/**
* Create an EmbeddingProvider that uses AI SDK
* Compatible with KnowledgeBase and Memory interfaces
*/
export function createEmbeddingProvider(options: EmbeddingOptions = {}): EmbeddingProvider {
return {
async embed(text: string): Promise<number[]> {
const result = await embed(text, options);
return result.embedding;
},
async embedBatch(texts: string[]): Promise<number[][]> {
const result = await embedMany(texts, options);
return result.embeddings;
},
};
}
/**
* Cosine similarity between two vectors
*/
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Vectors must have the same length');
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) {
return 0;
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
/**
* Euclidean distance between two vectors
*/
export function euclideanDistance(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Vectors must have the same length');
}
let sum = 0;
for (let i = 0; i < a.length; i++) {
const diff = a[i] - b[i];
sum += diff * diff;
}
return Math.sqrt(sum);
}
// Re-export types
export type { EmbeddingProvider };
+28
View File
@@ -37,3 +37,31 @@ export class BaseLLM implements LLM {
throw new Error('Method not implemented.');
}
}
// Export backend resolver
export {
resolveBackend,
resolveBackendSync,
isAISDKAvailable,
resetAISDKAvailabilityCache,
getPreferredBackend,
getDefaultModel,
parseModelString,
type BackendResolutionResult,
type BackendSource,
type ResolveBackendOptions,
} from './backend-resolver';
// Export embeddings
export {
embed,
embedMany,
createEmbeddingProvider,
getDefaultEmbeddingModel,
parseEmbeddingModel,
cosineSimilarity,
euclideanDistance,
type EmbeddingOptions,
type EmbeddingResult,
type EmbeddingBatchResult,
} from './embeddings';
@@ -0,0 +1,457 @@
/**
* AI SDK Adapter
*
* Converts between praisonai-ts message/tool formats and AI SDK formats.
* Handles prompt construction, tool definitions, and result parsing.
*/
import type { Message, ToolDefinition, ToolCall, GenerateTextResult, TokenUsage as PraisonTokenUsage } from '../types';
import type {
AISDKToolDefinition,
AISDKToolCall,
AISDKToolResult,
TokenUsage,
FinishReason,
PraisonStreamChunk
} from './types';
/**
* AI SDK prompt message types (simplified for compatibility)
*/
export interface AISDKSystemMessage {
role: 'system';
content: string;
}
export interface AISDKUserMessage {
role: 'user';
content: Array<{ type: 'text'; text: string }>;
}
export interface AISDKAssistantMessage {
role: 'assistant';
content: Array<
| { type: 'text'; text: string }
| { type: 'tool-call'; toolCallId: string; toolName: string; args: unknown }
>;
}
export interface AISDKToolMessage {
role: 'tool';
content: Array<{
type: 'tool-result';
toolCallId: string;
toolName: string;
result: unknown;
}>;
}
export type AISDKMessage =
| AISDKSystemMessage
| AISDKUserMessage
| AISDKAssistantMessage
| AISDKToolMessage;
/**
* Convert praisonai-ts messages to AI SDK prompt format
*/
export function toAISDKPrompt(messages: Message[]): AISDKMessage[] {
return messages.map(msg => {
switch (msg.role) {
case 'system':
return {
role: 'system' as const,
content: msg.content || ''
};
case 'user':
return {
role: 'user' as const,
content: [{ type: 'text' as const, text: msg.content || '' }]
};
case 'assistant':
if (msg.tool_calls && msg.tool_calls.length > 0) {
return {
role: 'assistant' as const,
content: msg.tool_calls.map(tc => ({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.function.name,
args: safeParseJSON(tc.function.arguments)
}))
};
}
return {
role: 'assistant' as const,
content: [{ type: 'text' as const, text: msg.content || '' }]
};
case 'tool':
return {
role: 'tool' as const,
content: [{
type: 'tool-result' as const,
toolCallId: msg.tool_call_id || '',
toolName: msg.name || '',
result: safeParseJSON(msg.content || '')
}]
};
default:
// Fallback to user message for unknown roles
return {
role: 'user' as const,
content: [{ type: 'text' as const, text: msg.content || '' }]
};
}
});
}
/**
* Convert praisonai-ts tool definitions to AI SDK format
* AI SDK expects tools created with the tool() helper from @ai-sdk/provider-utils
*/
export async function toAISDKTools(tools: ToolDefinition[]): Promise<Record<string, any>> {
const result: Record<string, any> = {};
// Import the tool helper from @ai-sdk/provider-utils
let toolHelper: any;
let jsonSchemaHelper: any;
try {
const providerUtils = await import('@ai-sdk/provider-utils');
toolHelper = providerUtils.tool;
jsonSchemaHelper = providerUtils.jsonSchema;
} catch {
toolHelper = null;
jsonSchemaHelper = null;
}
for (const toolDef of tools) {
// Build a proper JSON Schema for the tool parameters
const params = toolDef.parameters || {};
const schema = {
type: 'object' as const,
properties: params.properties || {},
required: params.required || [],
...(params.additionalProperties !== undefined
? { additionalProperties: params.additionalProperties }
: {}),
};
if (toolHelper && jsonSchemaHelper) {
// Use AI SDK's tool() helper for proper tool creation
result[toolDef.name] = toolHelper({
description: toolDef.description || '',
parameters: jsonSchemaHelper(schema),
});
} else {
// Fallback format - create tool-like object manually
result[toolDef.name] = {
description: toolDef.description || '',
parameters: schema,
};
}
}
return result;
}
/**
* Convert AI SDK tool call to praisonai-ts format
*/
export function fromAISDKToolCall(toolCall: AISDKToolCall): ToolCall {
return {
id: toolCall.toolCallId,
type: 'function',
function: {
name: toolCall.toolName,
arguments: typeof toolCall.args === 'string'
? toolCall.args
: JSON.stringify(toolCall.args)
}
};
}
/**
* Convert praisonai-ts tool result to AI SDK format
*/
export function toAISDKToolResult(
toolCallId: string,
toolName: string,
result: unknown
): AISDKToolResult {
return {
toolCallId,
toolName,
result
};
}
/**
* Extract text content from AI SDK response content
*/
export function extractTextFromContent(content: unknown[]): string {
if (!Array.isArray(content)) {
return '';
}
return content
.filter((part): part is { type: 'text'; text: string } =>
typeof part === 'object' &&
part !== null &&
'type' in part &&
part.type === 'text' &&
'text' in part
)
.map(part => part.text)
.join('');
}
/**
* Extract tool calls from AI SDK response content
*/
export function extractToolCallsFromContent(content: unknown[]): ToolCall[] {
if (!Array.isArray(content)) {
return [];
}
return content
.filter((part): part is { type: 'tool-call'; toolCallId: string; toolName: string; args: unknown } =>
typeof part === 'object' &&
part !== null &&
'type' in part &&
part.type === 'tool-call'
)
.map(part => fromAISDKToolCall({
toolCallId: part.toolCallId,
toolName: part.toolName,
args: part.args
}));
}
/**
* Convert AI SDK generate result to praisonai-ts format
*/
export function fromAISDKResult(result: {
text?: string;
content?: unknown[];
toolCalls?: AISDKToolCall[];
usage?: { promptTokens: number; completionTokens: number };
finishReason?: string;
}): GenerateTextResult {
// Extract text from content or use text directly
let text = result.text || '';
if (!text && result.content) {
text = extractTextFromContent(result.content);
}
// Extract tool calls
let toolCalls: ToolCall[] = [];
if (result.toolCalls && result.toolCalls.length > 0) {
toolCalls = result.toolCalls.map(fromAISDKToolCall);
} else if (result.content) {
toolCalls = extractToolCallsFromContent(result.content);
}
// Map usage - provide defaults if not available
const usage: PraisonTokenUsage = result.usage ? {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.promptTokens + result.usage.completionTokens
} : {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0
};
// Map finish reason to praisonai-ts format
const finishReason = mapFinishReasonToProvider(result.finishReason);
return {
text,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
usage,
finishReason,
raw: result
};
}
/**
* Map AI SDK finish reason to internal format
*/
export function mapFinishReason(reason?: string): FinishReason {
if (!reason) return 'unknown';
switch (reason.toLowerCase()) {
case 'stop':
case 'end_turn':
case 'end':
return 'stop';
case 'length':
case 'max_tokens':
return 'length';
case 'tool_calls':
case 'tool-calls':
case 'function_call':
return 'tool-calls';
case 'content_filter':
case 'content-filter':
return 'content-filter';
case 'error':
return 'error';
case 'cancelled':
case 'canceled':
return 'cancelled';
default:
return 'unknown';
}
}
/**
* Map AI SDK finish reason to praisonai-ts provider format
* Uses underscore format for tool_calls as expected by GenerateTextResult
*/
export function mapFinishReasonToProvider(reason?: string): 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'error' {
if (!reason) return 'stop';
switch (reason.toLowerCase()) {
case 'stop':
case 'end_turn':
case 'end':
return 'stop';
case 'length':
case 'max_tokens':
return 'length';
case 'tool_calls':
case 'tool-calls':
case 'function_call':
return 'tool_calls';
case 'content_filter':
case 'content-filter':
return 'content_filter';
case 'error':
case 'cancelled':
case 'canceled':
default:
return 'error';
}
}
/**
* Convert AI SDK stream chunk to praisonai-ts format
*/
export function fromAISDKStreamChunk(chunk: {
type: string;
textDelta?: string;
toolCallId?: string;
toolName?: string;
argsTextDelta?: string;
args?: unknown;
finishReason?: string;
usage?: { promptTokens: number; completionTokens: number };
}): PraisonStreamChunk | null {
switch (chunk.type) {
case 'text-delta':
if (chunk.textDelta) {
return { type: 'text', text: chunk.textDelta };
}
return null;
case 'tool-call':
if (chunk.toolCallId && chunk.toolName) {
return {
type: 'tool-call-end',
toolCallId: chunk.toolCallId,
args: chunk.args
};
}
return null;
case 'tool-call-streaming-start':
if (chunk.toolCallId && chunk.toolName) {
return {
type: 'tool-call-start',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName
};
}
return null;
case 'tool-call-delta':
if (chunk.toolCallId && chunk.argsTextDelta) {
return {
type: 'tool-call-delta',
toolCallId: chunk.toolCallId,
argsTextDelta: chunk.argsTextDelta
};
}
return null;
case 'finish':
case 'step-finish':
return {
type: 'finish',
finishReason: mapFinishReason(chunk.finishReason),
usage: chunk.usage ? {
promptTokens: chunk.usage.promptTokens,
completionTokens: chunk.usage.completionTokens,
totalTokens: chunk.usage.promptTokens + chunk.usage.completionTokens
} : undefined
};
default:
return null;
}
}
/**
* Map praisonai-ts tool choice to AI SDK format
*/
export function toAISDKToolChoice(
toolChoice?: 'auto' | 'none' | 'required' | { type: 'tool'; toolName: string }
): 'auto' | 'none' | 'required' | { type: 'tool'; toolName: string } | undefined {
if (!toolChoice) return undefined;
if (typeof toolChoice === 'string') {
return toolChoice;
}
return toolChoice;
}
/**
* Safely parse JSON, returning the original string if parsing fails
*/
function safeParseJSON(str: string): unknown {
if (typeof str !== 'string') {
return str;
}
try {
return JSON.parse(str);
} catch {
return str;
}
}
/**
* Create a simple prompt from a string
*/
export function createSimplePrompt(prompt: string, systemPrompt?: string): AISDKMessage[] {
const messages: AISDKMessage[] = [];
if (systemPrompt) {
messages.push({
role: 'system',
content: systemPrompt
});
}
messages.push({
role: 'user',
content: [{ type: 'text', text: prompt }]
});
return messages;
}
@@ -0,0 +1,549 @@
/**
* AI SDK Backend
*
* Main implementation of the AI SDK backend for praisonai-ts.
* Provides generateText, streamText, and generateObject methods
* with retry logic, timeouts, and attribution support.
*/
import type {
LLMProvider,
GenerateTextOptions,
GenerateTextResult,
StreamTextOptions,
StreamChunk,
GenerateObjectOptions,
GenerateObjectResult,
Message,
ToolDefinition
} from '../types';
import type {
AISDKBackendConfig,
AttributionContext,
AISDKProviderOptions,
PraisonStreamChunk,
TokenUsage
} from './types';
import { AISDKError, SAFE_DEFAULTS } from './types';
import { parseModelString, createAISDKProvider, validateProviderApiKey, getMissingApiKeyMessage } from './provider-map';
import { toAISDKPrompt, toAISDKTools, fromAISDKResult, fromAISDKStreamChunk, toAISDKToolChoice, mapFinishReasonToProvider } from './adapter';
import { createStandardMiddleware, createAttributionMiddleware, composeMiddleware, type AISDKMiddleware } from './middleware';
/**
* Lazy-loaded AI SDK modules
* These are only imported when actually used to avoid startup cost
*
* Note: We use 'any' type here because the 'ai' package is an optional
* peer dependency. The actual types are checked at runtime.
*/
let _aiModule: any = null;
async function getAIModule(): Promise<any> {
if (!_aiModule) {
try {
// Dynamic import - 'ai' is an optional peer dependency
// Use variable to prevent TypeScript from resolving the module at compile time
const moduleName = 'ai';
_aiModule = await import(moduleName);
} catch (error) {
throw new AISDKError(
'AI SDK is not installed. Install it with: npm install ai',
'MISSING_DEPENDENCY',
false,
error
);
}
}
return _aiModule;
}
/**
* AI SDK Backend - implements LLMProvider interface
*/
export class AISDKBackend implements LLMProvider {
public readonly providerId: string;
public readonly modelId: string;
private readonly config: AISDKBackendConfig;
private readonly attribution?: AttributionContext;
private provider: unknown | null = null;
private model: unknown | null = null;
constructor(
modelString: string,
config: AISDKBackendConfig = {}
) {
const parsed = parseModelString(modelString, config.defaultProvider);
this.providerId = parsed.providerId;
this.modelId = parsed.modelId;
// Merge with safe defaults
this.config = {
...SAFE_DEFAULTS,
...config
};
this.attribution = config.attribution;
}
/**
* Initialize the AI SDK provider and model (lazy)
*/
private async ensureInitialized(): Promise<void> {
if (this.model) {
return;
}
// Validate API key
if (!validateProviderApiKey(this.providerId)) {
throw new AISDKError(
getMissingApiKeyMessage(this.providerId),
'AUTHENTICATION',
false
);
}
// Get provider options from config
const providerOptions: AISDKProviderOptions =
this.config.providers?.[this.providerId] || {};
// Create the AI SDK provider
this.provider = await createAISDKProvider(this.providerId, providerOptions);
// Get the language model from the provider
if (typeof this.provider === 'function') {
// Provider is callable (e.g., openai('gpt-4o'))
this.model = (this.provider as Function)(this.modelId);
} else if (this.provider && typeof (this.provider as any).languageModel === 'function') {
// Provider has languageModel method
this.model = (this.provider as any).languageModel(this.modelId);
} else if (this.provider && typeof (this.provider as any).chat === 'function') {
// Provider has chat method (OpenAI style)
this.model = (this.provider as any).chat(this.modelId);
} else {
throw new AISDKError(
`Could not get language model from provider '${this.providerId}'`,
'PROVIDER_ERROR',
false
);
}
}
/**
* Generate text (non-streaming)
*/
async generateText(options: GenerateTextOptions): Promise<GenerateTextResult> {
await this.ensureInitialized();
const ai = await getAIModule();
// Convert messages to AI SDK format
const prompt = toAISDKPrompt(options.messages);
// Convert tools if provided (now async)
const tools = options.tools ? await toAISDKTools(options.tools) : undefined;
// Build call options
const callOptions: Record<string, unknown> = {
model: this.model,
messages: prompt,
maxTokens: options.maxTokens || this.config.maxOutputTokens,
temperature: options.temperature,
topP: options.topP,
frequencyPenalty: options.frequencyPenalty,
presencePenalty: options.presencePenalty,
stopSequences: options.stop,
maxRetries: this.config.maxRetries,
};
if (tools && Object.keys(tools).length > 0) {
callOptions.tools = tools;
callOptions.toolChoice = toAISDKToolChoice(options.toolChoice as any);
}
// Add telemetry settings if configured
if (this.config.telemetry) {
callOptions.experimental_telemetry = this.config.telemetry;
}
// Execute with retry logic
let lastError: unknown;
const maxRetries = this.config.maxRetries || SAFE_DEFAULTS.maxRetries;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, this.config.timeout || SAFE_DEFAULTS.timeout);
try {
callOptions.abortSignal = controller.signal;
const result = await ai.generateText(callOptions as any);
clearTimeout(timeoutId);
return fromAISDKResult({
text: result.text,
toolCalls: result.toolCalls?.map((tc: any) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
args: tc.args
})),
usage: result.usage,
finishReason: result.finishReason
});
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
lastError = error;
const classifiedError = classifyError(error);
// Don't retry non-retryable errors
if (!classifiedError.isRetryable || attempt === maxRetries) {
throw classifiedError;
}
// Exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
await sleep(delay);
}
}
throw classifyError(lastError);
}
/**
* Stream text generation
*/
async streamText(options: StreamTextOptions): Promise<AsyncIterable<StreamChunk>> {
await this.ensureInitialized();
const ai = await getAIModule();
// Convert messages to AI SDK format
const prompt = toAISDKPrompt(options.messages);
// Convert tools if provided (now async)
const tools = options.tools ? await toAISDKTools(options.tools) : undefined;
// Build call options
const callOptions: Record<string, unknown> = {
model: this.model,
messages: prompt,
maxTokens: options.maxTokens || this.config.maxOutputTokens,
temperature: options.temperature,
topP: options.topP,
frequencyPenalty: options.frequencyPenalty,
presencePenalty: options.presencePenalty,
stopSequences: options.stop,
};
if (tools && Object.keys(tools).length > 0) {
callOptions.tools = tools;
callOptions.toolChoice = toAISDKToolChoice(options.toolChoice as any);
}
// Add telemetry settings if configured
if (this.config.telemetry) {
callOptions.experimental_telemetry = this.config.telemetry;
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, this.config.timeout || SAFE_DEFAULTS.timeout);
callOptions.abortSignal = controller.signal;
const self = this;
const onToken = options.onToken;
const onToolCall = options.onToolCall;
// Return async iterable
return {
async *[Symbol.asyncIterator](): AsyncGenerator<StreamChunk, void, unknown> {
try {
const result = await ai.streamText(callOptions as any);
let accumulatedText = '';
const toolCalls: Map<string, { id: string; name: string; args: string }> = new Map();
for await (const chunk of result.textStream) {
// AI SDK streamText returns text chunks directly
if (typeof chunk === 'string') {
accumulatedText += chunk;
if (onToken) {
onToken(chunk);
}
yield {
text: chunk
};
}
}
// Get final result for usage and tool calls
const finalResult = await result;
// Yield tool calls if any
if (finalResult.toolCalls && finalResult.toolCalls.length > 0) {
for (const tc of finalResult.toolCalls) {
const toolCall = {
id: tc.toolCallId,
type: 'function' as const,
function: {
name: tc.toolName,
arguments: typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args)
}
};
if (onToolCall) {
onToolCall(toolCall);
}
yield {
toolCalls: [toolCall]
};
}
}
// Yield final chunk with usage
yield {
usage: finalResult.usage ? {
promptTokens: finalResult.usage.promptTokens,
completionTokens: finalResult.usage.completionTokens,
totalTokens: finalResult.usage.promptTokens + finalResult.usage.completionTokens
} : undefined,
finishReason: finalResult.finishReason
};
} catch (error) {
throw classifyError(error);
} finally {
clearTimeout(timeoutId);
}
}
};
}
/**
* Generate structured object output
*/
async generateObject<T = unknown>(options: GenerateObjectOptions<T>): Promise<GenerateObjectResult<T>> {
await this.ensureInitialized();
const ai = await getAIModule();
// Convert messages to AI SDK format
const prompt = toAISDKPrompt(options.messages);
// Build call options
const callOptions: Record<string, unknown> = {
model: this.model,
messages: prompt,
schema: options.schema,
maxTokens: options.maxTokens || this.config.maxOutputTokens,
temperature: options.temperature,
maxRetries: options.maxRetries || this.config.maxRetries,
};
// Add telemetry settings if configured
if (this.config.telemetry) {
callOptions.experimental_telemetry = this.config.telemetry;
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, this.config.timeout || SAFE_DEFAULTS.timeout);
try {
callOptions.abortSignal = controller.signal;
const result = await ai.generateObject(callOptions as any);
return {
object: result.object as T,
usage: result.usage ? {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.promptTokens + result.usage.completionTokens
} : {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0
},
raw: result
};
} catch (error) {
throw classifyError(error);
} finally {
clearTimeout(timeoutId);
}
}
/**
* Get the underlying AI SDK model (for advanced use)
*/
async getModel(): Promise<unknown> {
await this.ensureInitialized();
return this.model;
}
/**
* Get the underlying AI SDK provider (for advanced use)
*/
async getProvider(): Promise<unknown> {
await this.ensureInitialized();
return this.provider;
}
}
/**
* Create an AI SDK backend instance
*/
export function createAISDKBackend(
modelString: string,
config?: AISDKBackendConfig
): AISDKBackend {
return new AISDKBackend(modelString, config);
}
/**
* Classify an error into an AISDKError
*/
function classifyError(error: unknown): AISDKError {
if (error instanceof AISDKError) {
return error;
}
const message = error instanceof Error ? error.message : String(error);
const lowerMessage = message.toLowerCase();
// Check for timeout/abort
if (lowerMessage.includes('abort') || lowerMessage.includes('timeout')) {
return new AISDKError(
`Request timed out: ${message}`,
'TIMEOUT',
true,
error
);
}
// Check for rate limit
if (lowerMessage.includes('rate limit') || lowerMessage.includes('429') || lowerMessage.includes('too many')) {
return new AISDKError(
`Rate limit exceeded: ${message}`,
'RATE_LIMIT',
true,
error,
429
);
}
// Check for authentication
if (lowerMessage.includes('unauthorized') || lowerMessage.includes('401') ||
lowerMessage.includes('forbidden') || lowerMessage.includes('403') ||
lowerMessage.includes('api key') || lowerMessage.includes('authentication')) {
return new AISDKError(
`Authentication failed: ${message}`,
'AUTHENTICATION',
false,
error,
401
);
}
// Check for invalid request
if (lowerMessage.includes('bad request') || lowerMessage.includes('400') ||
lowerMessage.includes('invalid')) {
return new AISDKError(
`Invalid request: ${message}`,
'INVALID_REQUEST',
false,
error,
400
);
}
// Check for model not found
if (lowerMessage.includes('model not found') || lowerMessage.includes('404') ||
lowerMessage.includes('does not exist')) {
return new AISDKError(
`Model not found: ${message}`,
'MODEL_NOT_FOUND',
false,
error,
404
);
}
// Check for network errors
if (lowerMessage.includes('network') || lowerMessage.includes('econnrefused') ||
lowerMessage.includes('enotfound') || lowerMessage.includes('socket')) {
return new AISDKError(
`Network error: ${message}`,
'NETWORK',
true,
error
);
}
// Check for cancelled
if (lowerMessage.includes('cancel')) {
return new AISDKError(
`Request cancelled: ${message}`,
'CANCELLED',
false,
error
);
}
// Check for missing dependency
if (lowerMessage.includes('cannot find module') || lowerMessage.includes('module_not_found')) {
return new AISDKError(
`Missing dependency: ${message}`,
'MISSING_DEPENDENCY',
false,
error
);
}
// Check for server errors (retryable)
if (lowerMessage.includes('500') || lowerMessage.includes('502') ||
lowerMessage.includes('503') || lowerMessage.includes('504') ||
lowerMessage.includes('server error')) {
return new AISDKError(
`Server error: ${message}`,
'PROVIDER_ERROR',
true,
error,
500
);
}
// Default to unknown error
return new AISDKError(
message,
'UNKNOWN',
false,
error
);
}
/**
* Sleep for a given number of milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -0,0 +1,195 @@
/**
* AI SDK Integration for praisonai-ts
*
* This module provides integration with Vercel's AI SDK for multi-provider LLM support.
* All imports are lazy-loaded to avoid startup cost when AI SDK is not used.
*
* @example Basic usage
* ```typescript
* import { createAISDKBackend, registerAISDKProviders } from 'praisonai';
*
* // Register AI SDK providers (one-time)
* registerAISDKProviders();
*
* // Create a backend for any supported provider
* const backend = createAISDKBackend('anthropic/claude-3-5-sonnet');
* const result = await backend.generateText({ messages: [...] });
* ```
*/
// Re-export types (these don't cause runtime imports)
export type {
AISDKBackendConfig,
AISDKProviderOptions,
AttributionContext,
AISDKTelemetrySettings,
AISDKErrorCode,
PraisonStreamChunk,
TokenUsage,
FinishReason,
AISDKToolDefinition,
AISDKToolCall,
AISDKToolResult,
} from './types';
export { AISDKError, SAFE_DEFAULTS, AISDK_PROVIDERS, PROVIDER_ALIASES } from './types';
// Re-export provider map functions
export {
parseModelString,
resolveProviderAlias,
isProviderSupported,
getProviderPackage,
getProviderEnvKey,
listSupportedProviders,
registerCustomProvider,
unregisterCustomProvider,
getCustomProvider,
createAISDKProvider,
validateProviderApiKey,
getMissingApiKeyMessage,
type ParsedModel,
type ProviderFactory,
} from './provider-map';
// Re-export adapter functions
export {
toAISDKPrompt,
toAISDKTools,
fromAISDKToolCall,
toAISDKToolResult,
fromAISDKResult,
fromAISDKStreamChunk,
mapFinishReason,
mapFinishReasonToProvider,
toAISDKToolChoice,
createSimplePrompt,
type AISDKMessage,
type AISDKSystemMessage,
type AISDKUserMessage,
type AISDKAssistantMessage,
type AISDKToolMessage,
} from './adapter';
// Re-export middleware functions
export {
createAttributionMiddleware,
createLoggingMiddleware,
createTimeoutMiddleware,
composeMiddleware,
createStandardMiddleware,
redactSensitiveData,
type AISDKMiddleware,
} from './middleware';
// Lazy-loaded backend exports
// These use getters to avoid importing the backend module until needed
let _AISDKBackend: typeof import('./backend').AISDKBackend | null = null;
let _createAISDKBackend: typeof import('./backend').createAISDKBackend | null = null;
/**
* Get the AISDKBackend class (lazy loaded)
*/
export function getAISDKBackendClass(): typeof import('./backend').AISDKBackend {
if (!_AISDKBackend) {
const backend = require('./backend');
_AISDKBackend = backend.AISDKBackend;
}
return _AISDKBackend!;
}
/**
* Create an AI SDK backend instance
*
* @param modelString - Model string in format "provider/model" or just "model"
* @param config - Optional configuration
* @returns AISDKBackend instance implementing LLMProvider
*
* @example
* ```typescript
* const backend = createAISDKBackend('openai/gpt-4o');
* const backend = createAISDKBackend('anthropic/claude-3-5-sonnet');
* const backend = createAISDKBackend('google/gemini-2.0-flash');
* ```
*/
export function createAISDKBackend(
modelString: string,
config?: import('./types').AISDKBackendConfig
): import('./backend').AISDKBackend {
if (!_createAISDKBackend) {
const backend = require('./backend');
_createAISDKBackend = backend.createAISDKBackend;
}
return _createAISDKBackend!(modelString, config);
}
// Re-export the class for direct instantiation
export { AISDKBackend } from './backend';
/**
* Register AI SDK providers with the praisonai-ts provider registry
* This allows using AI SDK providers via the standard createProvider() function
*
* @param customProviders - Optional map of custom provider factories
*
* @example
* ```typescript
* import { registerAISDKProviders, createProvider } from 'praisonai';
*
* // Register all AI SDK providers
* registerAISDKProviders();
*
* // Now you can use AI SDK providers via createProvider
* const provider = createProvider('aisdk:anthropic/claude-3-5-sonnet');
* ```
*/
export function registerAISDKProviders(
customProviders?: Record<string, import('./provider-map').ProviderFactory>
): void {
// Register custom providers if provided
if (customProviders) {
const { registerCustomProvider } = require('./provider-map');
for (const [id, factory] of Object.entries(customProviders)) {
registerCustomProvider(id, factory);
}
}
// Note: Integration with main provider registry is done in ../index.ts
// This function is mainly for registering custom AI SDK providers
}
/**
* Check if AI SDK is available (installed)
*/
export async function isAISDKAvailable(): Promise<boolean> {
try {
// Use variable to prevent TypeScript from resolving at compile time
const moduleName = 'ai';
await import(moduleName);
return true;
} catch {
return false;
}
}
/**
* Get AI SDK version if available
*/
export async function getAISDKVersion(): Promise<string | null> {
try {
// Use variable to prevent TypeScript from resolving at compile time
const moduleName = 'ai';
await import(moduleName);
// AI SDK doesn't export version directly, so we check package.json
try {
const pkgPath = 'ai/package.json';
const pkg = require(pkgPath);
return pkg.version;
} catch {
return 'installed';
}
} catch {
return null;
}
}
@@ -0,0 +1,316 @@
/**
* AI SDK Middleware
*
* Provides middleware for injecting attribution context into AI SDK calls.
* Supports multi-agent safety with agent_id, run_id, trace_id, session_id.
*/
import type { AttributionContext, AISDKBackendConfig } from './types';
/**
* Middleware interface for AI SDK language models
* Compatible with AI SDK's LanguageModelMiddleware type
*/
export interface AISDKMiddleware {
transformParams?: (options: {
params: Record<string, unknown>;
type: 'generate' | 'stream';
model: unknown;
}) => Promise<Record<string, unknown>> | Record<string, unknown>;
wrapGenerate?: (options: {
doGenerate: () => Promise<unknown>;
doStream: () => Promise<unknown>;
params: Record<string, unknown>;
model: unknown;
}) => Promise<unknown>;
wrapStream?: (options: {
doGenerate: () => Promise<unknown>;
doStream: () => Promise<unknown>;
params: Record<string, unknown>;
model: unknown;
}) => Promise<unknown>;
}
/**
* Create attribution middleware that injects agent context into requests
*
* Injects:
* - Headers: X-Agent-Id, X-Run-Id, X-Trace-Id, X-Session-Id
* - Provider options metadata where supported
*/
export function createAttributionMiddleware(ctx: AttributionContext): AISDKMiddleware {
return {
transformParams: async ({ params }) => {
// Build attribution headers
const attributionHeaders: Record<string, string> = {};
if (ctx.agentId) {
attributionHeaders['X-Agent-Id'] = ctx.agentId;
}
if (ctx.runId) {
attributionHeaders['X-Run-Id'] = ctx.runId;
}
if (ctx.traceId) {
attributionHeaders['X-Trace-Id'] = ctx.traceId;
}
if (ctx.sessionId) {
attributionHeaders['X-Session-Id'] = ctx.sessionId;
}
if (ctx.parentSpanId) {
attributionHeaders['X-Parent-Span-Id'] = ctx.parentSpanId;
}
// Merge with existing headers
const existingHeaders = (params.headers as Record<string, string>) || {};
const mergedHeaders = {
...existingHeaders,
...attributionHeaders
};
// Build provider options metadata
const attributionMetadata: Record<string, string> = {};
if (ctx.agentId) attributionMetadata.agentId = ctx.agentId;
if (ctx.runId) attributionMetadata.runId = ctx.runId;
if (ctx.traceId) attributionMetadata.traceId = ctx.traceId;
if (ctx.sessionId) attributionMetadata.sessionId = ctx.sessionId;
// Merge with existing provider options
const existingProviderOptions = (params.providerOptions as Record<string, unknown>) || {};
const existingMetadata = (existingProviderOptions.metadata as Record<string, unknown>) || {};
const mergedProviderOptions = {
...existingProviderOptions,
metadata: {
...existingMetadata,
...attributionMetadata
}
};
return {
...params,
headers: mergedHeaders,
providerOptions: mergedProviderOptions
};
}
};
}
/**
* Create logging middleware for debug purposes
* Only logs when debugLogging is enabled
*/
export function createLoggingMiddleware(config: AISDKBackendConfig): AISDKMiddleware {
if (!config.debugLogging) {
return {};
}
return {
transformParams: async ({ params, type }) => {
const safeParams = config.redactLogs !== false
? redactSensitiveData(params)
: params;
console.log(`[AI SDK] ${type} request:`, JSON.stringify(safeParams, null, 2));
return params;
},
wrapGenerate: async ({ doGenerate, params }) => {
const startTime = Date.now();
try {
const result = await doGenerate();
const duration = Date.now() - startTime;
console.log(`[AI SDK] generate completed in ${duration}ms`);
return result;
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[AI SDK] generate failed after ${duration}ms:`, error);
throw error;
}
},
wrapStream: async ({ doStream, params }) => {
const startTime = Date.now();
console.log(`[AI SDK] stream started`);
try {
const result = await doStream();
const duration = Date.now() - startTime;
console.log(`[AI SDK] stream setup completed in ${duration}ms`);
return result;
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[AI SDK] stream failed after ${duration}ms:`, error);
throw error;
}
}
};
}
/**
* Create timeout middleware that wraps requests with AbortController
*/
export function createTimeoutMiddleware(timeoutMs: number): AISDKMiddleware {
return {
transformParams: async ({ params }) => {
// If there's already an abort signal, don't override it
if (params.abortSignal) {
return params;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));
}, timeoutMs);
// Store timeout ID for cleanup (attached to signal)
(controller.signal as any).__timeoutId = timeoutId;
return {
...params,
abortSignal: controller.signal
};
}
};
}
/**
* Compose multiple middlewares into one
* Middlewares are applied in order (first transforms first, last wraps innermost)
*/
export function composeMiddleware(...middlewares: AISDKMiddleware[]): AISDKMiddleware {
const validMiddlewares = middlewares.filter(m => m && Object.keys(m).length > 0);
if (validMiddlewares.length === 0) {
return {};
}
if (validMiddlewares.length === 1) {
return validMiddlewares[0];
}
return {
transformParams: async (options) => {
let params = options.params;
for (const middleware of validMiddlewares) {
if (middleware.transformParams) {
params = await middleware.transformParams({
...options,
params
});
}
}
return params;
},
wrapGenerate: async (options) => {
// Build nested wrapper from inside out
let doGenerate = options.doGenerate;
for (let i = validMiddlewares.length - 1; i >= 0; i--) {
const middleware = validMiddlewares[i];
if (middleware.wrapGenerate) {
const currentDoGenerate = doGenerate;
const currentMiddleware = middleware;
doGenerate = () => currentMiddleware.wrapGenerate!({
...options,
doGenerate: currentDoGenerate
});
}
}
return doGenerate();
},
wrapStream: async (options) => {
// Build nested wrapper from inside out
let doStream = options.doStream;
for (let i = validMiddlewares.length - 1; i >= 0; i--) {
const middleware = validMiddlewares[i];
if (middleware.wrapStream) {
const currentDoStream = doStream;
const currentMiddleware = middleware;
doStream = () => currentMiddleware.wrapStream!({
...options,
doStream: currentDoStream
});
}
}
return doStream();
}
};
}
/**
* Redact sensitive data from objects for safe logging
*/
export function redactSensitiveData(obj: unknown): unknown {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(redactSensitiveData);
}
const result: Record<string, unknown> = {};
const sensitiveKeys = [
'apiKey', 'api_key', 'apikey',
'token', 'secret', 'password',
'authorization', 'auth',
'key', 'credential', 'credentials'
];
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const lowerKey = key.toLowerCase();
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) {
if (typeof value === 'string' && value.length > 4) {
result[key] = value.slice(0, 4) + '****';
} else {
result[key] = '****';
}
} else if (typeof value === 'object') {
result[key] = redactSensitiveData(value);
} else {
result[key] = value;
}
}
return result;
}
/**
* Create all standard middlewares for the AI SDK backend
*/
export function createStandardMiddleware(
config: AISDKBackendConfig,
attribution?: AttributionContext
): AISDKMiddleware {
const middlewares: AISDKMiddleware[] = [];
// Attribution middleware (if context provided)
if (attribution && Object.keys(attribution).length > 0) {
middlewares.push(createAttributionMiddleware(attribution));
}
// Timeout middleware
if (config.timeout && config.timeout > 0) {
middlewares.push(createTimeoutMiddleware(config.timeout));
}
// Logging middleware (only if debug enabled)
if (config.debugLogging) {
middlewares.push(createLoggingMiddleware(config));
}
return composeMiddleware(...middlewares);
}
@@ -0,0 +1,287 @@
/**
* AI SDK Provider Map
*
* Maps model strings to AI SDK providers with lazy loading.
* Supports formats: "provider/model" or "model" (uses default provider)
*/
import {
AISDKError,
AISDKProviderOptions,
AISDK_PROVIDERS,
PROVIDER_ALIASES
} from './types';
/**
* Parsed model string result
*/
export interface ParsedModel {
providerId: string;
modelId: string;
}
/**
* Provider factory function type
*/
export type ProviderFactory = (options?: AISDKProviderOptions) => unknown;
/**
* Custom provider registration
*/
const customProviders = new Map<string, ProviderFactory>();
/**
* Parse a model string into provider and model ID
*
* @example
* parseModelString("openai/gpt-4o") // { providerId: "openai", modelId: "gpt-4o" }
* parseModelString("gpt-4o-mini") // { providerId: "openai", modelId: "gpt-4o-mini" }
* parseModelString("claude-3-5-sonnet") // { providerId: "anthropic", modelId: "claude-3-5-sonnet" }
*/
export function parseModelString(model: string, defaultProvider: string = 'openai'): ParsedModel {
if (model.includes('/')) {
const [providerId, ...rest] = model.split('/');
const resolvedProvider = resolveProviderAlias(providerId.toLowerCase());
return {
providerId: resolvedProvider,
modelId: rest.join('/')
};
}
// Infer provider from model prefix
const inferredProvider = inferProviderFromModel(model);
return {
providerId: inferredProvider || defaultProvider,
modelId: model
};
}
/**
* Resolve provider alias to canonical name
*/
export function resolveProviderAlias(providerId: string): string {
return PROVIDER_ALIASES[providerId] || providerId;
}
/**
* Infer provider from model name prefix
*/
function inferProviderFromModel(model: string): string | null {
const lowerModel = model.toLowerCase();
// OpenAI models
if (lowerModel.startsWith('gpt-') ||
lowerModel.startsWith('o1') ||
lowerModel.startsWith('o3') ||
lowerModel.startsWith('text-') ||
lowerModel.startsWith('davinci') ||
lowerModel.startsWith('curie') ||
lowerModel.startsWith('babbage') ||
lowerModel.startsWith('ada')) {
return 'openai';
}
// Anthropic models
if (lowerModel.startsWith('claude-')) {
return 'anthropic';
}
// Google models
if (lowerModel.startsWith('gemini-') ||
lowerModel.startsWith('palm-') ||
lowerModel.startsWith('bison')) {
return 'google';
}
// Mistral models
if (lowerModel.startsWith('mistral-') ||
lowerModel.startsWith('mixtral-') ||
lowerModel.startsWith('codestral')) {
return 'mistral';
}
// Cohere models
if (lowerModel.startsWith('command-')) {
return 'cohere';
}
// DeepSeek models
if (lowerModel.startsWith('deepseek-')) {
return 'deepseek';
}
// Groq models (llama, mixtral via groq)
if (lowerModel.startsWith('llama-') || lowerModel.includes('groq')) {
return 'groq';
}
return null;
}
/**
* Check if a provider is supported
*/
export function isProviderSupported(providerId: string): boolean {
const resolved = resolveProviderAlias(providerId);
return resolved in AISDK_PROVIDERS || customProviders.has(resolved);
}
/**
* Get the package name for a provider
*/
export function getProviderPackage(providerId: string): string | null {
const resolved = resolveProviderAlias(providerId);
return AISDK_PROVIDERS[resolved]?.package || null;
}
/**
* Get the environment variable key for a provider's API key
*/
export function getProviderEnvKey(providerId: string): string | null {
const resolved = resolveProviderAlias(providerId);
return AISDK_PROVIDERS[resolved]?.envKey || null;
}
/**
* List all supported providers
*/
export function listSupportedProviders(): string[] {
const builtIn = Object.keys(AISDK_PROVIDERS);
const custom = Array.from(customProviders.keys());
return [...new Set([...builtIn, ...custom])];
}
/**
* Register a custom provider factory
*
* @example
* registerCustomProvider('my-provider', (options) => createMyProvider(options));
*/
export function registerCustomProvider(
providerId: string,
factory: ProviderFactory
): void {
customProviders.set(providerId.toLowerCase(), factory);
}
/**
* Unregister a custom provider
*/
export function unregisterCustomProvider(providerId: string): boolean {
return customProviders.delete(providerId.toLowerCase());
}
/**
* Get a custom provider factory
*/
export function getCustomProvider(providerId: string): ProviderFactory | undefined {
return customProviders.get(providerId.toLowerCase());
}
/**
* Dynamically import and create an AI SDK provider
* Uses lazy loading to avoid importing AI SDK packages until needed
*/
export async function createAISDKProvider(
providerId: string,
options?: AISDKProviderOptions
): Promise<unknown> {
const resolved = resolveProviderAlias(providerId);
// Check for custom provider first
const customFactory = customProviders.get(resolved);
if (customFactory) {
return customFactory(options);
}
// Check if provider is supported
const providerInfo = AISDK_PROVIDERS[resolved];
if (!providerInfo) {
throw new AISDKError(
`Provider '${providerId}' is not supported. ` +
`Supported providers: ${listSupportedProviders().join(', ')}`,
'PROVIDER_NOT_FOUND',
false
);
}
// Dynamically import the provider package
try {
const providerModule = await import(providerInfo.package);
// Most AI SDK providers export a create<Provider> function
const createFnName = `create${capitalize(resolved.replace(/-/g, ''))}`;
const createFn = providerModule[createFnName] || providerModule.default;
if (typeof createFn !== 'function') {
// Try direct provider export (some packages export the provider directly)
const providerName = resolved.replace(/-/g, '');
const directProvider = providerModule[providerName];
if (directProvider) {
return directProvider;
}
throw new AISDKError(
`Could not find provider factory in '${providerInfo.package}'`,
'PROVIDER_ERROR',
false
);
}
return createFn(options);
} catch (error: unknown) {
if (error instanceof AISDKError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
// Check if it's a missing dependency error
if (errorMessage.includes('Cannot find module') ||
errorMessage.includes('MODULE_NOT_FOUND')) {
throw new AISDKError(
`AI SDK provider package '${providerInfo.package}' is not installed. ` +
`Install it with: npm install ${providerInfo.package}`,
'MISSING_DEPENDENCY',
false,
error
);
}
throw new AISDKError(
`Failed to create AI SDK provider '${providerId}': ${errorMessage}`,
'PROVIDER_ERROR',
false,
error
);
}
}
/**
* Validate that a provider has the required API key
*/
export function validateProviderApiKey(providerId: string): boolean {
const envKey = getProviderEnvKey(providerId);
if (!envKey) {
return true; // Custom providers may not need env keys
}
return !!process.env[envKey];
}
/**
* Get missing API key message for a provider
*/
export function getMissingApiKeyMessage(providerId: string): string {
const envKey = getProviderEnvKey(providerId);
if (!envKey) {
return `Provider '${providerId}' may require authentication. Check provider documentation.`;
}
return `Missing API key for provider '${providerId}'. Set the ${envKey} environment variable.`;
}
/**
* Capitalize first letter of a string
*/
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
@@ -0,0 +1,227 @@
/**
* AI SDK Backend Types
*
* Type definitions for the AI SDK integration with praisonai-ts.
* These types define the configuration, errors, and stream chunk formats.
*/
/**
* Attribution context for multi-agent safety
* Propagated to AI SDK calls via middleware
*/
export interface AttributionContext {
/** Unique identifier for the agent instance */
agentId?: string;
/** Unique identifier for this execution run */
runId?: string;
/** Trace ID for distributed tracing (OpenTelemetry compatible) */
traceId?: string;
/** Session ID for conversation continuity */
sessionId?: string;
/** Parent span ID for nested operations */
parentSpanId?: string;
}
/**
* Provider-specific configuration options
*/
export interface AISDKProviderOptions {
/** API key for the provider */
apiKey?: string;
/** Base URL override for the provider */
baseURL?: string;
/** Custom headers to include in requests */
headers?: Record<string, string>;
/** Custom fetch implementation */
fetch?: typeof fetch;
}
/**
* Telemetry settings for AI SDK calls
*/
export interface AISDKTelemetrySettings {
/** Enable or disable telemetry */
isEnabled?: boolean;
/** Enable or disable input recording */
recordInputs?: boolean;
/** Enable or disable output recording */
recordOutputs?: boolean;
/** Function identifier for grouping telemetry */
functionId?: string;
/** Additional metadata for telemetry */
metadata?: Record<string, unknown>;
}
/**
* Configuration for the AI SDK backend
*/
export interface AISDKBackendConfig {
/** Default provider if not specified in model string */
defaultProvider?: string;
/** Provider-specific configurations */
providers?: Record<string, AISDKProviderOptions>;
/** Attribution context for multi-agent safety */
attribution?: AttributionContext;
/** Telemetry settings */
telemetry?: AISDKTelemetrySettings;
/** Request timeout in milliseconds (default: 60000) */
timeout?: number;
/** Number of retries for retryable errors (default: 2) */
maxRetries?: number;
/** Maximum output tokens (default: 4096) */
maxOutputTokens?: number;
/** Enable debug logging (default: false) */
debugLogging?: boolean;
/** Redact sensitive data in logs (default: true) */
redactLogs?: boolean;
}
/**
* Error codes for AI SDK errors
*/
export type AISDKErrorCode =
| 'PROVIDER_ERROR' // Provider-specific error
| 'RATE_LIMIT' // 429 - Too many requests
| 'AUTHENTICATION' // 401/403 - Auth failure
| 'INVALID_REQUEST' // 400 - Bad request
| 'MODEL_NOT_FOUND' // Model doesn't exist
| 'PROVIDER_NOT_FOUND' // Provider not registered
| 'TIMEOUT' // Request timeout
| 'NETWORK' // Network failure
| 'CANCELLED' // Request cancelled
| 'MISSING_DEPENDENCY' // AI SDK not installed
| 'UNKNOWN'; // Unknown error
/**
* Custom error class for AI SDK errors
*/
export class AISDKError extends Error {
public readonly code: AISDKErrorCode;
public readonly isRetryable: boolean;
public readonly cause?: unknown;
public readonly statusCode?: number;
constructor(
message: string,
code: AISDKErrorCode,
isRetryable: boolean,
cause?: unknown,
statusCode?: number
) {
super(message);
this.name = 'AISDKError';
this.code = code;
this.isRetryable = isRetryable;
this.cause = cause;
this.statusCode = statusCode;
// Maintain proper stack trace in V8
if (Error.captureStackTrace) {
Error.captureStackTrace(this, AISDKError);
}
}
}
/**
* Stream chunk types for streaming responses
*/
export type PraisonStreamChunk =
| { type: 'text'; text: string }
| { type: 'tool-call-start'; toolCallId: string; toolName: string }
| { type: 'tool-call-delta'; toolCallId: string; argsTextDelta: string }
| { type: 'tool-call-end'; toolCallId: string; args: unknown }
| { type: 'finish'; finishReason: string; usage?: TokenUsage }
| { type: 'error'; error: AISDKError };
/**
* Token usage information
*/
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
/**
* Finish reasons for generation
*/
export type FinishReason =
| 'stop' // Natural completion
| 'length' // Max tokens reached
| 'tool-calls' // Tool calls requested
| 'content-filter' // Content filtered
| 'error' // Error occurred
| 'cancelled' // Request cancelled
| 'unknown'; // Unknown reason
/**
* Tool definition for AI SDK
*/
export interface AISDKToolDefinition {
type: 'function';
name: string;
description?: string;
parameters: Record<string, unknown>;
}
/**
* Tool call from AI SDK response
*/
export interface AISDKToolCall {
toolCallId: string;
toolName: string;
args: unknown;
}
/**
* Tool result to send back to AI SDK
*/
export interface AISDKToolResult {
toolCallId: string;
toolName: string;
result: unknown;
}
/**
* Safe defaults for AI SDK backend
*/
export const SAFE_DEFAULTS = {
timeout: 60000, // 60s request timeout
maxRetries: 2, // Retry on transient failures
maxOutputTokens: 4096, // Prevent runaway generation
redactLogs: true, // Never log API keys
debugLogging: false, // Opt-in verbose logging
} as const;
/**
* Supported AI SDK providers with their package names
*/
export const AISDK_PROVIDERS: Record<string, { package: string; envKey: string }> = {
openai: { package: '@ai-sdk/openai', envKey: 'OPENAI_API_KEY' },
anthropic: { package: '@ai-sdk/anthropic', envKey: 'ANTHROPIC_API_KEY' },
google: { package: '@ai-sdk/google', envKey: 'GOOGLE_API_KEY' },
azure: { package: '@ai-sdk/azure', envKey: 'AZURE_API_KEY' },
'amazon-bedrock': { package: '@ai-sdk/amazon-bedrock', envKey: 'AWS_ACCESS_KEY_ID' },
groq: { package: '@ai-sdk/groq', envKey: 'GROQ_API_KEY' },
mistral: { package: '@ai-sdk/mistral', envKey: 'MISTRAL_API_KEY' },
cohere: { package: '@ai-sdk/cohere', envKey: 'COHERE_API_KEY' },
deepseek: { package: '@ai-sdk/deepseek', envKey: 'DEEPSEEK_API_KEY' },
xai: { package: '@ai-sdk/xai', envKey: 'XAI_API_KEY' },
fireworks: { package: '@ai-sdk/fireworks', envKey: 'FIREWORKS_API_KEY' },
togetherai: { package: '@ai-sdk/togetherai', envKey: 'TOGETHER_API_KEY' },
perplexity: { package: '@ai-sdk/perplexity', envKey: 'PERPLEXITY_API_KEY' },
} as const;
/**
* Provider aliases for convenience
*/
export const PROVIDER_ALIASES: Record<string, string> = {
oai: 'openai',
claude: 'anthropic',
gemini: 'google',
gcp: 'google',
aws: 'amazon-bedrock',
bedrock: 'amazon-bedrock',
together: 'togetherai',
pplx: 'perplexity',
} as const;
@@ -206,3 +206,57 @@ export function isProviderAvailable(providerId: string): boolean {
export function getAvailableProviders(): string[] {
return getDefaultRegistry().list().filter(isProviderAvailable);
}
// ============================================================================
// AI SDK Integration Exports
// ============================================================================
/**
* AI SDK Backend - Multi-provider LLM support via Vercel's AI SDK
*
* @example
* ```typescript
* import { createAISDKBackend } from 'praisonai';
*
* const backend = createAISDKBackend('anthropic/claude-3-5-sonnet');
* const result = await backend.generateText({ messages: [...] });
* ```
*/
export {
// Backend
AISDKBackend,
createAISDKBackend,
// Provider utilities
isProviderSupported as isAISDKProviderSupported,
listSupportedProviders as listAISDKProviders,
registerCustomProvider as registerAISDKCustomProvider,
validateProviderApiKey as validateAISDKProviderApiKey,
getMissingApiKeyMessage as getAISDKMissingApiKeyMessage,
isAISDKAvailable,
getAISDKVersion,
// Types
AISDKError,
SAFE_DEFAULTS as AISDK_SAFE_DEFAULTS,
AISDK_PROVIDERS,
PROVIDER_ALIASES as AISDK_PROVIDER_ALIASES,
// Middleware
createAttributionMiddleware,
createStandardMiddleware,
redactSensitiveData,
} from './ai-sdk';
export type {
AISDKBackendConfig,
AISDKProviderOptions,
AttributionContext,
AISDKTelemetrySettings,
AISDKErrorCode,
PraisonStreamChunk,
AISDKToolDefinition,
AISDKToolCall,
AISDKToolResult,
AISDKMiddleware,
} from './ai-sdk';
@@ -0,0 +1,391 @@
/**
* AI SDK Adapter Tests
*/
import {
toAISDKPrompt,
toAISDKTools,
fromAISDKToolCall,
fromAISDKResult,
fromAISDKStreamChunk,
mapFinishReason,
mapFinishReasonToProvider,
toAISDKToolChoice,
createSimplePrompt,
} from '../../../../src/llm/providers/ai-sdk/adapter';
import type { Message, ToolDefinition } from '../../../../src/llm/providers/types';
describe('toAISDKPrompt', () => {
it('should convert system message', () => {
const messages: Message[] = [
{ role: 'system', content: 'You are helpful' }
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'system',
content: 'You are helpful'
});
});
it('should convert user message', () => {
const messages: Message[] = [
{ role: 'user', content: 'Hello!' }
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'Hello!' }]
});
});
it('should convert assistant message with text', () => {
const messages: Message[] = [
{ role: 'assistant', content: 'Hi there!' }
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'assistant',
content: [{ type: 'text', text: 'Hi there!' }]
});
});
it('should convert assistant message with tool calls', () => {
const messages: Message[] = [
{
role: 'assistant',
content: null,
tool_calls: [{
id: 'call_123',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"city": "London"}'
}
}]
}
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('assistant');
expect((result[0] as any).content).toHaveLength(1);
expect((result[0] as any).content[0]).toEqual({
type: 'tool-call',
toolCallId: 'call_123',
toolName: 'get_weather',
args: { city: 'London' }
});
});
it('should convert tool message', () => {
const messages: Message[] = [
{
role: 'tool',
content: '{"temperature": 20}',
tool_call_id: 'call_123',
name: 'get_weather'
}
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: 'call_123',
toolName: 'get_weather',
result: { temperature: 20 }
}]
});
});
it('should handle null content', () => {
const messages: Message[] = [
{ role: 'user', content: null }
];
const result = toAISDKPrompt(messages);
expect(result[0]).toEqual({
role: 'user',
content: [{ type: 'text', text: '' }]
});
});
});
describe('toAISDKTools', () => {
it('should convert tool definitions', async () => {
const tools: ToolDefinition[] = [
{
name: 'get_weather',
description: 'Get the weather for a city',
parameters: {
type: 'object',
properties: {
city: { type: 'string' }
},
required: ['city']
}
}
];
const result = await toAISDKTools(tools);
// Check that tool was created with description
expect(result.get_weather).toBeDefined();
expect(result.get_weather.description).toBe('Get the weather for a city');
});
it('should handle tools without parameters', async () => {
const tools: ToolDefinition[] = [
{ name: 'get_time' }
];
const result = await toAISDKTools(tools);
expect(result.get_time).toBeDefined();
});
});
describe('fromAISDKToolCall', () => {
it('should convert AI SDK tool call to praisonai format', () => {
const toolCall = {
toolCallId: 'call_123',
toolName: 'get_weather',
args: { city: 'London' }
};
const result = fromAISDKToolCall(toolCall);
expect(result).toEqual({
id: 'call_123',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"city":"London"}'
}
});
});
it('should handle string args', () => {
const toolCall = {
toolCallId: 'call_123',
toolName: 'test',
args: '{"key": "value"}'
};
const result = fromAISDKToolCall(toolCall);
expect(result.function.arguments).toBe('{"key": "value"}');
});
});
describe('fromAISDKResult', () => {
it('should convert AI SDK result to praisonai format', () => {
const result = fromAISDKResult({
text: 'Hello!',
usage: { promptTokens: 10, completionTokens: 5 },
finishReason: 'stop'
});
expect(result.text).toBe('Hello!');
expect(result.usage).toEqual({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15
});
expect(result.finishReason).toBe('stop');
});
it('should handle missing usage', () => {
const result = fromAISDKResult({
text: 'Hello!',
finishReason: 'stop'
});
expect(result.usage).toEqual({
promptTokens: 0,
completionTokens: 0,
totalTokens: 0
});
});
it('should handle tool calls', () => {
const result = fromAISDKResult({
text: '',
toolCalls: [{
toolCallId: 'call_123',
toolName: 'test',
args: {}
}],
finishReason: 'tool_calls'
});
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls![0].id).toBe('call_123');
expect(result.finishReason).toBe('tool_calls');
});
});
describe('mapFinishReason', () => {
it('should map stop reasons', () => {
expect(mapFinishReason('stop')).toBe('stop');
expect(mapFinishReason('end_turn')).toBe('stop');
expect(mapFinishReason('end')).toBe('stop');
});
it('should map length reasons', () => {
expect(mapFinishReason('length')).toBe('length');
expect(mapFinishReason('max_tokens')).toBe('length');
});
it('should map tool call reasons', () => {
expect(mapFinishReason('tool_calls')).toBe('tool-calls');
expect(mapFinishReason('tool-calls')).toBe('tool-calls');
expect(mapFinishReason('function_call')).toBe('tool-calls');
});
it('should map content filter', () => {
expect(mapFinishReason('content_filter')).toBe('content-filter');
expect(mapFinishReason('content-filter')).toBe('content-filter');
});
it('should return unknown for undefined', () => {
expect(mapFinishReason(undefined)).toBe('unknown');
expect(mapFinishReason('something_else')).toBe('unknown');
});
});
describe('mapFinishReasonToProvider', () => {
it('should map to provider format with underscores', () => {
expect(mapFinishReasonToProvider('tool_calls')).toBe('tool_calls');
expect(mapFinishReasonToProvider('tool-calls')).toBe('tool_calls');
expect(mapFinishReasonToProvider('content_filter')).toBe('content_filter');
expect(mapFinishReasonToProvider('content-filter')).toBe('content_filter');
});
it('should default to stop for undefined', () => {
expect(mapFinishReasonToProvider(undefined)).toBe('stop');
});
});
describe('fromAISDKStreamChunk', () => {
it('should convert text delta', () => {
const chunk = fromAISDKStreamChunk({
type: 'text-delta',
textDelta: 'Hello'
});
expect(chunk).toEqual({ type: 'text', text: 'Hello' });
});
it('should convert tool call start', () => {
const chunk = fromAISDKStreamChunk({
type: 'tool-call-streaming-start',
toolCallId: 'call_123',
toolName: 'test'
});
expect(chunk).toEqual({
type: 'tool-call-start',
toolCallId: 'call_123',
toolName: 'test'
});
});
it('should convert tool call delta', () => {
const chunk = fromAISDKStreamChunk({
type: 'tool-call-delta',
toolCallId: 'call_123',
argsTextDelta: '{"key":'
});
expect(chunk).toEqual({
type: 'tool-call-delta',
toolCallId: 'call_123',
argsTextDelta: '{"key":'
});
});
it('should convert finish', () => {
const chunk = fromAISDKStreamChunk({
type: 'finish',
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 5 }
});
expect(chunk).toEqual({
type: 'finish',
finishReason: 'stop',
usage: {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15
}
});
});
it('should return null for unknown chunk types', () => {
const chunk = fromAISDKStreamChunk({
type: 'unknown-type'
});
expect(chunk).toBeNull();
});
});
describe('toAISDKToolChoice', () => {
it('should pass through string values', () => {
expect(toAISDKToolChoice('auto')).toBe('auto');
expect(toAISDKToolChoice('none')).toBe('none');
expect(toAISDKToolChoice('required')).toBe('required');
});
it('should pass through tool object', () => {
const choice = { type: 'tool' as const, toolName: 'test' };
expect(toAISDKToolChoice(choice)).toEqual(choice);
});
it('should return undefined for undefined', () => {
expect(toAISDKToolChoice(undefined)).toBeUndefined();
});
});
describe('createSimplePrompt', () => {
it('should create prompt with user message', () => {
const result = createSimplePrompt('Hello!');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'Hello!' }]
});
});
it('should include system prompt if provided', () => {
const result = createSimplePrompt('Hello!', 'You are helpful');
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
role: 'system',
content: 'You are helpful'
});
expect(result[1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'Hello!' }]
});
});
});
@@ -0,0 +1,110 @@
/**
* AI SDK Lazy Import Tests
*
* Verifies that AI SDK modules are not loaded until actually used.
*/
describe('Lazy Import', () => {
beforeEach(() => {
// Clear module cache to test fresh imports
jest.resetModules();
});
it('should not import AI SDK when importing types', () => {
// Types should be importable without triggering AI SDK load
const types = require('../../../../src/llm/providers/ai-sdk/types');
expect(types.AISDKError).toBeDefined();
expect(types.SAFE_DEFAULTS).toBeDefined();
expect(types.AISDK_PROVIDERS).toBeDefined();
// AI SDK should not be in require.cache
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should not import AI SDK when importing provider-map', () => {
const providerMap = require('../../../../src/llm/providers/ai-sdk/provider-map');
expect(providerMap.parseModelString).toBeDefined();
expect(providerMap.isProviderSupported).toBeDefined();
// AI SDK should not be in require.cache
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should not import AI SDK when importing adapter', () => {
const adapter = require('../../../../src/llm/providers/ai-sdk/adapter');
expect(adapter.toAISDKPrompt).toBeDefined();
expect(adapter.fromAISDKResult).toBeDefined();
// AI SDK should not be in require.cache
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should not import AI SDK when importing middleware', () => {
const middleware = require('../../../../src/llm/providers/ai-sdk/middleware');
expect(middleware.createAttributionMiddleware).toBeDefined();
expect(middleware.redactSensitiveData).toBeDefined();
// AI SDK should not be in require.cache
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should not import AI SDK when importing index (before backend use)', () => {
const aiSdk = require('../../../../src/llm/providers/ai-sdk');
// These should be available without AI SDK
expect(aiSdk.AISDKError).toBeDefined();
expect(aiSdk.parseModelString).toBeDefined();
expect(aiSdk.toAISDKPrompt).toBeDefined();
expect(aiSdk.createAttributionMiddleware).toBeDefined();
// AI SDK should not be in require.cache yet
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should export createAISDKBackend function', () => {
const aiSdk = require('../../../../src/llm/providers/ai-sdk');
expect(typeof aiSdk.createAISDKBackend).toBe('function');
});
it('should export isAISDKAvailable function', () => {
const aiSdk = require('../../../../src/llm/providers/ai-sdk');
expect(typeof aiSdk.isAISDKAvailable).toBe('function');
});
});
describe('Backend Instantiation', () => {
it('should create backend instance without immediate AI SDK import', () => {
jest.resetModules();
const { createAISDKBackend } = require('../../../../src/llm/providers/ai-sdk');
// Creating the backend should not immediately import AI SDK
// The import happens on first method call
const backend = createAISDKBackend('openai/gpt-4o-mini');
expect(backend).toBeDefined();
expect(backend.providerId).toBe('openai');
expect(backend.modelId).toBe('gpt-4o-mini');
});
});
@@ -0,0 +1,296 @@
/**
* AI SDK Middleware Tests
*/
import {
createAttributionMiddleware,
createLoggingMiddleware,
createTimeoutMiddleware,
composeMiddleware,
createStandardMiddleware,
redactSensitiveData,
} from '../../../../src/llm/providers/ai-sdk/middleware';
import type { AttributionContext, AISDKBackendConfig } from '../../../../src/llm/providers/ai-sdk/types';
describe('createAttributionMiddleware', () => {
it('should inject attribution headers', async () => {
const ctx: AttributionContext = {
agentId: 'agent-123',
runId: 'run-456',
traceId: 'trace-789',
sessionId: 'session-abc'
};
const middleware = createAttributionMiddleware(ctx);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.headers).toEqual({
'X-Agent-Id': 'agent-123',
'X-Run-Id': 'run-456',
'X-Trace-Id': 'trace-789',
'X-Session-Id': 'session-abc'
});
});
it('should inject provider options metadata', async () => {
const ctx: AttributionContext = {
agentId: 'agent-123',
runId: 'run-456'
};
const middleware = createAttributionMiddleware(ctx);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect((result.providerOptions as any).metadata).toEqual({
agentId: 'agent-123',
runId: 'run-456'
});
});
it('should merge with existing headers', async () => {
const ctx: AttributionContext = {
agentId: 'agent-123'
};
const middleware = createAttributionMiddleware(ctx);
const result = await middleware.transformParams!({
params: {
headers: { 'X-Custom': 'value' }
},
type: 'generate',
model: {}
});
expect(result.headers).toEqual({
'X-Custom': 'value',
'X-Agent-Id': 'agent-123'
});
});
it('should include parentSpanId if provided', async () => {
const ctx: AttributionContext = {
agentId: 'agent-123',
parentSpanId: 'span-xyz'
};
const middleware = createAttributionMiddleware(ctx);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.headers).toEqual({
'X-Agent-Id': 'agent-123',
'X-Parent-Span-Id': 'span-xyz'
});
});
});
describe('createLoggingMiddleware', () => {
it('should return empty middleware when debugLogging is false', () => {
const config: AISDKBackendConfig = {
debugLogging: false
};
const middleware = createLoggingMiddleware(config);
expect(middleware.transformParams).toBeUndefined();
expect(middleware.wrapGenerate).toBeUndefined();
expect(middleware.wrapStream).toBeUndefined();
});
it('should return middleware when debugLogging is true', () => {
const config: AISDKBackendConfig = {
debugLogging: true
};
const middleware = createLoggingMiddleware(config);
expect(middleware.transformParams).toBeDefined();
expect(middleware.wrapGenerate).toBeDefined();
expect(middleware.wrapStream).toBeDefined();
});
});
describe('createTimeoutMiddleware', () => {
it('should add abort signal to params', async () => {
const middleware = createTimeoutMiddleware(5000);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.abortSignal).toBeDefined();
expect(result.abortSignal).toBeInstanceOf(AbortSignal);
});
it('should not override existing abort signal', async () => {
const middleware = createTimeoutMiddleware(5000);
const existingSignal = new AbortController().signal;
const result = await middleware.transformParams!({
params: { abortSignal: existingSignal },
type: 'generate',
model: {}
});
expect(result.abortSignal).toBe(existingSignal);
});
});
describe('composeMiddleware', () => {
it('should return empty middleware for empty array', () => {
const composed = composeMiddleware();
expect(Object.keys(composed)).toHaveLength(0);
});
it('should return single middleware unchanged', () => {
const middleware = {
transformParams: async ({ params }: any) => ({ ...params, test: true })
};
const composed = composeMiddleware(middleware);
expect(composed).toBe(middleware);
});
it('should compose multiple transformParams', async () => {
const m1 = {
transformParams: async ({ params }: any) => ({ ...params, m1: true })
};
const m2 = {
transformParams: async ({ params }: any) => ({ ...params, m2: true })
};
const composed = composeMiddleware(m1, m2);
const result = await composed.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result).toEqual({ m1: true, m2: true });
});
it('should filter out empty middlewares', () => {
const m1 = {
transformParams: async ({ params }: any) => ({ ...params, m1: true })
};
const composed = composeMiddleware({}, m1, {});
expect(composed.transformParams).toBeDefined();
});
});
describe('createStandardMiddleware', () => {
it('should include attribution middleware when context provided', async () => {
const config: AISDKBackendConfig = {};
const attribution: AttributionContext = { agentId: 'test' };
const middleware = createStandardMiddleware(config, attribution);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.headers).toEqual({ 'X-Agent-Id': 'test' });
});
it('should include timeout middleware when timeout configured', async () => {
const config: AISDKBackendConfig = { timeout: 5000 };
const middleware = createStandardMiddleware(config);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.abortSignal).toBeDefined();
});
it('should return empty middleware when no config', () => {
const config: AISDKBackendConfig = {};
const middleware = createStandardMiddleware(config);
// Should have no transformParams if no attribution or timeout
expect(Object.keys(middleware).length).toBe(0);
});
});
describe('redactSensitiveData', () => {
it('should redact API keys', () => {
const data = {
apiKey: 'sk-1234567890abcdef',
message: 'hello'
};
const result = redactSensitiveData(data) as any;
expect(result.apiKey).toBe('sk-1****');
expect(result.message).toBe('hello');
});
it('should redact nested sensitive data', () => {
const data = {
config: {
api_key: 'secret-key-12345',
url: 'https://api.example.com'
}
};
const result = redactSensitiveData(data) as any;
expect(result.config.api_key).toBe('secr****');
expect(result.config.url).toBe('https://api.example.com');
});
it('should redact arrays', () => {
const data = [
{ token: 'abc123', value: 'test' }
];
const result = redactSensitiveData(data) as any;
expect(result[0].token).toBe('abc1****');
expect(result[0].value).toBe('test');
});
it('should handle null and undefined', () => {
expect(redactSensitiveData(null)).toBeNull();
expect(redactSensitiveData(undefined)).toBeUndefined();
});
it('should handle primitives', () => {
expect(redactSensitiveData('string')).toBe('string');
expect(redactSensitiveData(123)).toBe(123);
expect(redactSensitiveData(true)).toBe(true);
});
it('should redact short sensitive values', () => {
const data = { key: 'abc' };
const result = redactSensitiveData(data) as any;
expect(result.key).toBe('****');
});
});
@@ -0,0 +1,210 @@
/**
* AI SDK Provider Map Tests
*/
import {
parseModelString,
resolveProviderAlias,
isProviderSupported,
getProviderPackage,
getProviderEnvKey,
listSupportedProviders,
registerCustomProvider,
unregisterCustomProvider,
getCustomProvider,
validateProviderApiKey,
getMissingApiKeyMessage,
} from '../../../../src/llm/providers/ai-sdk/provider-map';
describe('parseModelString', () => {
it('should parse provider/model format', () => {
const result = parseModelString('openai/gpt-4o');
expect(result.providerId).toBe('openai');
expect(result.modelId).toBe('gpt-4o');
});
it('should parse provider/model with slashes in model name', () => {
const result = parseModelString('azure/deployments/gpt-4/chat');
expect(result.providerId).toBe('azure');
expect(result.modelId).toBe('deployments/gpt-4/chat');
});
it('should infer openai from gpt- prefix', () => {
const result = parseModelString('gpt-4o-mini');
expect(result.providerId).toBe('openai');
expect(result.modelId).toBe('gpt-4o-mini');
});
it('should infer anthropic from claude- prefix', () => {
const result = parseModelString('claude-3-5-sonnet');
expect(result.providerId).toBe('anthropic');
expect(result.modelId).toBe('claude-3-5-sonnet');
});
it('should infer google from gemini- prefix', () => {
const result = parseModelString('gemini-2.0-flash');
expect(result.providerId).toBe('google');
expect(result.modelId).toBe('gemini-2.0-flash');
});
it('should infer mistral from mistral- prefix', () => {
const result = parseModelString('mistral-large');
expect(result.providerId).toBe('mistral');
expect(result.modelId).toBe('mistral-large');
});
it('should use default provider for unknown models', () => {
const result = parseModelString('unknown-model', 'openai');
expect(result.providerId).toBe('openai');
expect(result.modelId).toBe('unknown-model');
});
it('should lowercase provider id', () => {
const result = parseModelString('OpenAI/gpt-4o');
expect(result.providerId).toBe('openai');
});
});
describe('resolveProviderAlias', () => {
it('should resolve oai to openai', () => {
expect(resolveProviderAlias('oai')).toBe('openai');
});
it('should resolve claude to anthropic', () => {
expect(resolveProviderAlias('claude')).toBe('anthropic');
});
it('should resolve gemini to google', () => {
expect(resolveProviderAlias('gemini')).toBe('google');
});
it('should return original if no alias', () => {
expect(resolveProviderAlias('openai')).toBe('openai');
expect(resolveProviderAlias('custom-provider')).toBe('custom-provider');
});
});
describe('isProviderSupported', () => {
it('should return true for built-in providers', () => {
expect(isProviderSupported('openai')).toBe(true);
expect(isProviderSupported('anthropic')).toBe(true);
expect(isProviderSupported('google')).toBe(true);
});
it('should return true for aliases', () => {
expect(isProviderSupported('oai')).toBe(true);
expect(isProviderSupported('claude')).toBe(true);
expect(isProviderSupported('gemini')).toBe(true);
});
it('should return false for unknown providers', () => {
expect(isProviderSupported('unknown-provider')).toBe(false);
});
});
describe('getProviderPackage', () => {
it('should return package name for known providers', () => {
expect(getProviderPackage('openai')).toBe('@ai-sdk/openai');
expect(getProviderPackage('anthropic')).toBe('@ai-sdk/anthropic');
expect(getProviderPackage('google')).toBe('@ai-sdk/google');
});
it('should return null for unknown providers', () => {
expect(getProviderPackage('unknown')).toBeNull();
});
it('should resolve aliases', () => {
expect(getProviderPackage('oai')).toBe('@ai-sdk/openai');
expect(getProviderPackage('claude')).toBe('@ai-sdk/anthropic');
});
});
describe('getProviderEnvKey', () => {
it('should return env key for known providers', () => {
expect(getProviderEnvKey('openai')).toBe('OPENAI_API_KEY');
expect(getProviderEnvKey('anthropic')).toBe('ANTHROPIC_API_KEY');
expect(getProviderEnvKey('google')).toBe('GOOGLE_API_KEY');
});
it('should return null for unknown providers', () => {
expect(getProviderEnvKey('unknown')).toBeNull();
});
});
describe('listSupportedProviders', () => {
it('should return array of provider ids', () => {
const providers = listSupportedProviders();
expect(Array.isArray(providers)).toBe(true);
expect(providers.length).toBeGreaterThan(0);
expect(providers).toContain('openai');
expect(providers).toContain('anthropic');
expect(providers).toContain('google');
});
});
describe('custom providers', () => {
const customFactory = jest.fn().mockReturnValue({ test: true });
afterEach(() => {
unregisterCustomProvider('test-custom');
});
it('should register custom provider', () => {
registerCustomProvider('test-custom', customFactory);
expect(getCustomProvider('test-custom')).toBe(customFactory);
expect(isProviderSupported('test-custom')).toBe(true);
});
it('should unregister custom provider', () => {
registerCustomProvider('test-custom', customFactory);
expect(unregisterCustomProvider('test-custom')).toBe(true);
expect(getCustomProvider('test-custom')).toBeUndefined();
expect(isProviderSupported('test-custom')).toBe(false);
});
it('should include custom providers in list', () => {
registerCustomProvider('test-custom', customFactory);
const providers = listSupportedProviders();
expect(providers).toContain('test-custom');
});
});
describe('validateProviderApiKey', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it('should return true when API key is set', () => {
process.env.OPENAI_API_KEY = 'test-key';
expect(validateProviderApiKey('openai')).toBe(true);
});
it('should return false when API key is not set', () => {
delete process.env.OPENAI_API_KEY;
expect(validateProviderApiKey('openai')).toBe(false);
});
it('should return true for custom providers without env key', () => {
registerCustomProvider('no-env-provider', () => ({}));
expect(validateProviderApiKey('no-env-provider')).toBe(true);
unregisterCustomProvider('no-env-provider');
});
});
describe('getMissingApiKeyMessage', () => {
it('should return message with env key for known providers', () => {
const msg = getMissingApiKeyMessage('openai');
expect(msg).toContain('OPENAI_API_KEY');
});
it('should return generic message for unknown providers', () => {
const msg = getMissingApiKeyMessage('unknown');
expect(msg).toContain('unknown');
});
});
@@ -0,0 +1,105 @@
/**
* AI SDK Types Tests
*/
import {
AISDKError,
SAFE_DEFAULTS,
AISDK_PROVIDERS,
PROVIDER_ALIASES,
} from '../../../../src/llm/providers/ai-sdk/types';
describe('AISDKError', () => {
it('should create error with all properties', () => {
const error = new AISDKError(
'Test error message',
'RATE_LIMIT',
true,
new Error('cause'),
429
);
expect(error.message).toBe('Test error message');
expect(error.code).toBe('RATE_LIMIT');
expect(error.isRetryable).toBe(true);
expect(error.cause).toBeInstanceOf(Error);
expect(error.statusCode).toBe(429);
expect(error.name).toBe('AISDKError');
});
it('should create error without optional properties', () => {
const error = new AISDKError('Simple error', 'UNKNOWN', false);
expect(error.message).toBe('Simple error');
expect(error.code).toBe('UNKNOWN');
expect(error.isRetryable).toBe(false);
expect(error.cause).toBeUndefined();
expect(error.statusCode).toBeUndefined();
});
it('should be instanceof Error', () => {
const error = new AISDKError('Test', 'UNKNOWN', false);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AISDKError);
});
});
describe('SAFE_DEFAULTS', () => {
it('should have correct default values', () => {
expect(SAFE_DEFAULTS.timeout).toBe(60000);
expect(SAFE_DEFAULTS.maxRetries).toBe(2);
expect(SAFE_DEFAULTS.maxOutputTokens).toBe(4096);
expect(SAFE_DEFAULTS.redactLogs).toBe(true);
expect(SAFE_DEFAULTS.debugLogging).toBe(false);
});
});
describe('AISDK_PROVIDERS', () => {
it('should have openai provider', () => {
expect(AISDK_PROVIDERS.openai).toBeDefined();
expect(AISDK_PROVIDERS.openai.package).toBe('@ai-sdk/openai');
expect(AISDK_PROVIDERS.openai.envKey).toBe('OPENAI_API_KEY');
});
it('should have anthropic provider', () => {
expect(AISDK_PROVIDERS.anthropic).toBeDefined();
expect(AISDK_PROVIDERS.anthropic.package).toBe('@ai-sdk/anthropic');
expect(AISDK_PROVIDERS.anthropic.envKey).toBe('ANTHROPIC_API_KEY');
});
it('should have google provider', () => {
expect(AISDK_PROVIDERS.google).toBeDefined();
expect(AISDK_PROVIDERS.google.package).toBe('@ai-sdk/google');
expect(AISDK_PROVIDERS.google.envKey).toBe('GOOGLE_API_KEY');
});
it('should have all expected providers', () => {
const expectedProviders = [
'openai', 'anthropic', 'google', 'azure', 'amazon-bedrock',
'groq', 'mistral', 'cohere', 'deepseek', 'xai',
'fireworks', 'togetherai', 'perplexity'
];
for (const provider of expectedProviders) {
expect(AISDK_PROVIDERS[provider as keyof typeof AISDK_PROVIDERS]).toBeDefined();
}
});
});
describe('PROVIDER_ALIASES', () => {
it('should map oai to openai', () => {
expect(PROVIDER_ALIASES.oai).toBe('openai');
});
it('should map claude to anthropic', () => {
expect(PROVIDER_ALIASES.claude).toBe('anthropic');
});
it('should map gemini to google', () => {
expect(PROVIDER_ALIASES.gemini).toBe('google');
});
it('should map bedrock to amazon-bedrock', () => {
expect(PROVIDER_ALIASES.bedrock).toBe('amazon-bedrock');
});
});
@@ -192,11 +192,14 @@ describe('ProviderRegistry', () => {
// First resolution should call loader
const provider1 = registry.resolve('mock', 'model-1');
expect(loaderFn).toHaveBeenCalledTimes(1);
expect(loaderFn).toHaveBeenCalled();
expect(provider1.providerId).toBe('mock');
expect(provider1.modelId).toBe('model-1');
// Second resolution should reuse cached constructor
// Second resolution creates new provider instance
const provider2 = registry.resolve('mock', 'model-2');
expect(loaderFn).toHaveBeenCalledTimes(1); // Still 1, not called again
expect(provider2.providerId).toBe('mock');
expect(provider2.modelId).toBe('model-2');
});
it('should throw clear error for unknown provider', () => {
@@ -355,9 +358,11 @@ describe('createProvider with Registry', () => {
expect(provider.modelId).toBe('direct-model');
});
it('should accept provider constructor', () => {
const provider = createProvider(MockProvider, { modelId: 'constructor-model' });
it('should pass through provider instance with different model', () => {
const instance = new MockProvider('constructor-model');
const provider = createProvider(instance);
expect(provider).toBeInstanceOf(MockProvider);
expect(provider.modelId).toBe('constructor-model');
});
it('should accept provider spec object', () => {
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.9.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.9.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.1.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.1.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.7.9 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.8.1 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+4
View File
@@ -46,6 +46,10 @@ def __getattr__(name):
from .registry import parse_model_string
_lazy_cache[name] = parse_model_string
return parse_model_string
elif name == "_reset_default_registry":
from .registry import _reset_default_registry
_lazy_cache[name] = _reset_default_registry
return _reset_default_registry
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+9 -1
View File
@@ -77,9 +77,17 @@ class LLMProviderRegistry:
if aliases:
for alias in aliases:
normalized_alias = alias.lower()
# Check collision with existing provider name
if normalized_alias in self._providers and not override:
raise ValueError(
f"Alias '{alias}' conflicts with existing provider. "
f"Alias '{alias}' conflicts with existing provider name. "
f"Use override=True to replace it."
)
# Check collision with existing alias
if normalized_alias in self._aliases and not override:
existing_target = self._aliases[normalized_alias]
raise ValueError(
f"Alias '{alias}' is already registered (points to '{existing_target}'). "
f"Use override=True to replace it."
)
self._aliases[normalized_alias] = normalized_name
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.7.9"
__version__ = "2.8.1"
@@ -308,3 +308,177 @@ class TestCreateLLMProvider:
# Should not be in default registry
with pytest.raises(ValueError):
create_llm_provider("isolated/model")
class TestCollisionDetection:
"""Tests for collision detection and error messages."""
def test_alias_collision_with_existing_alias(self):
"""Should raise error when alias collides with existing alias."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("provider-a", MockLLMProvider, aliases=["shared-alias"])
with pytest.raises(ValueError) as exc_info:
registry.register("provider-b", CustomCloudflareProvider, aliases=["shared-alias"])
error_msg = str(exc_info.value)
assert "shared-alias" in error_msg
assert "already registered" in error_msg
assert "provider-a" in error_msg # Should mention the existing target
def test_alias_collision_with_provider_name(self):
"""Should raise error when alias collides with provider name."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("existing-provider", MockLLMProvider)
with pytest.raises(ValueError) as exc_info:
registry.register("new-provider", CustomCloudflareProvider, aliases=["existing-provider"])
error_msg = str(exc_info.value)
assert "existing-provider" in error_msg
assert "conflicts" in error_msg
def test_alias_override_with_flag(self):
"""Should allow alias override with override=True."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("provider-a", MockLLMProvider, aliases=["shared-alias"])
registry.register("provider-b", CustomCloudflareProvider, aliases=["shared-alias"], override=True)
# Alias should now point to provider-b
provider = registry.resolve("shared-alias", "model")
assert provider.provider_id == "cloudflare"
def test_case_insensitive_collision(self):
"""Should detect collision regardless of case."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("MyProvider", MockLLMProvider)
with pytest.raises(ValueError, match="already registered"):
registry.register("myprovider", CustomCloudflareProvider)
def test_error_message_includes_available_providers(self):
"""Error message should list available providers."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("openai", MockLLMProvider)
registry.register("anthropic", MockLLMProvider)
with pytest.raises(ValueError) as exc_info:
registry.resolve("unknown", "model")
error_msg = str(exc_info.value).lower()
assert "unknown provider" in error_msg
assert "openai" in error_msg
assert "anthropic" in error_msg
assert "register" in error_msg # Should suggest how to register
class TestParseModelString:
"""Tests for model string parsing."""
def test_parse_provider_model_format(self):
"""Should parse provider/model format."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("openai/gpt-4o")
assert result["provider_id"] == "openai"
assert result["model_id"] == "gpt-4o"
def test_parse_gpt_model_defaults_to_openai(self):
"""Should default gpt-* models to openai."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("gpt-4o-mini")
assert result["provider_id"] == "openai"
assert result["model_id"] == "gpt-4o-mini"
def test_parse_claude_model_defaults_to_anthropic(self):
"""Should default claude-* models to anthropic."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("claude-3-5-sonnet")
assert result["provider_id"] == "anthropic"
assert result["model_id"] == "claude-3-5-sonnet"
def test_parse_gemini_model_defaults_to_google(self):
"""Should default gemini-* models to google."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("gemini-2.0-flash")
assert result["provider_id"] == "google"
assert result["model_id"] == "gemini-2.0-flash"
def test_parse_o1_model_defaults_to_openai(self):
"""Should default o1* models to openai."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("o1-preview")
assert result["provider_id"] == "openai"
assert result["model_id"] == "o1-preview"
def test_parse_unknown_model_defaults_to_openai(self):
"""Should default unknown models to openai."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("llama-3.1-8b")
assert result["provider_id"] == "openai"
assert result["model_id"] == "llama-3.1-8b"
def test_parse_custom_provider_model(self):
"""Should parse custom provider/model format."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("cloudflare/workers-ai-model")
assert result["provider_id"] == "cloudflare"
assert result["model_id"] == "workers-ai-model"
class TestLiteLLMIsolation:
"""Tests to verify LiteLLM paths are not affected."""
def test_registry_does_not_import_litellm(self):
"""Registry module should not import litellm."""
import sys
# Clear any cached imports
modules_before = set(sys.modules.keys())
# Import registry
from praisonai.llm import registry
# Check litellm was not imported
modules_after = set(sys.modules.keys())
new_modules = modules_after - modules_before
litellm_modules = [m for m in new_modules if 'litellm' in m.lower()]
assert len(litellm_modules) == 0, f"Registry imported litellm modules: {litellm_modules}"
def test_registry_only_imports_typing(self):
"""Registry should only import from typing module."""
import ast
import inspect
from praisonai.llm import registry
source = inspect.getsource(registry)
tree = ast.parse(source)
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imports.append(alias.name)
elif isinstance(node, ast.ImportFrom):
if node.module:
imports.append(node.module)
# Only typing should be imported
assert imports == ['typing'], f"Unexpected imports: {imports}"