mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 03:31:28 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa67ad27c | ||
|
|
307df443ef | ||
|
|
6434a87b60 | ||
|
|
97d5b6db0d | ||
|
|
4996a9f9fa | ||
|
|
54f4a96cba | ||
|
|
fff67a96de | ||
|
|
e59be8f0fd | ||
|
|
b52708f3f6 | ||
|
|
519ad56e2b | ||
|
|
98c5e8f175 |
No files matched your search
@@ -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.8.0" \
|
||||
"praisonai>=2.8.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -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.8.0" \
|
||||
"praisonai>=2.8.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -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.8.0" \
|
||||
"praisonai>=2.8.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import warnings
|
||||
import re
|
||||
import inspect
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, Union, Literal, Callable
|
||||
from pydantic import BaseModel
|
||||
import time
|
||||
@@ -330,7 +331,12 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self.last_token_metrics: Optional[TokenMetrics] = None
|
||||
self.session_token_metrics: Optional[TokenMetrics] = None
|
||||
self.current_agent_name: Optional[str] = None
|
||||
|
||||
|
||||
# Rate limiting and retry settings
|
||||
self._rate_limiter = extra_settings.get('rate_limiter', None)
|
||||
self._max_retries = extra_settings.get('max_retries', 3)
|
||||
self._retry_delay = extra_settings.get('retry_delay', 60) # Default 60 seconds
|
||||
|
||||
# Cache for formatted tools and messages
|
||||
self._formatted_tools_cache = {}
|
||||
self._max_cache_size = 100
|
||||
@@ -432,13 +438,199 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
else:
|
||||
return False
|
||||
|
||||
def _parse_retry_delay(self, error_message: str) -> float:
|
||||
"""Parse retry delay from rate limit error message.
|
||||
|
||||
Args:
|
||||
error_message: The error message from the API
|
||||
|
||||
Returns:
|
||||
Retry delay in seconds, or default if not found
|
||||
"""
|
||||
# Try to find retry delay patterns like "retryDelay: 58s" or "retry after 58 seconds"
|
||||
patterns = [
|
||||
r'"retryDelay":\s*"(\d+)s"', # JSON format: "retryDelay": "58s"
|
||||
r'retryDelay:\s*"?(\d+)s"?', # retryDelay: 58s or retryDelay: "58s"
|
||||
r'retry.{0,10}?(\d+)\s*second', # retry after 58 seconds (non-greedy)
|
||||
r'wait\s+(\d+)\s*second', # wait 58 seconds
|
||||
r'try again in (\d+)', # try again in 58
|
||||
r'Retry-After:\s*(\d+)', # HTTP Retry-After header
|
||||
]
|
||||
|
||||
# Max delay cap to prevent unbounded sleep (5 minutes default)
|
||||
max_delay = 300
|
||||
if self._rate_limiter is not None and hasattr(self._rate_limiter, 'max_retry_delay'):
|
||||
max_delay = self._rate_limiter.max_retry_delay
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, error_message, re.IGNORECASE)
|
||||
if match:
|
||||
delay = float(match.group(1))
|
||||
# Clamp to safe bounds [0, max_delay] to prevent unbounded sleep
|
||||
return max(0, min(delay, max_delay))
|
||||
|
||||
return self._retry_delay
|
||||
|
||||
def _is_rate_limit_error(self, error: Exception) -> bool:
|
||||
"""Check if an exception is a rate limit error.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a rate limit (429) error
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
error_type = type(error).__name__.lower()
|
||||
|
||||
# Check for common rate limit indicators
|
||||
indicators = [
|
||||
'429',
|
||||
'rate limit',
|
||||
'ratelimit',
|
||||
'too many request',
|
||||
'resource_exhausted',
|
||||
'quota exceeded',
|
||||
'tokens per minute',
|
||||
]
|
||||
|
||||
return any(indicator in error_str or indicator in error_type for indicator in indicators)
|
||||
|
||||
def _call_with_retry(self, func, *args, **kwargs):
|
||||
"""Call a function with automatic retry on rate limit errors.
|
||||
|
||||
Args:
|
||||
func: The function to call (e.g., litellm.completion)
|
||||
*args: Positional arguments for the function
|
||||
**kwargs: Keyword arguments for the function
|
||||
|
||||
Returns:
|
||||
The result of the function call
|
||||
|
||||
Raises:
|
||||
The original exception if max retries exceeded
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
# Apply rate limiter before the call if configured
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.acquire()
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
if not self._is_rate_limit_error(e):
|
||||
raise
|
||||
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
if attempt < self._max_retries:
|
||||
retry_delay = self._parse_retry_delay(error_str)
|
||||
|
||||
logging.warning(
|
||||
f"Rate limit hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"waiting {retry_delay:.1f}s before retry..."
|
||||
)
|
||||
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.wait_for_retry(retry_delay)
|
||||
else:
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
logging.error(f"Rate limit exceeded after {self._max_retries + 1} attempts")
|
||||
raise
|
||||
|
||||
raise last_error
|
||||
|
||||
async def _call_with_retry_async(self, func, *args, **kwargs):
|
||||
"""Async version of _call_with_retry.
|
||||
|
||||
Args:
|
||||
func: The async function to call
|
||||
*args: Positional arguments for the function
|
||||
**kwargs: Keyword arguments for the function
|
||||
|
||||
Returns:
|
||||
The result of the function call
|
||||
|
||||
Raises:
|
||||
The original exception if max retries exceeded
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
# Apply rate limiter before the call if configured
|
||||
if self._rate_limiter is not None:
|
||||
await self._rate_limiter.acquire_async()
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
if not self._is_rate_limit_error(e):
|
||||
raise
|
||||
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
if attempt < self._max_retries:
|
||||
retry_delay = self._parse_retry_delay(error_str)
|
||||
|
||||
logging.warning(
|
||||
f"Rate limit hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"waiting {retry_delay:.1f}s before retry..."
|
||||
)
|
||||
|
||||
if self._rate_limiter is not None:
|
||||
await self._rate_limiter.wait_for_retry_async(retry_delay)
|
||||
else:
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
logging.error(f"Rate limit exceeded after {self._max_retries + 1} attempts")
|
||||
raise
|
||||
|
||||
raise last_error
|
||||
|
||||
def _completion_with_retry(self, **completion_params):
|
||||
"""Execute litellm.completion with automatic retry on rate limit errors.
|
||||
|
||||
This is a convenience wrapper that combines _call_with_retry with litellm.completion.
|
||||
Use this for non-streaming completion calls that should have retry support.
|
||||
|
||||
Args:
|
||||
**completion_params: Parameters to pass to litellm.completion
|
||||
|
||||
Returns:
|
||||
The completion response from litellm
|
||||
"""
|
||||
import litellm
|
||||
return self._call_with_retry(litellm.completion, **completion_params)
|
||||
|
||||
async def _acompletion_with_retry(self, **completion_params):
|
||||
"""Execute litellm.acompletion with automatic retry on rate limit errors.
|
||||
|
||||
This is a convenience wrapper that combines _call_with_retry_async with litellm.acompletion.
|
||||
Use this for non-streaming async completion calls that should have retry support.
|
||||
|
||||
Args:
|
||||
**completion_params: Parameters to pass to litellm.acompletion
|
||||
|
||||
Returns:
|
||||
The completion response from litellm
|
||||
"""
|
||||
import litellm
|
||||
return await self._call_with_retry_async(litellm.acompletion, **completion_params)
|
||||
|
||||
def _supports_web_search(self) -> bool:
|
||||
"""
|
||||
Check if the current model supports native web search via LiteLLM.
|
||||
|
||||
|
||||
Native web search allows the model to search the web in real-time
|
||||
without requiring external tools like DuckDuckGo.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports native web search, False otherwise
|
||||
"""
|
||||
@@ -3754,7 +3946,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if content:
|
||||
response_text += content
|
||||
else:
|
||||
response = litellm.completion(**completion_params)
|
||||
# Use retry wrapper for non-streaming calls
|
||||
response = self._completion_with_retry(**completion_params)
|
||||
response_text = response.choices[0].message.content.strip() if response.choices[0].message.content else ""
|
||||
|
||||
if verbose:
|
||||
@@ -3850,7 +4043,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if content:
|
||||
response_text += content
|
||||
else:
|
||||
response = await litellm.acompletion(**completion_params)
|
||||
# Use retry wrapper for non-streaming async calls
|
||||
response = await self._acompletion_with_retry(**completion_params)
|
||||
response_text = response.choices[0].message.content.strip() if response.choices[0].message.content else ""
|
||||
|
||||
if verbose:
|
||||
|
||||
@@ -11,59 +11,92 @@ Zero Performance Impact:
|
||||
Usage:
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
# Request-based limiting
|
||||
limiter = RateLimiter(requests_per_minute=60)
|
||||
agent = Agent(name="Test", rate_limiter=limiter)
|
||||
|
||||
# Token-based limiting (for API token quotas)
|
||||
limiter = RateLimiter(tokens_per_minute=1000000)
|
||||
agent = Agent(name="Test", rate_limiter=limiter)
|
||||
"""
|
||||
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimiter:
|
||||
"""Token bucket rate limiter for API calls.
|
||||
|
||||
|
||||
Uses the token bucket algorithm:
|
||||
- Tokens are added at a fixed rate (requests_per_minute / 60 per second)
|
||||
- Each request consumes one token
|
||||
- If no tokens available, wait until one is available
|
||||
- Burst allows multiple requests in quick succession
|
||||
|
||||
|
||||
Args:
|
||||
requests_per_minute: Maximum requests per minute
|
||||
tokens_per_minute: Optional token-based rate limiting (for future use)
|
||||
burst: Maximum burst size (default: 1)
|
||||
|
||||
requests_per_minute: Maximum requests per minute (default: None)
|
||||
tokens_per_minute: Maximum API tokens per minute for token-based limiting
|
||||
burst: Maximum burst size (default: 1 for requests, auto-calculated for tokens)
|
||||
max_retry_delay: Maximum delay in seconds for rate limit retries (default: 120)
|
||||
|
||||
Example:
|
||||
# Request-based limiting
|
||||
limiter = RateLimiter(requests_per_minute=60) # 1 req/sec
|
||||
limiter.acquire() # Blocks if rate exceeded
|
||||
|
||||
|
||||
# Token-based limiting (for API quotas like Gemini)
|
||||
limiter = RateLimiter(tokens_per_minute=1000000) # 1M tokens/min
|
||||
limiter.acquire_tokens(5000) # Request 5000 tokens
|
||||
|
||||
# Async usage
|
||||
await limiter.acquire_async()
|
||||
"""
|
||||
requests_per_minute: int
|
||||
requests_per_minute: Optional[int] = None
|
||||
tokens_per_minute: Optional[int] = None
|
||||
burst: int = 1
|
||||
|
||||
# Internal state
|
||||
max_retry_delay: int = 120
|
||||
|
||||
# Internal state for request limiting
|
||||
_tokens: float = field(default=None, init=False, repr=False)
|
||||
_last_update: float = field(default=None, init=False, repr=False)
|
||||
_lock: asyncio.Lock = field(default=None, init=False, repr=False)
|
||||
|
||||
|
||||
# Internal state for token-based limiting
|
||||
_api_tokens: float = field(default=None, init=False, repr=False)
|
||||
_api_tokens_last_update: float = field(default=None, init=False, repr=False)
|
||||
_api_tokens_lock: asyncio.Lock = field(default=None, init=False, repr=False)
|
||||
|
||||
# Injectable functions for testing
|
||||
_get_time: Callable[[], float] = field(default=None, init=False, repr=False)
|
||||
_sleep: Callable[[float], None] = field(default=None, init=False, repr=False)
|
||||
_async_sleep: Callable[[float], None] = field(default=None, init=False, repr=False)
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize internal state."""
|
||||
self._tokens = float(self.burst)
|
||||
# Request-based limiting
|
||||
if self.requests_per_minute is not None:
|
||||
self._tokens = float(self.burst)
|
||||
else:
|
||||
self._tokens = float('inf')
|
||||
self._last_update = time.monotonic()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
|
||||
# Token-based limiting
|
||||
if self.tokens_per_minute is not None:
|
||||
token_burst = max(self.burst, self.tokens_per_minute // 60)
|
||||
self._api_tokens = float(token_burst)
|
||||
else:
|
||||
self._api_tokens = float('inf')
|
||||
self._api_tokens_last_update = time.monotonic()
|
||||
self._api_tokens_lock = asyncio.Lock()
|
||||
|
||||
# Default implementations (can be overridden for testing)
|
||||
if self._get_time is None:
|
||||
self._get_time = time.monotonic
|
||||
@@ -71,77 +104,206 @@ class RateLimiter:
|
||||
self._sleep = time.sleep
|
||||
if self._async_sleep is None:
|
||||
self._async_sleep = asyncio.sleep
|
||||
|
||||
|
||||
@property
|
||||
def _rate(self) -> float:
|
||||
"""Tokens per second."""
|
||||
"""Requests per second."""
|
||||
if self.requests_per_minute is None:
|
||||
return float('inf')
|
||||
return self.requests_per_minute / 60.0
|
||||
|
||||
|
||||
@property
|
||||
def _token_rate(self) -> float:
|
||||
"""API tokens per second."""
|
||||
if self.tokens_per_minute is None:
|
||||
return float('inf')
|
||||
return self.tokens_per_minute / 60.0
|
||||
|
||||
def _refill(self) -> None:
|
||||
"""Refill tokens based on elapsed time."""
|
||||
"""Refill request tokens based on elapsed time."""
|
||||
if self.requests_per_minute is None:
|
||||
return
|
||||
now = self._get_time()
|
||||
elapsed = now - self._last_update
|
||||
self._tokens = min(self.burst, self._tokens + elapsed * self._rate)
|
||||
self._last_update = now
|
||||
|
||||
|
||||
def _refill_api_tokens(self) -> None:
|
||||
"""Refill API tokens based on elapsed time."""
|
||||
if self.tokens_per_minute is None:
|
||||
return
|
||||
now = self._get_time()
|
||||
elapsed = now - self._api_tokens_last_update
|
||||
token_burst = max(self.burst, self.tokens_per_minute // 60)
|
||||
self._api_tokens = min(token_burst, self._api_tokens + elapsed * self._token_rate)
|
||||
self._api_tokens_last_update = now
|
||||
|
||||
def _wait_time(self) -> float:
|
||||
"""Calculate time to wait for next token."""
|
||||
if self._tokens >= 1.0:
|
||||
"""Calculate time to wait for next request token."""
|
||||
if self.requests_per_minute is None or self._tokens >= 1.0:
|
||||
return 0.0
|
||||
return (1.0 - self._tokens) / self._rate
|
||||
|
||||
|
||||
def _wait_time_for_tokens(self, num_tokens: int) -> float:
|
||||
"""Calculate time to wait for API tokens."""
|
||||
if self.tokens_per_minute is None or self._api_tokens >= num_tokens:
|
||||
return 0.0
|
||||
tokens_needed = num_tokens - self._api_tokens
|
||||
return tokens_needed / self._token_rate
|
||||
|
||||
def acquire(self) -> None:
|
||||
"""Acquire a token, blocking if necessary.
|
||||
|
||||
"""Acquire a request token, blocking if necessary.
|
||||
|
||||
This is the synchronous version. Use acquire_async() for async code.
|
||||
"""
|
||||
if self.requests_per_minute is None:
|
||||
return
|
||||
|
||||
self._refill()
|
||||
|
||||
|
||||
wait = self._wait_time()
|
||||
if wait > 0:
|
||||
logger.debug(f"Rate limit: waiting {wait:.2f}s before next request")
|
||||
self._sleep(wait)
|
||||
self._refill()
|
||||
|
||||
|
||||
self._tokens -= 1.0
|
||||
|
||||
|
||||
def acquire_tokens(self, num_tokens: int) -> None:
|
||||
"""Acquire API tokens, blocking if necessary.
|
||||
|
||||
Args:
|
||||
num_tokens: Number of API tokens to acquire
|
||||
|
||||
Note:
|
||||
If num_tokens exceeds the burst capacity, this will wait until
|
||||
enough tokens accumulate. For very large requests, consider
|
||||
splitting into smaller chunks or increasing tokens_per_minute.
|
||||
"""
|
||||
if self.tokens_per_minute is None:
|
||||
return
|
||||
|
||||
self._refill_api_tokens()
|
||||
|
||||
wait = self._wait_time_for_tokens(num_tokens)
|
||||
if wait > 0:
|
||||
# Do NOT cap internal wait time - this ensures correct rate limiting
|
||||
# The wait time is calculated based on token refill rate and must be honored
|
||||
logger.debug(f"Token limit: waiting {wait:.2f}s for {num_tokens} tokens")
|
||||
self._sleep(wait)
|
||||
self._refill_api_tokens()
|
||||
|
||||
self._api_tokens -= num_tokens
|
||||
|
||||
async def acquire_async(self) -> None:
|
||||
"""Acquire a token asynchronously, waiting if necessary.
|
||||
|
||||
"""Acquire a request token asynchronously, waiting if necessary.
|
||||
|
||||
This is the async version. Use acquire() for sync code.
|
||||
"""
|
||||
if self.requests_per_minute is None:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
self._refill()
|
||||
|
||||
|
||||
wait = self._wait_time()
|
||||
if wait > 0:
|
||||
logger.debug(f"Rate limit: waiting {wait:.2f}s before next request")
|
||||
await self._async_sleep(wait)
|
||||
self._refill()
|
||||
|
||||
|
||||
self._tokens -= 1.0
|
||||
|
||||
|
||||
async def acquire_tokens_async(self, num_tokens: int) -> None:
|
||||
"""Acquire API tokens asynchronously.
|
||||
|
||||
Args:
|
||||
num_tokens: Number of API tokens to acquire
|
||||
|
||||
Note:
|
||||
If num_tokens exceeds the burst capacity, this will wait until
|
||||
enough tokens accumulate. For very large requests, consider
|
||||
splitting into smaller chunks or increasing tokens_per_minute.
|
||||
"""
|
||||
if self.tokens_per_minute is None:
|
||||
return
|
||||
|
||||
async with self._api_tokens_lock:
|
||||
self._refill_api_tokens()
|
||||
|
||||
wait = self._wait_time_for_tokens(num_tokens)
|
||||
if wait > 0:
|
||||
# Do NOT cap internal wait time - this ensures correct rate limiting
|
||||
logger.debug(f"Token limit: waiting {wait:.2f}s for {num_tokens} tokens")
|
||||
await self._async_sleep(wait)
|
||||
self._refill_api_tokens()
|
||||
|
||||
self._api_tokens -= num_tokens
|
||||
|
||||
def try_acquire(self) -> bool:
|
||||
"""Try to acquire a token without blocking.
|
||||
|
||||
|
||||
Returns:
|
||||
True if token acquired, False if rate limit exceeded
|
||||
"""
|
||||
if self.requests_per_minute is None:
|
||||
return True
|
||||
|
||||
self._refill()
|
||||
|
||||
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the rate limiter to initial state."""
|
||||
self._tokens = float(self.burst)
|
||||
if self.requests_per_minute is not None:
|
||||
self._tokens = float(self.burst)
|
||||
self._last_update = self._get_time()
|
||||
|
||||
|
||||
if self.tokens_per_minute is not None:
|
||||
token_burst = max(self.burst, self.tokens_per_minute // 60)
|
||||
self._api_tokens = float(token_burst)
|
||||
self._api_tokens_last_update = self._get_time()
|
||||
|
||||
def wait_for_retry(self, retry_delay: float) -> None:
|
||||
"""Wait for a specified retry delay (for handling 429 errors).
|
||||
|
||||
Args:
|
||||
retry_delay: Delay in seconds to wait
|
||||
"""
|
||||
delay = min(retry_delay, self.max_retry_delay)
|
||||
logger.info(f"Rate limit exceeded, waiting {delay:.1f}s before retry")
|
||||
self._sleep(delay)
|
||||
|
||||
async def wait_for_retry_async(self, retry_delay: float) -> None:
|
||||
"""Asynchronously wait for a specified retry delay.
|
||||
|
||||
Args:
|
||||
retry_delay: Delay in seconds to wait
|
||||
"""
|
||||
delay = min(retry_delay, self.max_retry_delay)
|
||||
logger.info(f"Rate limit exceeded, waiting {delay:.1f}s before retry")
|
||||
await self._async_sleep(delay)
|
||||
|
||||
@property
|
||||
def available_tokens(self) -> float:
|
||||
"""Get current available tokens (for monitoring)."""
|
||||
"""Get current available request tokens (for monitoring)."""
|
||||
self._refill()
|
||||
return self._tokens
|
||||
|
||||
|
||||
@property
|
||||
def available_api_tokens(self) -> float:
|
||||
"""Get current available API tokens (for monitoring)."""
|
||||
self._refill_api_tokens()
|
||||
return self._api_tokens
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"RateLimiter(rpm={self.requests_per_minute}, burst={self.burst})"
|
||||
parts = []
|
||||
if self.requests_per_minute is not None:
|
||||
parts.append(f"rpm={self.requests_per_minute}")
|
||||
if self.tokens_per_minute is not None:
|
||||
parts.append(f"tpm={self.tokens_per_minute}")
|
||||
parts.append(f"burst={self.burst}")
|
||||
return f"RateLimiter({', '.join(parts)})"
|
||||
@@ -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)
|
||||
|
||||
@@ -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,251 @@
|
||||
"""
|
||||
Comprehensive tests for rate limiting and retry functionality.
|
||||
Tests for issue #1086 - tokens_per_minute rate limiting and 429 error auto-retry.
|
||||
"""
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
|
||||
class TestRetryDelayParsing:
|
||||
"""Test retry delay parsing from various error message formats."""
|
||||
|
||||
def test_parse_json_format(self):
|
||||
"""Test parsing JSON format: "retryDelay": "58s" """
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
msg = '{"error": {"details": [{"@type": "RetryInfo", "retryDelay": "58s"}]}}'
|
||||
assert llm._parse_retry_delay(msg) == 58.0
|
||||
|
||||
def test_parse_plain_format(self):
|
||||
"""Test parsing plain format: retry after 30 seconds"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
msg = 'retry after 30 seconds'
|
||||
assert llm._parse_retry_delay(msg) == 30.0
|
||||
|
||||
def test_parse_wait_format(self):
|
||||
"""Test parsing wait format: wait 45 seconds"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
msg = 'Please wait 45 seconds before retrying'
|
||||
assert llm._parse_retry_delay(msg) == 45.0
|
||||
|
||||
def test_parse_try_again_format(self):
|
||||
"""Test parsing try again format: try again in 60"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
msg = 'Rate limited. Please try again in 60 seconds'
|
||||
assert llm._parse_retry_delay(msg) == 60.0
|
||||
|
||||
def test_parse_retry_after_header(self):
|
||||
"""Test parsing Retry-After header format"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
msg = 'Retry-After: 120'
|
||||
assert llm._parse_retry_delay(msg) == 120.0
|
||||
|
||||
def test_parse_returns_default_on_no_match(self):
|
||||
"""Test that default is returned when no pattern matches"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
msg = 'Some random error without delay info'
|
||||
assert llm._parse_retry_delay(msg) == 60.0 # Default retry_delay
|
||||
|
||||
def test_parse_clamps_huge_delay(self):
|
||||
"""Test that huge delays are clamped to max_retry_delay"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
msg = 'retry after 999999 seconds'
|
||||
delay = llm._parse_retry_delay(msg)
|
||||
assert delay <= 300 # Default max is 300 seconds
|
||||
|
||||
|
||||
class TestRateLimitErrorDetection:
|
||||
"""Test rate limit error detection."""
|
||||
|
||||
def test_detect_429_error(self):
|
||||
"""Test detection of 429 status code"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
e = Exception("429 Too Many Requests")
|
||||
assert llm._is_rate_limit_error(e) is True
|
||||
|
||||
def test_detect_rate_limit_text(self):
|
||||
"""Test detection of rate limit text"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
e = Exception("Rate limit exceeded")
|
||||
assert llm._is_rate_limit_error(e) is True
|
||||
|
||||
def test_detect_resource_exhausted(self):
|
||||
"""Test detection of RESOURCE_EXHAUSTED error"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
e = Exception("RESOURCE_EXHAUSTED: quota exceeded")
|
||||
assert llm._is_rate_limit_error(e) is True
|
||||
|
||||
def test_detect_tokens_per_minute(self):
|
||||
"""Test detection of tokens per minute quota error"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
e = Exception("tokens per minute quota exceeded")
|
||||
assert llm._is_rate_limit_error(e) is True
|
||||
|
||||
def test_non_rate_limit_error(self):
|
||||
"""Test that non-rate-limit errors are not detected"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
e = Exception("Connection timeout")
|
||||
assert llm._is_rate_limit_error(e) is False
|
||||
|
||||
|
||||
class TestCallWithRetry:
|
||||
"""Test the _call_with_retry method."""
|
||||
|
||||
def test_success_on_first_try(self):
|
||||
"""Test successful call on first attempt"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
mock_func = Mock(return_value="success")
|
||||
result = llm._call_with_retry(mock_func, "arg1", kwarg1="value1")
|
||||
|
||||
assert result == "success"
|
||||
mock_func.assert_called_once_with("arg1", kwarg1="value1")
|
||||
|
||||
def test_retry_on_rate_limit(self):
|
||||
"""Test retry behavior on rate limit error"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
llm._retry_delay = 0.01 # Short delay for testing
|
||||
|
||||
# Fail twice, then succeed
|
||||
mock_func = Mock(side_effect=[
|
||||
Exception("429 Too Many Requests"),
|
||||
Exception("Rate limit exceeded"),
|
||||
"success"
|
||||
])
|
||||
|
||||
with patch('time.sleep'):
|
||||
result = llm._call_with_retry(mock_func)
|
||||
|
||||
assert result == "success"
|
||||
assert mock_func.call_count == 3
|
||||
|
||||
def test_no_retry_on_non_rate_limit_error(self):
|
||||
"""Test that non-rate-limit errors are not retried"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
mock_func = Mock(side_effect=Exception("Connection error"))
|
||||
|
||||
with pytest.raises(Exception, match="Connection error"):
|
||||
llm._call_with_retry(mock_func)
|
||||
|
||||
mock_func.assert_called_once()
|
||||
|
||||
def test_max_retries_exceeded(self):
|
||||
"""Test that exception is raised after max retries"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
llm._max_retries = 2
|
||||
llm._retry_delay = 0.01
|
||||
|
||||
mock_func = Mock(side_effect=Exception("429 Too Many Requests"))
|
||||
|
||||
with patch('time.sleep'):
|
||||
with pytest.raises(Exception, match="429"):
|
||||
llm._call_with_retry(mock_func)
|
||||
|
||||
# Should be called max_retries + 1 times (initial + retries)
|
||||
assert mock_func.call_count == 3
|
||||
|
||||
|
||||
class TestTokenBucketAlgorithm:
|
||||
"""Test token bucket rate limiting algorithm."""
|
||||
|
||||
def test_acquire_tokens_without_blocking(self):
|
||||
"""Test acquiring tokens when available"""
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
|
||||
limiter = RateLimiter(tokens_per_minute=60000, burst=1000)
|
||||
|
||||
start = time.time()
|
||||
limiter.acquire_tokens(500)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert elapsed < 0.5 # Should be nearly instant
|
||||
|
||||
def test_token_refill_over_time(self):
|
||||
"""Test that tokens refill over time"""
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
|
||||
# Create limiter with injectable time
|
||||
fake_time = [0.0]
|
||||
limiter = RateLimiter(tokens_per_minute=60000, burst=1000)
|
||||
limiter._get_time = lambda: fake_time[0]
|
||||
limiter._sleep = lambda x: None # No-op sleep
|
||||
|
||||
# Reset to known state
|
||||
limiter._api_tokens = 0.0
|
||||
limiter._api_tokens_last_update = 0.0
|
||||
|
||||
# Advance time by 1 second (should add 1000 tokens at 60000/min = 1000/sec)
|
||||
fake_time[0] = 1.0
|
||||
limiter._refill_api_tokens()
|
||||
|
||||
assert limiter._api_tokens == 1000.0
|
||||
|
||||
def test_no_cap_on_internal_wait(self):
|
||||
"""Test that internal wait time is NOT capped by max_retry_delay"""
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
|
||||
# Create limiter with low rate
|
||||
limiter = RateLimiter(tokens_per_minute=60, burst=1, max_retry_delay=10)
|
||||
|
||||
# Calculate wait time for 100 tokens (should be ~100 seconds at 1 token/sec)
|
||||
limiter._api_tokens = 0.0
|
||||
wait = limiter._wait_time_for_tokens(100)
|
||||
|
||||
# Wait should NOT be capped to max_retry_delay
|
||||
assert wait > 10 # Should be ~100 seconds
|
||||
|
||||
|
||||
class TestRateLimiterWithLLM:
|
||||
"""Test RateLimiter integration with LLM class."""
|
||||
|
||||
def test_llm_with_rate_limiter(self):
|
||||
"""Test that LLM accepts rate_limiter in extra_settings"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
|
||||
limiter = RateLimiter(requests_per_minute=60)
|
||||
llm = LLM(model="gpt-4o-mini", rate_limiter=limiter)
|
||||
|
||||
assert llm._rate_limiter is limiter
|
||||
|
||||
def test_llm_retry_settings(self):
|
||||
"""Test that LLM accepts retry settings"""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o-mini", max_retries=5, retry_delay=30)
|
||||
|
||||
assert llm._max_retries == 5
|
||||
assert llm._retry_delay == 30
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Test for tokens_per_minute rate limiting functionality.
|
||||
This test verifies the fix for issue #1086.
|
||||
"""
|
||||
import time
|
||||
|
||||
|
||||
class TestTokensPerMinuteRateLimiter:
|
||||
"""Test the enhanced RateLimiter with tokens_per_minute support."""
|
||||
|
||||
def test_rate_limiter_import(self):
|
||||
"""Test that RateLimiter can be imported."""
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
assert RateLimiter is not None
|
||||
|
||||
def test_rate_limiter_requests_per_minute(self):
|
||||
"""Test basic requests_per_minute functionality."""
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
|
||||
limiter = RateLimiter(requests_per_minute=60, burst=2)
|
||||
assert limiter.requests_per_minute == 60
|
||||
assert limiter.burst == 2
|
||||
|
||||
# Should acquire without waiting
|
||||
start = time.time()
|
||||
limiter.acquire()
|
||||
limiter.acquire() # Second within burst
|
||||
elapsed = time.time() - start
|
||||
assert elapsed < 0.5 # Should be nearly instant
|
||||
|
||||
def test_rate_limiter_tokens_per_minute(self):
|
||||
"""Test tokens_per_minute functionality."""
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
|
||||
limiter = RateLimiter(tokens_per_minute=1000000) # 1M tokens/min
|
||||
assert limiter.tokens_per_minute == 1000000
|
||||
|
||||
# Should acquire tokens without waiting
|
||||
start = time.time()
|
||||
limiter.acquire_tokens(1000)
|
||||
elapsed = time.time() - start
|
||||
assert elapsed < 0.5
|
||||
|
||||
def test_rate_limiter_repr(self):
|
||||
"""Test string representation."""
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
|
||||
limiter1 = RateLimiter(requests_per_minute=60)
|
||||
assert 'rpm=60' in repr(limiter1)
|
||||
|
||||
limiter2 = RateLimiter(tokens_per_minute=1000000)
|
||||
assert 'tpm=1000000' in repr(limiter2)
|
||||
|
||||
limiter3 = RateLimiter(requests_per_minute=60, tokens_per_minute=1000000)
|
||||
assert 'rpm=60' in repr(limiter3)
|
||||
assert 'tpm=1000000' in repr(limiter3)
|
||||
|
||||
def test_rate_limiter_available_tokens(self):
|
||||
"""Test available tokens monitoring."""
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
|
||||
limiter = RateLimiter(tokens_per_minute=60000, burst=1000)
|
||||
initial = limiter.available_api_tokens
|
||||
assert initial > 0
|
||||
|
||||
limiter.acquire_tokens(500)
|
||||
after = limiter.available_api_tokens
|
||||
assert after < initial
|
||||
|
||||
def test_llm_rate_limit_parsing(self):
|
||||
"""Test that LLM can parse retry delay from error messages."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
# Test JSON format
|
||||
msg1 = '{"error": {"details": [{"@type": "RetryInfo", "retryDelay": "58s"}]}}'
|
||||
delay1 = llm._parse_retry_delay(msg1)
|
||||
assert delay1 == 58.0
|
||||
|
||||
# Test plain format
|
||||
msg2 = 'retry after 30 seconds'
|
||||
delay2 = llm._parse_retry_delay(msg2)
|
||||
assert delay2 == 30.0
|
||||
|
||||
def test_llm_rate_limit_error_detection(self):
|
||||
"""Test that LLM can detect rate limit errors."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
# Test 429 error detection
|
||||
class RateLimitError(Exception):
|
||||
pass
|
||||
|
||||
e1 = RateLimitError("429 Too Many Requests")
|
||||
assert llm._is_rate_limit_error(e1)
|
||||
|
||||
e2 = Exception("RESOURCE_EXHAUSTED: quota exceeded")
|
||||
assert llm._is_rate_limit_error(e2)
|
||||
|
||||
e3 = Exception("tokens per minute quota exceeded")
|
||||
assert llm._is_rate_limit_error(e3)
|
||||
|
||||
e4 = Exception("Some other error")
|
||||
assert not llm._is_rate_limit_error(e4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "praisonai",
|
||||
"version": "1.3.3",
|
||||
"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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}` }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -489,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 };
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.8.0.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.0.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.2.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -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.8.0 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.8.2 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.8.0"
|
||||
__version__ = "2.8.2"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=0.5.1",
|
||||
"praisonaiagents>=0.5.2",
|
||||
"python-dotenv>=0.19.0",
|
||||
"instructor>=1.3.3",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
Generated
+4
-4
@@ -4118,7 +4118,7 @@ requires-dist = [
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.5.1" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.5.2" },
|
||||
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
|
||||
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
|
||||
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
|
||||
@@ -4161,7 +4161,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -4170,9 +4170,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/49/1534414aac662df69537784350c90f35f62dfd22950274a875d6924a63c3/praisonaiagents-0.5.1.tar.gz", hash = "sha256:f18cd248197e327a5ae595a9e4151bbc6b2030fcf51e188cc495ea869fb6dcc2", size = 774252, upload-time = "2025-12-29T02:13:02.645Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/e5/f0569d57595331894418c250cc40edb158a1476a4c7271448095ef3aa744/praisonaiagents-0.5.2.tar.gz", hash = "sha256:1d4f0616ca6a683b54edc84757ae9decaf14742acfa2398debdde65aef02ae32", size = 780477, upload-time = "2025-12-30T17:47:27.852Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/95/6bc3822fa9396be446155ed7461821a273f02d10fc8e0dfb8186a758f11e/praisonaiagents-0.5.1-py3-none-any.whl", hash = "sha256:cc9c524c7901ce1102a9580b78eb6cc71db6751c8dfe4566c102f4b93ab19a89", size = 721775, upload-time = "2025-12-29T02:13:00.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/7f/9b71b7c67a07e8a3085ab380a3fc2a8d69521543cae285ffb0c7462cebeb/praisonaiagents-0.5.2-py3-none-any.whl", hash = "sha256:a9fc263fe5664e3901ac2cee05e44a3cf0ea9e6fe732da786d89f2e862c8def1", size = 724488, upload-time = "2025-12-30T17:47:25.834Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user