Compare commits

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

These tests verify that actionable error messages with install instructions
are shown when users try to use MCP functionality without the mcp package.
2025-12-30 16:14:15 +00:00
Mervin Praison fff67a96de feat: improve rate limiting with retry wrappers and uncapped token wait times
- Add _completion_with_retry() and _acompletion_with_retry() convenience wrappers
- Remove max_retry_delay cap from internal token wait times to ensure correct rate limiting
- Add max_delay cap (300s default) to _parse_retry_delay() to prevent unbounded sleep from API errors
- Improve retry delay regex patterns (non-greedy matching, HTTP Retry-After header)
- Add documentation notes about large token requests exceeding burst capacity
2025-12-30 16:10:24 +00:00
Mervin Praison e59be8f0fd Merge pull request #1096 from majiayu000/fix/issue-1091-mcp-optional-import
fix: make MCP package import optional to prevent errors when not installed
2025-12-30 16:09:06 +00:00
Mervin Praison b52708f3f6 Release v1.3.4 2025-12-30 15:10:46 +00:00
majiayu000 519ad56e2b feat: add tokens_per_minute rate limiting and 429 error auto-retry
- Enhance RateLimiter class to support tokens_per_minute limiting
- Add acquire_tokens() method for token-based rate limiting
- Add wait_for_retry() methods for handling rate limit delays
- Add _call_with_retry() methods in LLM class for auto-retry on 429 errors
- Parse retry delay from API error responses (e.g., "retryDelay: 58s")
- Add rate_limiter, max_retries, retry_delay settings to LLM class

Closes #1086

Signed-off-by: majiayu000 <[email protected]>
2025-12-30 20:00:05 +08:00
majiayu000 98c5e8f175 fix: make MCP package import optional to prevent errors when not installed
Wrap mcp imports in try/except blocks in mcp.py, mcp_sse.py, and
mcp_http_stream.py. Add MCP_AVAILABLE flag to check availability at runtime.
When MCP class is instantiated without the mcp package installed, raise a
clear ImportError with installation instructions.

Closes #1091

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

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.8.0" \
"praisonai>=2.8.2" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.8.0" \
"praisonai>=2.8.2" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.8.0" \
"praisonai>=2.8.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+65
View File
@@ -0,0 +1,65 @@
/**
* Basic Embedding Example
*
* Demonstrates using Agent.embed() for text embeddings
*
* Run: npx ts-node examples/js/embeddings/embed-basic.ts
*/
import { Agent } from '../../../src/praisonai-ts/dist/agent/simple';
import { embed, embedMany, cosineSimilarity } from '../../../src/praisonai-ts/dist/llm/embeddings';
async function main() {
console.log('=== Basic Embedding Example ===\n');
// Method 1: Using Agent.embed()
console.log('1. Agent.embed() - Single text');
const agent = new Agent({
instructions: 'You are a helpful assistant',
llm: 'openai/gpt-4o-mini',
verbose: false
});
const singleEmbedding = await agent.embed('Hello world');
console.log(` Dimensions: ${(singleEmbedding as number[]).length}`);
console.log(` First 5 values: [${(singleEmbedding as number[]).slice(0, 5).map(v => v.toFixed(4)).join(', ')}...]`);
// Method 2: Using Agent.embed() with multiple texts
console.log('\n2. Agent.embed() - Multiple texts');
const multiEmbeddings = await agent.embed(['Hello', 'World', 'How are you?']);
console.log(` Count: ${(multiEmbeddings as number[][]).length}`);
console.log(` Each has ${(multiEmbeddings as number[][])[0].length} dimensions`);
// Method 3: Direct embed function
console.log('\n3. Direct embed() function');
const result = await embed('PraisonAI is an AI agent framework');
console.log(` Dimensions: ${result.embedding.length}`);
console.log(` Tokens used: ${result.usage?.tokens || 'N/A'}`);
// Method 4: Batch embeddings
console.log('\n4. Batch embedMany() function');
const texts = [
'Machine learning is a subset of AI',
'Deep learning uses neural networks',
'Natural language processing handles text'
];
const batchResult = await embedMany(texts);
console.log(` Embedded ${batchResult.embeddings.length} texts`);
console.log(` Total tokens: ${batchResult.usage?.tokens || 'N/A'}`);
// Method 5: Similarity comparison
console.log('\n5. Cosine Similarity');
const emb1 = await embed('I love programming');
const emb2 = await embed('Coding is my passion');
const emb3 = await embed('The weather is nice today');
const sim12 = cosineSimilarity(emb1.embedding, emb2.embedding);
const sim13 = cosineSimilarity(emb1.embedding, emb3.embedding);
console.log(` "I love programming" vs "Coding is my passion": ${(sim12 * 100).toFixed(1)}%`);
console.log(` "I love programming" vs "The weather is nice": ${(sim13 * 100).toFixed(1)}%`);
console.log('\n=== Complete ===');
}
main().catch(console.error);
+119
View File
@@ -0,0 +1,119 @@
/**
* Document Embedding Example
*
* Demonstrates chunking and embedding documents for retrieval
*
* Run: npx ts-node examples/js/embeddings/embed-docs.ts
*/
import { embedMany, cosineSimilarity } from '../../../src/praisonai-ts/dist/llm/embeddings';
// Sample documents
const documents = [
{
id: 'doc1',
title: 'Introduction to AI',
content: 'Artificial Intelligence (AI) is the simulation of human intelligence by machines. It includes machine learning, natural language processing, and computer vision.'
},
{
id: 'doc2',
title: 'Machine Learning Basics',
content: 'Machine learning is a subset of AI that enables systems to learn from data. It includes supervised learning, unsupervised learning, and reinforcement learning.'
},
{
id: 'doc3',
title: 'Deep Learning',
content: 'Deep learning uses neural networks with multiple layers. It excels at tasks like image recognition, speech processing, and natural language understanding.'
},
{
id: 'doc4',
title: 'Natural Language Processing',
content: 'NLP enables computers to understand human language. Applications include chatbots, translation, sentiment analysis, and text summarization.'
},
{
id: 'doc5',
title: 'Computer Vision',
content: 'Computer vision allows machines to interpret visual information. It powers applications like facial recognition, autonomous vehicles, and medical imaging.'
}
];
interface EmbeddedDocument {
id: string;
title: string;
content: string;
embedding: number[];
}
async function main() {
console.log('=== Document Embedding Example ===\n');
// Step 1: Embed all documents
console.log('1. Embedding documents...');
const contents = documents.map(d => d.content);
const { embeddings } = await embedMany(contents);
const embeddedDocs: EmbeddedDocument[] = documents.map((doc, i) => ({
...doc,
embedding: embeddings[i]
}));
console.log(` Embedded ${embeddedDocs.length} documents`);
console.log(` Embedding dimensions: ${embeddings[0].length}`);
// Step 2: Search function
async function search(query: string, topK: number = 3): Promise<Array<{ doc: EmbeddedDocument; score: number }>> {
const { embedding: queryEmbedding } = await (await import('../../../src/praisonai-ts/dist/llm/embeddings')).embed(query);
const results = embeddedDocs.map(doc => ({
doc,
score: cosineSimilarity(queryEmbedding, doc.embedding)
}));
return results
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
// Step 3: Test queries
console.log('\n2. Testing search queries...\n');
const queries = [
'How do machines learn from data?',
'What is image recognition?',
'Tell me about chatbots and translation'
];
for (const query of queries) {
console.log(`Query: "${query}"`);
const results = await search(query);
console.log('Top results:');
results.forEach((r, i) => {
console.log(` ${i + 1}. [${(r.score * 100).toFixed(1)}%] ${r.doc.title}`);
});
console.log('');
}
// Step 4: Show document similarity matrix
console.log('3. Document Similarity Matrix');
console.log('');
// Header
process.stdout.write(' ');
documents.forEach((_, i) => process.stdout.write(`Doc${i + 1} `));
console.log('');
// Matrix
for (let i = 0; i < embeddedDocs.length; i++) {
process.stdout.write(`Doc${i + 1} `);
for (let j = 0; j < embeddedDocs.length; j++) {
const sim = cosineSimilarity(embeddedDocs[i].embedding, embeddedDocs[j].embedding);
process.stdout.write(`${(sim * 100).toFixed(0).padStart(3)}% `);
}
console.log('');
}
console.log('\n=== Complete ===');
}
main().catch(console.error);
+131
View File
@@ -0,0 +1,131 @@
/**
* Retrieval Example - Simple Vector Search
*
* Demonstrates building a simple retrieval system with embeddings
*
* Run: npx ts-node examples/js/embeddings/retrieval.ts
*/
import { embed, embedMany, cosineSimilarity } from '../../../src/praisonai-ts/dist/llm/embeddings';
interface Document {
id: string;
content: string;
embedding?: number[];
}
interface SearchResult {
document: Document;
score: number;
}
// Simple in-memory vector store
class SimpleVectorStore {
private documents: Document[] = [];
async add(doc: Document): Promise<void> {
const { embedding } = await embed(doc.content);
this.documents.push({ ...doc, embedding });
}
async addBatch(docs: Document[]): Promise<void> {
const contents = docs.map(d => d.content);
const { embeddings } = await embedMany(contents);
docs.forEach((doc, i) => {
this.documents.push({ ...doc, embedding: embeddings[i] });
});
}
async search(query: string, topK: number = 3): Promise<SearchResult[]> {
const { embedding: queryEmb } = await embed(query);
const results = this.documents
.filter(doc => doc.embedding)
.map(doc => ({
document: doc,
score: cosineSimilarity(queryEmb, doc.embedding!)
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
return results;
}
get(id: string): Document | undefined {
return this.documents.find(d => d.id === id);
}
delete(id: string): boolean {
const idx = this.documents.findIndex(d => d.id === id);
if (idx >= 0) {
this.documents.splice(idx, 1);
return true;
}
return false;
}
}
async function main() {
console.log('=== Simple Vector Retrieval ===\n');
// Create vector store
console.log('1. Creating vector store...');
const store = new SimpleVectorStore();
// Add documents
console.log('2. Adding documents...');
const docs: Document[] = [
{ id: '1', content: 'PraisonAI is an AI agent framework for building intelligent applications.' },
{ id: '2', content: 'Agents can use tools to interact with external systems and APIs.' },
{ id: '3', content: 'Workflows allow orchestrating multiple agents in sequence or parallel.' },
{ id: '4', content: 'Memory systems help agents maintain context across conversations.' },
{ id: '5', content: 'The AI SDK provides a unified interface for multiple LLM providers.' },
{ id: '6', content: 'Embeddings enable semantic search and similarity matching.' },
{ id: '7', content: 'Guardrails ensure agent outputs meet safety and quality standards.' },
{ id: '8', content: 'Sessions persist conversation state for multi-turn interactions.' }
];
await store.addBatch(docs);
console.log(` Added ${docs.length} documents`);
// Search queries
console.log('\n3. Running search queries...\n');
const queries = [
'How do I build an AI application?',
'Can agents call external APIs?',
'How to maintain conversation context?',
'What providers are supported?'
];
for (const query of queries) {
console.log(`Query: "${query}"`);
const results = await store.search(query);
if (results.length > 0) {
console.log('Results:');
results.forEach((r, i) => {
console.log(` ${i + 1}. [${(r.score * 100).toFixed(1)}%] ${r.document.content.substring(0, 60)}...`);
});
} else {
console.log(' No results found');
}
console.log('');
}
// Get specific document
console.log('4. Get document by ID...');
const doc = store.get('5');
if (doc) {
console.log(` Found: ${doc.content}`);
}
// Delete document
console.log('\n5. Delete document...');
const deleted = store.delete('8');
console.log(` Deleted doc 8: ${deleted}`);
console.log('\n=== Complete ===');
}
main().catch(console.error);
+199 -5
View File
@@ -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)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.5.1"
version = "0.5.2"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,149 @@
"""
Test that MCP module imports are optional and don't break basic functionality.
This test verifies the fix for issue #1091.
"""
import pytest
class TestMCPOptionalImport:
"""Test that MCP is properly optional."""
def test_praisonaiagents_import_without_mcp(self):
"""Test that praisonaiagents can be imported without mcp installed."""
# This should not raise any ImportError
import praisonaiagents
assert praisonaiagents is not None
def test_agent_import_without_mcp(self):
"""Test that Agent can be imported without mcp installed."""
from praisonaiagents import Agent
assert Agent is not None
def test_mcp_lazy_loading(self):
"""Test that MCP is lazily loaded."""
from praisonaiagents import MCP # noqa: F401
# MCP should either be available or None (not raise ImportError on access)
# The actual ImportError should only be raised when trying to instantiate
# if mcp package is not installed
def test_mcp_module_import_no_crash(self):
"""Test that mcp module can be imported without crashing."""
# This tests that the try/except in mcp.py works correctly
try:
from praisonaiagents.mcp import mcp as mcp_module
assert hasattr(mcp_module, 'MCP_AVAILABLE')
except ImportError:
# If import fails, it should be because of missing mcp,
# not because of a syntax error
pass
def test_mcp_sse_module_import_no_crash(self):
"""Test that mcp_sse module can be imported without crashing."""
try:
from praisonaiagents.mcp import mcp_sse
assert hasattr(mcp_sse, 'MCP_AVAILABLE')
except ImportError:
pass
def test_mcp_http_stream_module_import_no_crash(self):
"""Test that mcp_http_stream module can be imported without crashing."""
try:
from praisonaiagents.mcp import mcp_http_stream
assert hasattr(mcp_http_stream, 'MCP_AVAILABLE')
except ImportError:
pass
def test_basic_agent_creation_without_mcp_tools(self):
"""Test that agents can be created without MCP tools."""
from praisonaiagents import Agent
# Creating an agent without MCP tools should work
agent = Agent(
name="Test Agent",
instructions="You are a test agent.",
llm="gpt-4o-mini"
)
assert agent is not None
def test_mcp_raises_import_error_when_unavailable(self):
"""Test that MCP raises ImportError with install instructions when mcp package unavailable."""
import praisonaiagents.mcp.mcp as mcp_module
# Save original value
original_available = mcp_module.MCP_AVAILABLE
# Simulate MCP not installed
mcp_module.MCP_AVAILABLE = False
try:
from praisonaiagents.mcp.mcp import MCP
with pytest.raises(ImportError) as exc_info:
MCP('test-command')
error_msg = str(exc_info.value)
assert 'pip install praisonaiagents[mcp]' in error_msg
assert 'MCP' in error_msg or 'Model Context Protocol' in error_msg
finally:
# Restore original value
mcp_module.MCP_AVAILABLE = original_available
def test_sse_client_raises_import_error_when_unavailable(self):
"""Test that SSEMCPClient raises ImportError with install instructions when mcp unavailable."""
import praisonaiagents.mcp.mcp_sse as sse_module
original_available = sse_module.MCP_AVAILABLE
sse_module.MCP_AVAILABLE = False
try:
from praisonaiagents.mcp.mcp_sse import SSEMCPClient
with pytest.raises(ImportError) as exc_info:
SSEMCPClient('http://test.com/sse')
error_msg = str(exc_info.value)
assert 'pip install praisonaiagents[mcp]' in error_msg
finally:
sse_module.MCP_AVAILABLE = original_available
def test_http_stream_client_raises_import_error_when_mcp_unavailable(self):
"""Test that HTTPStreamMCPClient raises ImportError when mcp unavailable."""
import praisonaiagents.mcp.mcp_http_stream as http_module
original_available = http_module.MCP_AVAILABLE
http_module.MCP_AVAILABLE = False
try:
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamMCPClient
with pytest.raises(ImportError) as exc_info:
HTTPStreamMCPClient('http://test.com/mcp')
error_msg = str(exc_info.value)
assert 'pip install praisonaiagents[mcp]' in error_msg
finally:
http_module.MCP_AVAILABLE = original_available
def test_http_stream_client_raises_import_error_when_aiohttp_unavailable(self):
"""Test that HTTPStreamMCPClient raises ImportError when aiohttp unavailable."""
import praisonaiagents.mcp.mcp_http_stream as http_module
# Ensure MCP is available but aiohttp is not
original_available = http_module.MCP_AVAILABLE
original_aiohttp = http_module.aiohttp
http_module.MCP_AVAILABLE = True
http_module.aiohttp = None
try:
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamMCPClient
with pytest.raises(ImportError) as exc_info:
HTTPStreamMCPClient('http://test.com/mcp')
error_msg = str(exc_info.value)
assert 'aiohttp' in error_msg.lower()
assert 'pip install praisonaiagents[mcp]' in error_msg
finally:
http_module.MCP_AVAILABLE = original_available
http_module.aiohttp = original_aiohttp
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,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"])
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.5.1"
version = "0.5.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -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",
+29 -4
View File
@@ -2,7 +2,8 @@
* Context Agent - Agent with enhanced context management
*/
import { createProvider, type LLMProvider, type Message } from '../llm/providers';
import { type LLMProvider, type Message } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
import { Session } from '../session';
import { KnowledgeBase, type SearchResult } from '../knowledge/rag';
@@ -30,7 +31,9 @@ export class ContextAgent {
readonly name: string;
readonly instructions: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private knowledgeBase?: KnowledgeBase;
private contextWindow: number;
private maxContextTokens: number;
@@ -40,13 +43,34 @@ export class ContextAgent {
constructor(config: ContextAgentConfig) {
this.name = config.name || `ContextAgent_${Math.random().toString(36).substr(2, 9)}`;
this.instructions = config.instructions;
this.provider = createProvider(config.llm || 'gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.knowledgeBase = config.knowledgeBase;
this.contextWindow = config.contextWindow ?? 10;
this.maxContextTokens = config.maxContextTokens ?? 4000;
this.verbose = config.verbose ?? false;
}
/**
* Get the LLM provider (lazy initialization with AI SDK backend)
*/
private async getProvider(): Promise<LLMProvider> {
if (this.provider) {
return this.provider;
}
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, {
attribution: { agentId: this.name },
});
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Chat with context awareness
*/
@@ -83,7 +107,8 @@ export class ContextAgent {
];
// Generate response
const result = await this.provider.generateText({ messages });
const provider = await this.getProvider();
const result = await provider.generateText({ messages });
// Add assistant message
this.messages.push({
+37 -7
View File
@@ -2,7 +2,8 @@
* Enhanced Agent - Agent with session management, provider abstraction, and tool support
*/
import { createProvider, type LLMProvider, type Message, type ToolCall, type GenerateTextResult } from '../llm/providers';
import { type LLMProvider, type Message, type ToolCall, type GenerateTextResult } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
import { Session, Run, Trace, getSessionManager } from '../session';
import { FunctionTool, ToolRegistry, tool } from '../tools/decorator';
import { Logger } from '../utils/logger';
@@ -51,7 +52,9 @@ export class EnhancedAgent {
readonly instructions: string;
readonly sessionId: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private session: Session;
private toolRegistry: ToolRegistry;
private verbose: boolean;
@@ -71,8 +74,8 @@ export class EnhancedAgent {
this.maxTokens = config.maxTokens;
this.outputSchema = config.outputSchema;
// Initialize provider
this.provider = createProvider(config.llm || 'gpt-4o-mini');
// Store model string for lazy provider initialization
this.llmModel = config.llm || 'openai/gpt-4o-mini';
// Initialize session
if (config.session) {
@@ -93,6 +96,30 @@ export class EnhancedAgent {
Logger.setVerbose(this.verbose);
}
/**
* Get the LLM provider (lazy initialization with AI SDK backend)
*/
private async getProvider(): Promise<LLMProvider> {
if (this.provider) {
return this.provider;
}
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, {
attribution: {
agentId: this.name,
sessionId: this.sessionId,
},
});
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
private registerTools(tools: EnhancedAgentConfig['tools']): void {
if (!tools) return;
@@ -176,7 +203,8 @@ export class EnhancedAgent {
if (options.stream && !tools && options.onToken) {
// Streaming without tools
const stream = await this.provider.streamText({
const provider = await this.getProvider();
const stream = await provider.streamText({
messages,
temperature: options.temperature ?? this.temperature,
maxTokens: options.maxTokens ?? this.maxTokens,
@@ -197,7 +225,8 @@ export class EnhancedAgent {
break;
} else if (options.outputSchema) {
// Structured output
const objResult = await this.provider.generateObject({
const provider = await this.getProvider();
const objResult = await provider.generateObject({
messages,
schema: options.outputSchema ?? this.outputSchema,
temperature: options.temperature ?? this.temperature,
@@ -213,7 +242,8 @@ export class EnhancedAgent {
break;
} else {
// Regular generation with potential tool calls
result = await this.provider.generateText({
const provider = await this.getProvider();
result = await provider.generateText({
messages,
temperature: options.temperature ?? this.temperature,
maxTokens: options.maxTokens ?? this.maxTokens,
+33 -6
View File
@@ -2,7 +2,8 @@
* ImageAgent - Agent for image generation and analysis
*/
import { createProvider, type LLMProvider, type Message } from '../llm/providers';
import { type LLMProvider, type Message } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export interface ImageGenerationConfig {
prompt: string;
@@ -30,17 +31,40 @@ export interface ImageAgentConfig {
*/
export class ImageAgent {
readonly name: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private imageModel: string;
private verbose: boolean;
constructor(config: ImageAgentConfig = {}) {
this.name = config.name || `ImageAgent_${Math.random().toString(36).substr(2, 9)}`;
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.imageModel = config.imageModel || 'dall-e-3';
this.verbose = config.verbose ?? false;
}
/**
* Get the LLM provider (lazy initialization with AI SDK backend)
*/
private async getProvider(): Promise<LLMProvider> {
if (this.provider) {
return this.provider;
}
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, {
attribution: { agentId: this.name },
});
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Analyze an image
*/
@@ -63,7 +87,8 @@ export class ImageAgent {
}
];
const result = await this.provider.generateText({ messages });
const provider = await this.getProvider();
const result = await provider.generateText({ messages });
if (this.verbose) {
console.log(`[ImageAgent] Analysis: ${result.text.substring(0, 100)}...`);
@@ -93,7 +118,8 @@ export class ImageAgent {
return this.analyze({ imageUrl, prompt });
}
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [{ role: 'user', content: prompt }]
});
@@ -117,7 +143,8 @@ export class ImageAgent {
}
];
const result = await this.provider.generateText({ messages });
const provider = await this.getProvider();
const result = await provider.generateText({ messages });
return result.text;
}
}
+20 -4
View File
@@ -2,7 +2,8 @@
* PromptExpanderAgent - Expand and enhance prompts
*/
import { createProvider, type LLMProvider } from '../llm/providers';
import { type LLMProvider } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export type ExpandStrategy = 'detail' | 'context' | 'examples' | 'constraints' | 'auto';
@@ -25,17 +26,31 @@ export interface PromptExpanderConfig {
*/
export class PromptExpanderAgent {
readonly name: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private defaultStrategy: ExpandStrategy;
private verbose: boolean;
constructor(config: PromptExpanderConfig = {}) {
this.name = config.name || `PromptExpander_${Math.random().toString(36).substr(2, 9)}`;
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.defaultStrategy = config.defaultStrategy || 'auto';
this.verbose = config.verbose ?? false;
}
private async getProvider(): Promise<LLMProvider> {
if (this.provider) return this.provider;
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, { attribution: { agentId: this.name } });
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Expand a prompt
*/
@@ -45,7 +60,8 @@ export class PromptExpanderAgent {
const systemPrompt = this.getSystemPrompt(actualStrategy);
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Expand this prompt: ${prompt}` }
+20 -4
View File
@@ -2,7 +2,8 @@
* QueryRewriterAgent - Rewrite and optimize queries
*/
import { createProvider, type LLMProvider } from '../llm/providers';
import { type LLMProvider } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export type RewriteStrategy = 'expand' | 'simplify' | 'decompose' | 'rephrase' | 'auto';
@@ -25,17 +26,31 @@ export interface QueryRewriterConfig {
*/
export class QueryRewriterAgent {
readonly name: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private defaultStrategy: RewriteStrategy;
private verbose: boolean;
constructor(config: QueryRewriterConfig = {}) {
this.name = config.name || `QueryRewriter_${Math.random().toString(36).substr(2, 9)}`;
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.defaultStrategy = config.defaultStrategy || 'auto';
this.verbose = config.verbose ?? false;
}
private async getProvider(): Promise<LLMProvider> {
if (this.provider) return this.provider;
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, { attribution: { agentId: this.name } });
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Rewrite a query
*/
@@ -45,7 +60,8 @@ export class QueryRewriterAgent {
const prompt = this.buildPrompt(query, actualStrategy);
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{ role: 'system', content: this.getSystemPrompt(actualStrategy) },
{ role: 'user', content: prompt }
+22 -5
View File
@@ -2,7 +2,8 @@
* DeepResearchAgent - Agent for comprehensive research tasks
*/
import { createProvider, type LLMProvider, type Message } from '../llm/providers';
import { type LLMProvider, type Message } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export interface Citation {
title: string;
@@ -37,19 +38,33 @@ export interface DeepResearchConfig {
*/
export class DeepResearchAgent {
readonly name: string;
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private maxIterations: number;
private searchTool?: (query: string) => Promise<Citation[]>;
private verbose: boolean;
constructor(config: DeepResearchConfig = {}) {
this.name = config.name || `ResearchAgent_${Math.random().toString(36).substr(2, 9)}`;
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.maxIterations = config.maxIterations ?? 5;
this.searchTool = config.searchTool;
this.verbose = config.verbose ?? false;
}
private async getProvider(): Promise<LLMProvider> {
if (this.provider) return this.provider;
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, { attribution: { agentId: this.name } });
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Conduct deep research on a topic
*/
@@ -115,7 +130,8 @@ export class DeepResearchAgent {
}
private async generateSearchQueries(query: string): Promise<string[]> {
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{
role: 'system',
@@ -133,7 +149,8 @@ export class DeepResearchAgent {
? `\n\nRelevant sources:\n${citations.map(c => `- ${c.title}: ${c.snippet || ''}`).join('\n')}`
: '';
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{
role: 'system',
+123 -5
View File
@@ -3,6 +3,8 @@ import { Logger } from '../utils/logger';
import type { ChatCompletionTool } from 'openai/resources/chat/completions';
import type { DbAdapter, DbMessage, DbRun } from '../db/types';
import { randomUUID } from 'crypto';
import type { LLMProvider } from '../llm/providers/types';
import type { BackendResolutionResult } from '../llm/backend-resolver';
/**
* Agent Configuration
@@ -116,6 +118,12 @@ export class Agent {
private cacheTTL: number;
private responseCache: Map<string, { response: string; timestamp: number }> = new Map();
private telemetryEnabled: boolean;
// AI SDK backend support
private _backend: LLMProvider | null = null;
private _backendPromise: Promise<BackendResolutionResult> | null = null;
private _backendSource: 'ai-sdk' | 'native' | 'custom' | 'legacy' = 'legacy';
private _useAISDKBackend: boolean = false;
constructor(config: SimpleAgentConfig) {
// Build instructions from either simple or advanced mode
@@ -148,7 +156,16 @@ export class Agent {
this.cache = config.cache ?? false;
this.cacheTTL = config.cacheTTL ?? 3600;
this.telemetryEnabled = config.telemetry ?? false;
this.llmService = new OpenAIService(this.llm);
// Parse model string to extract provider and model ID
// Format: "provider/model" or just "model"
const providerId = this.llm.includes('/') ? this.llm.split('/')[0] : 'openai';
const modelId = this.llm.includes('/') ? this.llm.split('/').slice(1).join('/') : this.llm;
// For OpenAI, use OpenAIService directly for backward compatibility
// For other providers, we'll use the AI SDK backend via getBackend()
this._useAISDKBackend = providerId !== 'openai';
this.llmService = new OpenAIService(modelId);
// Configure logging
Logger.setVerbose(this.verbose);
@@ -207,13 +224,14 @@ export class Agent {
}
/**
* Generate a session ID based on current hour and agent name (like Python SDK)
* Generate a unique session ID based on current hour, agent name, and random suffix
*/
private generateSessionId(): string {
const now = new Date();
const hourStr = now.toISOString().slice(0, 13).replace(/[-T:]/g, '');
const hash = this.name ? this.name.slice(0, 6) : 'agent';
return `${hourStr}-${hash}`;
const randomSuffix = randomUUID().slice(0, 8);
return `${hourStr}-${hash}-${randomSuffix}`;
}
/**
@@ -432,8 +450,35 @@ export class Agent {
let finalResponse = '';
if (this.stream && !this.tools) {
// Use streaming with full conversation history
// Use AI SDK backend for non-OpenAI providers
if (this._useAISDKBackend) {
const backend = await this.getBackend();
if (this.stream && !this.tools) {
// Streaming with AI SDK backend
const stream = await backend.streamText({
messages,
temperature: 0.7
});
let accumulated = '';
for await (const chunk of stream) {
if (chunk.text) {
process.stdout.write(chunk.text);
accumulated += chunk.text;
}
}
finalResponse = accumulated;
} else {
// Non-streaming with AI SDK backend
const result = await backend.generateText({
messages,
temperature: 0.7
});
finalResponse = result.text;
}
} else if (this.stream && !this.tools) {
// Use streaming with full conversation history (OpenAI)
finalResponse = await this.llmService.streamChat(
messages,
0.7,
@@ -606,6 +651,79 @@ export class Agent {
clearCache(): void {
this.responseCache.clear();
}
/**
* Get the resolved backend (AI SDK preferred, native fallback)
* Lazy initialization - backend is only resolved on first use
*/
async getBackend(): Promise<LLMProvider> {
if (this._backend) {
return this._backend;
}
if (!this._backendPromise) {
this._backendPromise = (async () => {
const { resolveBackend } = await import('../llm/backend-resolver');
const result = await resolveBackend(this.llm, {
attribution: {
agentId: this.name,
runId: this.runId,
sessionId: this.sessionId,
},
});
this._backend = result.provider;
this._backendSource = result.source;
Logger.debug(`Agent ${this.name} using ${result.source} backend for ${this.llm}`);
return result;
})();
}
const result = await this._backendPromise;
return result.provider;
}
/**
* Get the backend source (ai-sdk, native, custom, or legacy)
*/
getBackendSource(): 'ai-sdk' | 'native' | 'custom' | 'legacy' {
return this._backendSource;
}
/**
* Embed text using AI SDK (preferred) or native provider
*
* @param text - Text to embed (string or array of strings)
* @param options - Embedding options
* @returns Embedding vector(s)
*
* @example Single text
* ```typescript
* const embedding = await agent.embed("Hello world");
* ```
*
* @example Multiple texts
* ```typescript
* const embeddings = await agent.embed(["Hello", "World"]);
* ```
*/
async embed(text: string | string[], options?: { model?: string }): Promise<number[] | number[][]> {
const { embed, embedMany } = await import('../llm/embeddings');
if (Array.isArray(text)) {
const result = await embedMany(text, { model: options?.model });
return result.embeddings;
} else {
const result = await embed(text, { model: options?.model });
return result.embedding;
}
}
/**
* Get the model string for this agent
*/
getModel(): string {
return this.llm;
}
}
/**
+29 -5
View File
@@ -2,7 +2,8 @@
* AutoAgents - Automatic agent generation from task descriptions
*/
import { createProvider, type LLMProvider, type Message } from '../llm/providers';
import { type LLMProvider, type Message } from '../llm/providers';
import { resolveBackend } from '../llm/backend-resolver';
export interface AgentConfig {
name: string;
@@ -36,25 +37,48 @@ export interface AutoAgentsConfig {
* AutoAgents - Generate agent configurations from task descriptions
*/
export class AutoAgents {
private provider: LLMProvider;
private provider: LLMProvider | null = null;
private providerPromise: Promise<LLMProvider> | null = null;
private llmModel: string;
private pattern: string;
private singleAgent: boolean;
private verbose: boolean;
constructor(config: AutoAgentsConfig = {}) {
this.provider = createProvider(config.llm || 'openai/gpt-4o-mini');
this.llmModel = config.llm || 'openai/gpt-4o-mini';
this.pattern = config.pattern || 'sequential';
this.singleAgent = config.singleAgent ?? false;
this.verbose = config.verbose ?? false;
}
/**
* Get the LLM provider (lazy initialization with AI SDK backend)
*/
private async getProvider(): Promise<LLMProvider> {
if (this.provider) {
return this.provider;
}
if (!this.providerPromise) {
this.providerPromise = (async () => {
const result = await resolveBackend(this.llmModel, {
attribution: { agentId: 'AutoAgents' },
});
this.provider = result.provider;
return result.provider;
})();
}
return this.providerPromise;
}
/**
* Generate agent configuration from task description
*/
async generate(taskDescription: string): Promise<TeamStructure> {
const prompt = this.buildPrompt(taskDescription);
const result = await this.provider.generateText({
const provider = await this.getProvider();
const result = await provider.generateText({
messages: [
{ role: 'system', content: this.getSystemPrompt() },
{ role: 'user', content: prompt }
@@ -0,0 +1,551 @@
/**
* Benchmark Command - Performance benchmarks for AI SDK vs Native backends
*
* Measures:
* - Import time (cold start)
* - Memory usage
* - First-call latency
* - Streaming throughput
* - Embedding throughput
*/
import { outputJson, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { ERROR_CODES } from '../output/errors';
export interface BenchmarkOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
iterations?: number;
backend?: 'ai-sdk' | 'native' | 'both';
real?: boolean;
}
interface BenchmarkResult {
name: string;
iterations: number;
mean: number;
min: number;
max: number;
p95: number;
stdDev: number;
unit: string;
}
interface BenchmarkReport {
timestamp: string;
results: BenchmarkResult[];
comparison?: {
aiSdk: BenchmarkResult;
native: BenchmarkResult;
difference: number;
winner: string;
}[];
}
const EXIT_CODES = {
SUCCESS: 0,
GENERAL_ERROR: 1,
INVALID_ARGS: 2,
} as const;
function formatBenchmarkSuccess(data: any, meta?: any): { success: true; data: any; meta?: any } {
return {
success: true as const,
data,
meta,
};
}
function calculateStats(values: number[]): { mean: number; min: number; max: number; p95: number; stdDev: number } {
if (values.length === 0) return { mean: 0, min: 0, max: 0, p95: 0, stdDev: 0 };
const sorted = [...values].sort((a, b) => a - b);
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const min = sorted[0];
const max = sorted[sorted.length - 1];
const p95Index = Math.floor(sorted.length * 0.95);
const p95 = sorted[p95Index] || max;
const squaredDiffs = values.map(v => Math.pow(v - mean, 2));
const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / values.length;
const stdDev = Math.sqrt(avgSquaredDiff);
return { mean, min, max, p95, stdDev };
}
export async function execute(args: string[], options: BenchmarkOptions): Promise<void> {
const subcommand = args[0] || 'run';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
switch (subcommand) {
case 'run':
await runAllBenchmarks(args.slice(1), options, outputFormat);
break;
case 'import':
await importBenchmark(args.slice(1), options, outputFormat);
break;
case 'memory':
await memoryBenchmark(args.slice(1), options, outputFormat);
break;
case 'latency':
await latencyBenchmark(args.slice(1), options, outputFormat);
break;
case 'streaming':
await streamingBenchmark(args.slice(1), options, outputFormat);
break;
case 'embedding':
await embeddingBenchmark(args.slice(1), options, outputFormat);
break;
default:
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown subcommand: ${subcommand}`));
} else {
await pretty.error(`Unknown subcommand: ${subcommand}`);
await pretty.info('Available subcommands: run, import, memory, latency, streaming, embedding');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
}
/**
* Run all benchmarks
*/
async function runAllBenchmarks(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 5;
const results: BenchmarkResult[] = [];
if (outputFormat !== 'json') {
await pretty.success('Running All Benchmarks');
console.log(`Iterations: ${iterations}`);
console.log('');
}
// Import benchmark
const importResult = await measureImportTime(iterations);
results.push(importResult);
// Memory benchmark
const memoryResult = await measureMemory(iterations);
results.push(memoryResult);
// Latency benchmark (mock mode unless --real)
if (options.real) {
const latencyResult = await measureLatency(iterations, true);
results.push(latencyResult);
} else {
const latencyResult = await measureLatency(iterations, false);
results.push(latencyResult);
}
const report: BenchmarkReport = {
timestamp: new Date().toISOString(),
results,
};
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess(report));
} else {
printBenchmarkTable(results);
console.log('');
await pretty.info('Use --real flag to run benchmarks with real API calls');
}
}
/**
* Benchmark import time
*/
async function importBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 10;
if (outputFormat !== 'json') {
await pretty.success('Import Time Benchmark');
console.log(`Iterations: ${iterations}`);
console.log('');
}
const results: BenchmarkResult[] = [];
// Measure core import (without AI SDK)
const coreResult = await measureCoreImportTime(iterations);
results.push(coreResult);
// Measure AI SDK import
const aiSdkResult = await measureAISDKImportTime(iterations);
results.push(aiSdkResult);
// Measure full import (with AI SDK loaded)
const fullResult = await measureImportTime(iterations);
results.push(fullResult);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ results }));
} else {
printBenchmarkTable(results);
console.log('');
// Show comparison
const overhead = aiSdkResult.mean;
console.log(`AI SDK import overhead: ${overhead.toFixed(2)}ms`);
console.log(`Core import (no AI SDK): ${coreResult.mean.toFixed(2)}ms`);
}
}
/**
* Benchmark memory usage
*/
async function memoryBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 5;
if (outputFormat !== 'json') {
await pretty.success('Memory Usage Benchmark');
console.log(`Iterations: ${iterations}`);
console.log('');
}
const result = await measureMemory(iterations);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ result }));
} else {
printBenchmarkTable([result]);
}
}
/**
* Benchmark first-call latency
*/
async function latencyBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 3;
const useReal = options.real || false;
if (outputFormat !== 'json') {
await pretty.success('First-Call Latency Benchmark');
console.log(`Iterations: ${iterations}`);
console.log(`Mode: ${useReal ? 'Real API' : 'Mock'}`);
console.log('');
}
const result = await measureLatency(iterations, useReal);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ result }));
} else {
printBenchmarkTable([result]);
if (!useReal) {
console.log('');
await pretty.info('Use --real flag to measure with real API calls');
}
}
}
/**
* Benchmark streaming throughput
*/
async function streamingBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 3;
const useReal = options.real || false;
if (outputFormat !== 'json') {
await pretty.success('Streaming Throughput Benchmark');
console.log(`Iterations: ${iterations}`);
console.log(`Mode: ${useReal ? 'Real API' : 'Mock'}`);
console.log('');
}
if (!useReal) {
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({
message: 'Streaming benchmark requires --real flag',
result: null
}));
} else {
await pretty.info('Streaming benchmark requires --real flag for meaningful results');
}
return;
}
const result = await measureStreaming(iterations);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ result }));
} else {
printBenchmarkTable([result]);
}
}
/**
* Benchmark embedding throughput
*/
async function embeddingBenchmark(args: string[], options: BenchmarkOptions, outputFormat: string): Promise<void> {
const iterations = options.iterations || 3;
const useReal = options.real || false;
if (outputFormat !== 'json') {
await pretty.success('Embedding Throughput Benchmark');
console.log(`Iterations: ${iterations}`);
console.log(`Mode: ${useReal ? 'Real API' : 'Mock'}`);
console.log('');
}
if (!useReal) {
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({
message: 'Embedding benchmark requires --real flag',
result: null
}));
} else {
await pretty.info('Embedding benchmark requires --real flag for meaningful results');
}
return;
}
const result = await measureEmbedding(iterations);
if (outputFormat === 'json') {
outputJson(formatBenchmarkSuccess({ result }));
} else {
printBenchmarkTable([result]);
}
}
// Measurement functions
async function measureCoreImportTime(iterations: number): Promise<BenchmarkResult> {
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
// Clear require cache for accurate measurement
const cacheKeys = Object.keys(require.cache).filter(k =>
k.includes('praisonai-ts') && !k.includes('node_modules')
);
cacheKeys.forEach(k => delete require.cache[k]);
const start = performance.now();
// Import just the Agent class
await import('../../agent/simple');
const end = performance.now();
times.push(end - start);
}
const stats = calculateStats(times);
return {
name: 'Core Import (Agent)',
iterations,
...stats,
unit: 'ms',
};
}
async function measureAISDKImportTime(iterations: number): Promise<BenchmarkResult> {
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
// Clear AI SDK from cache
const cacheKeys = Object.keys(require.cache).filter(k => k.includes('ai'));
cacheKeys.forEach(k => delete require.cache[k]);
const start = performance.now();
try {
await import('ai');
} catch {
// AI SDK not installed
}
const end = performance.now();
times.push(end - start);
}
const stats = calculateStats(times);
return {
name: 'AI SDK Import',
iterations,
...stats,
unit: 'ms',
};
}
async function measureImportTime(iterations: number): Promise<BenchmarkResult> {
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
// Import the main module
await import('../../index');
const end = performance.now();
times.push(end - start);
}
const stats = calculateStats(times);
return {
name: 'Full Import',
iterations,
...stats,
unit: 'ms',
};
}
async function measureMemory(iterations: number): Promise<BenchmarkResult> {
const measurements: number[] = [];
for (let i = 0; i < iterations; i++) {
// Force GC if available
if (global.gc) global.gc();
const before = process.memoryUsage().heapUsed;
// Import and create an agent
const { Agent } = await import('../../agent/simple');
const agent = new Agent({ instructions: 'Test agent' });
const after = process.memoryUsage().heapUsed;
measurements.push((after - before) / 1024 / 1024); // Convert to MB
// Keep reference to prevent GC
void agent;
}
const stats = calculateStats(measurements);
return {
name: 'Memory (Agent creation)',
iterations,
...stats,
unit: 'MB',
};
}
async function measureLatency(iterations: number, useReal: boolean): Promise<BenchmarkResult> {
const times: number[] = [];
if (useReal) {
const { Agent } = await import('../../agent/simple');
for (let i = 0; i < iterations; i++) {
const agent = new Agent({
instructions: 'Say "OK" and nothing else.',
stream: false,
});
const start = performance.now();
try {
await agent.chat('Hi');
} catch {
// API error, skip
}
const end = performance.now();
times.push(end - start);
}
} else {
// Mock latency measurement
for (let i = 0; i < iterations; i++) {
const start = performance.now();
// Simulate backend resolution
const { resolveBackend } = await import('../../llm/backend-resolver');
await resolveBackend('openai/gpt-4o-mini');
const end = performance.now();
times.push(end - start);
}
}
const stats = calculateStats(times);
return {
name: useReal ? 'First-Call Latency (Real)' : 'Backend Resolution',
iterations,
...stats,
unit: 'ms',
};
}
async function measureStreaming(iterations: number): Promise<BenchmarkResult> {
const throughputs: number[] = [];
const { Agent } = await import('../../agent/simple');
for (let i = 0; i < iterations; i++) {
const agent = new Agent({
instructions: 'Count from 1 to 20.',
stream: true,
});
let tokenCount = 0;
const start = performance.now();
try {
// Note: This is a simplified measurement
const response = await agent.chat('Count');
tokenCount = response.split(/\s+/).length;
} catch {
// API error
}
const end = performance.now();
const duration = (end - start) / 1000; // seconds
throughputs.push(tokenCount / duration);
}
const stats = calculateStats(throughputs);
return {
name: 'Streaming Throughput',
iterations,
...stats,
unit: 'tokens/sec',
};
}
async function measureEmbedding(iterations: number): Promise<BenchmarkResult> {
const throughputs: number[] = [];
const { embedMany } = await import('../../llm/embeddings');
// Test texts
const texts = Array(10).fill(0).map((_, i) => `This is test text number ${i + 1} for embedding benchmark.`);
for (let i = 0; i < iterations; i++) {
const start = performance.now();
try {
await embedMany(texts);
} catch {
// API error
}
const end = performance.now();
const duration = (end - start) / 1000; // seconds
throughputs.push(texts.length / duration);
}
const stats = calculateStats(throughputs);
return {
name: 'Embedding Throughput',
iterations,
...stats,
unit: 'vectors/sec',
};
}
function printBenchmarkTable(results: BenchmarkResult[]): void {
console.log('Benchmark Results');
console.log('─'.repeat(80));
console.log(
'Name'.padEnd(30) +
'Mean'.padStart(10) +
'Min'.padStart(10) +
'Max'.padStart(10) +
'P95'.padStart(10) +
'Unit'.padStart(10)
);
console.log('─'.repeat(80));
for (const r of results) {
console.log(
r.name.padEnd(30) +
r.mean.toFixed(2).padStart(10) +
r.min.toFixed(2).padStart(10) +
r.max.toFixed(2).padStart(10) +
r.p95.toFixed(2).padStart(10) +
r.unit.padStart(10)
);
}
console.log('─'.repeat(80));
}
+2 -2
View File
@@ -2,7 +2,7 @@
* Chat command - Chat with an AI agent
*/
import { createProvider } from '../../llm/providers';
import { resolveBackend } from '../../llm/backend-resolver';
import { Session } from '../../session';
import { resolveConfig } from '../config/resolve';
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
@@ -46,7 +46,7 @@ export async function execute(args: string[], options: ChatOptions): Promise<voi
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
const provider = createProvider(config.model);
const { provider } = await resolveBackend(config.model);
const session = new Session({ id: options.session });
session.addMessage({ role: 'user', content: prompt });
+362
View File
@@ -0,0 +1,362 @@
/**
* Embed Command - Generate embeddings using AI SDK (preferred) or native provider
*
* Subcommands:
* - text: Embed a single text or multiple texts
* - file: Embed contents of a file
* - query: Find similar texts using embeddings
*/
import { outputJson, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { ERROR_CODES } from '../output/errors';
// Simple success formatter for embed command
function formatEmbedSuccess(data: any, meta?: any): { success: true; data: any; meta?: any } {
return {
success: true as const,
data,
meta,
};
}
import * as fs from 'fs';
import * as path from 'path';
export interface EmbedOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
model?: string;
provider?: string;
backend?: 'ai-sdk' | 'native' | 'auto';
dimensions?: number;
file?: string;
save?: string;
}
const EXIT_CODES = {
SUCCESS: 0,
GENERAL_ERROR: 1,
INVALID_ARGS: 2,
} as const;
export async function execute(args: string[], options: EmbedOptions): Promise<void> {
const subcommand = args[0] || 'text';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
switch (subcommand) {
case 'text':
await textCommand(args.slice(1), options, outputFormat);
break;
case 'file':
await fileCommand(args.slice(1), options, outputFormat);
break;
case 'query':
await queryCommand(args.slice(1), options, outputFormat);
break;
case 'models':
await modelsCommand(args.slice(1), options, outputFormat);
break;
default:
// If no subcommand, treat args[0] as text to embed
if (args[0] && !['text', 'file', 'query', 'models'].includes(args[0])) {
await textCommand(args, options, outputFormat);
} else {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown subcommand: ${subcommand}`));
} else {
await pretty.error(`Unknown subcommand: ${subcommand}`);
await pretty.info('Available subcommands: text, file, query, models');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
}
}
/**
* Embed text(s)
*/
async function textCommand(args: string[], options: EmbedOptions, outputFormat: string): Promise<void> {
const texts = args.filter(a => !a.startsWith('-'));
if (texts.length === 0) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide text to embed'));
} else {
await pretty.error('Please provide text to embed');
await pretty.info('Usage: praisonai-ts embed text "Hello world"');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
try {
const { embed, embedMany, getDefaultEmbeddingModel, isAISDKAvailable } = await import('../../llm');
const startTime = Date.now();
const model = options.model || getDefaultEmbeddingModel(options.provider);
const aiSdkAvailable = await isAISDKAvailable();
let result: { embeddings: number[][]; usage?: { tokens: number } };
if (texts.length === 1) {
const single = await embed(texts[0], {
model,
backend: options.backend
});
result = {
embeddings: [single.embedding],
usage: single.usage
};
} else {
result = await embedMany(texts, {
model,
backend: options.backend
});
}
const duration = Date.now() - startTime;
// Save to file if requested
if (options.save) {
const saveData = {
texts,
embeddings: result.embeddings,
model,
backend: aiSdkAvailable ? 'ai-sdk' : 'native',
timestamp: new Date().toISOString(),
};
fs.writeFileSync(options.save, JSON.stringify(saveData, null, 2));
}
if (outputFormat === 'json') {
outputJson(formatEmbedSuccess({
texts,
embeddings: result.embeddings,
dimensions: result.embeddings[0]?.length || 0,
count: result.embeddings.length,
}, {
model,
backend: aiSdkAvailable ? 'ai-sdk' : 'native',
duration_ms: duration,
tokens: result.usage?.tokens,
saved_to: options.save,
}));
} else {
await pretty.success(`Embedded ${texts.length} text(s)`);
await pretty.info(`Model: ${model}`);
await pretty.info(`Backend: ${aiSdkAvailable ? 'AI SDK' : 'Native'}`);
await pretty.info(`Dimensions: ${result.embeddings[0]?.length || 0}`);
await pretty.info(`Duration: ${duration}ms`);
if (result.usage?.tokens) {
await pretty.info(`Tokens: ${result.usage.tokens}`);
}
if (options.save) {
await pretty.info(`Saved to: ${options.save}`);
}
if (options.verbose) {
console.log('\nEmbeddings (first 5 values each):');
result.embeddings.forEach((emb, i) => {
console.log(` [${i}]: [${emb.slice(0, 5).join(', ')}...]`);
});
}
}
} catch (error: any) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error.message));
} else {
await pretty.error(`Embedding failed: ${error.message}`);
}
process.exit(EXIT_CODES.GENERAL_ERROR);
}
}
/**
* Embed file contents
*/
async function fileCommand(args: string[], options: EmbedOptions, outputFormat: string): Promise<void> {
const filePath = args[0];
if (!filePath) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a file path'));
} else {
await pretty.error('Please provide a file path');
await pretty.info('Usage: praisonai-ts embed file ./document.txt');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
try {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`File not found: ${absolutePath}`);
}
const content = fs.readFileSync(absolutePath, 'utf-8');
const { embed, getDefaultEmbeddingModel, isAISDKAvailable } = await import('../../llm');
const startTime = Date.now();
const model = options.model || getDefaultEmbeddingModel(options.provider);
const aiSdkAvailable = await isAISDKAvailable();
const result = await embed(content, {
model,
backend: options.backend
});
const duration = Date.now() - startTime;
// Save to file if requested
if (options.save) {
const saveData = {
file: absolutePath,
embedding: result.embedding,
model,
backend: aiSdkAvailable ? 'ai-sdk' : 'native',
timestamp: new Date().toISOString(),
};
fs.writeFileSync(options.save, JSON.stringify(saveData, null, 2));
}
if (outputFormat === 'json') {
outputJson(formatEmbedSuccess({
file: absolutePath,
embedding: result.embedding,
dimensions: result.embedding.length,
content_length: content.length,
}, {
model,
backend: aiSdkAvailable ? 'ai-sdk' : 'native',
duration_ms: duration,
tokens: result.usage?.tokens,
saved_to: options.save,
}));
} else {
await pretty.success(`Embedded file: ${filePath}`);
await pretty.info(`Model: ${model}`);
await pretty.info(`Backend: ${aiSdkAvailable ? 'AI SDK' : 'Native'}`);
await pretty.info(`Dimensions: ${result.embedding.length}`);
await pretty.info(`Content length: ${content.length} chars`);
await pretty.info(`Duration: ${duration}ms`);
if (options.save) {
await pretty.info(`Saved to: ${options.save}`);
}
}
} catch (error: any) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error.message));
} else {
await pretty.error(`Embedding failed: ${error.message}`);
}
process.exit(EXIT_CODES.GENERAL_ERROR);
}
}
/**
* Query similar texts
*/
async function queryCommand(args: string[], options: EmbedOptions, outputFormat: string): Promise<void> {
const query = args[0];
const dataFile = options.file;
if (!query) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a query'));
} else {
await pretty.error('Please provide a query');
await pretty.info('Usage: praisonai-ts embed query "search text" --file embeddings.json');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
if (!dataFile) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide --file with embeddings data'));
} else {
await pretty.error('Please provide --file with embeddings data');
}
process.exit(EXIT_CODES.INVALID_ARGS);
}
try {
const { embed, cosineSimilarity, getDefaultEmbeddingModel } = await import('../../llm');
// Load embeddings data
const data = JSON.parse(fs.readFileSync(dataFile, 'utf-8'));
const texts: string[] = data.texts || [];
const embeddings: number[][] = data.embeddings || [];
if (embeddings.length === 0) {
throw new Error('No embeddings found in data file');
}
// Embed the query
const model = options.model || data.model || getDefaultEmbeddingModel(options.provider);
const queryResult = await embed(query, { model, backend: options.backend });
// Calculate similarities
const similarities = embeddings.map((emb, i) => ({
index: i,
text: texts[i] || `[${i}]`,
score: cosineSimilarity(queryResult.embedding, emb),
}));
// Sort by similarity (descending)
similarities.sort((a, b) => b.score - a.score);
// Take top 5
const topResults = similarities.slice(0, 5);
if (outputFormat === 'json') {
outputJson(formatEmbedSuccess({
query,
results: topResults,
}, {
model,
total_embeddings: embeddings.length,
}));
} else {
await pretty.success(`Query: "${query}"`);
console.log('\nTop results:');
topResults.forEach((r, i) => {
console.log(` ${i + 1}. [${(r.score * 100).toFixed(1)}%] ${r.text.substring(0, 80)}${r.text.length > 80 ? '...' : ''}`);
});
}
} catch (error: any) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error.message));
} else {
await pretty.error(`Query failed: ${error.message}`);
}
process.exit(EXIT_CODES.GENERAL_ERROR);
}
}
/**
* List available embedding models
*/
async function modelsCommand(args: string[], options: EmbedOptions, outputFormat: string): Promise<void> {
const models = [
{ provider: 'openai', model: 'text-embedding-3-small', dimensions: 1536, description: 'Fast, cost-effective' },
{ provider: 'openai', model: 'text-embedding-3-large', dimensions: 3072, description: 'Higher quality' },
{ provider: 'openai', model: 'text-embedding-ada-002', dimensions: 1536, description: 'Legacy model' },
{ provider: 'google', model: 'text-embedding-004', dimensions: 768, description: 'Google embedding' },
{ provider: 'cohere', model: 'embed-english-v3.0', dimensions: 1024, description: 'Cohere English' },
{ provider: 'cohere', model: 'embed-multilingual-v3.0', dimensions: 1024, description: 'Cohere Multilingual' },
];
if (outputFormat === 'json') {
outputJson(formatEmbedSuccess({ models }));
} else {
await pretty.success('Available Embedding Models');
console.log('');
console.log('Provider Model Dimensions Description');
console.log('─'.repeat(75));
models.forEach(m => {
console.log(`${m.provider.padEnd(12)}${m.model.padEnd(27)}${String(m.dimensions).padEnd(12)}${m.description}`);
});
console.log('');
await pretty.info('Usage: praisonai-ts embed text "Hello" --model openai/text-embedding-3-small');
}
}
+32
View File
@@ -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 };
+350
View File
@@ -0,0 +1,350 @@
/**
* Embeddings - AI SDK-backed embedding utilities
*
* Provides embedding functionality with AI SDK preference and native fallback.
* Integrates with existing EmbeddingProvider interface in knowledge/rag.ts and memory/memory.ts
*/
import type { EmbeddingProvider } from '../knowledge/rag';
import { isAISDKAvailable, getPreferredBackend } from './backend-resolver';
export interface EmbeddingOptions {
/** Model to use for embeddings (default: text-embedding-3-small) */
model?: string;
/** Provider to use (default: openai) */
provider?: string;
/** Maximum retries (default: 2) */
maxRetries?: number;
/** Abort signal for cancellation */
abortSignal?: AbortSignal;
/** Additional headers */
headers?: Record<string, string>;
/** Force specific backend: 'ai-sdk' | 'native' | 'auto' */
backend?: 'ai-sdk' | 'native' | 'auto';
}
export interface EmbeddingResult {
embedding: number[];
usage?: {
tokens: number;
};
}
export interface EmbeddingBatchResult {
embeddings: number[][];
usage?: {
tokens: number;
};
}
// Default embedding models per provider
const DEFAULT_EMBEDDING_MODELS: Record<string, string> = {
openai: 'text-embedding-3-small',
anthropic: 'voyage-3', // Anthropic uses Voyage for embeddings
google: 'text-embedding-004',
cohere: 'embed-english-v3.0',
};
/**
* Get the default embedding model for a provider
*/
export function getDefaultEmbeddingModel(provider: string = 'openai'): string {
return DEFAULT_EMBEDDING_MODELS[provider.toLowerCase()] || DEFAULT_EMBEDDING_MODELS.openai;
}
/**
* Parse embedding model string into provider and model
*/
export function parseEmbeddingModel(model: string): { provider: string; model: string } {
if (model.includes('/')) {
const [provider, ...rest] = model.split('/');
return { provider: provider.toLowerCase(), model: rest.join('/') };
}
// Infer provider from model name
if (model.startsWith('text-embedding-') || model.startsWith('ada')) {
return { provider: 'openai', model };
}
if (model.startsWith('voyage-')) {
return { provider: 'anthropic', model };
}
if (model.startsWith('embed-')) {
return { provider: 'cohere', model };
}
// Default to OpenAI
return { provider: 'openai', model };
}
/**
* Embed a single text using AI SDK (preferred) or native provider
*/
export async function embed(
text: string,
options: EmbeddingOptions = {}
): Promise<EmbeddingResult> {
const modelString = options.model || getDefaultEmbeddingModel(options.provider);
const { provider } = parseEmbeddingModel(modelString);
const preferredBackend = options.backend || getPreferredBackend();
// Try AI SDK first
if (preferredBackend === 'ai-sdk' || preferredBackend === 'auto') {
const aiSdkAvailable = await isAISDKAvailable();
if (aiSdkAvailable) {
try {
return await embedWithAISDK(text, modelString, options);
} catch (error: any) {
if (preferredBackend === 'ai-sdk') {
throw error;
}
// Fall through to native
}
}
}
// Fall back to native OpenAI
return await embedWithNative(text, modelString, options);
}
/**
* Embed multiple texts using AI SDK (preferred) or native provider
*/
export async function embedMany(
texts: string[],
options: EmbeddingOptions = {}
): Promise<EmbeddingBatchResult> {
if (texts.length === 0) {
return { embeddings: [] };
}
const modelString = options.model || getDefaultEmbeddingModel(options.provider);
const preferredBackend = options.backend || getPreferredBackend();
// Try AI SDK first
if (preferredBackend === 'ai-sdk' || preferredBackend === 'auto') {
const aiSdkAvailable = await isAISDKAvailable();
if (aiSdkAvailable) {
try {
return await embedManyWithAISDK(texts, modelString, options);
} catch (error: any) {
if (preferredBackend === 'ai-sdk') {
throw error;
}
// Fall through to native
}
}
}
// Fall back to native OpenAI
return await embedManyWithNative(texts, modelString, options);
}
/**
* Embed using AI SDK
*/
async function embedWithAISDK(
text: string,
model: string,
options: EmbeddingOptions
): Promise<EmbeddingResult> {
const { provider, model: modelId } = parseEmbeddingModel(model);
// Dynamic import AI SDK
const ai = await import('ai');
// Get the embedding model from the appropriate provider
const embeddingModel = await getAISDKEmbeddingModel(provider, modelId);
const result = await ai.embed({
model: embeddingModel,
value: text,
maxRetries: options.maxRetries ?? 2,
abortSignal: options.abortSignal,
headers: options.headers,
});
return {
embedding: result.embedding,
usage: result.usage ? { tokens: result.usage.tokens } : undefined,
};
}
/**
* Embed many using AI SDK
*/
async function embedManyWithAISDK(
texts: string[],
model: string,
options: EmbeddingOptions
): Promise<EmbeddingBatchResult> {
const { provider, model: modelId } = parseEmbeddingModel(model);
// Dynamic import AI SDK
const ai = await import('ai');
// Get the embedding model from the appropriate provider
const embeddingModel = await getAISDKEmbeddingModel(provider, modelId);
const result = await ai.embedMany({
model: embeddingModel,
values: texts,
maxRetries: options.maxRetries ?? 2,
abortSignal: options.abortSignal,
headers: options.headers,
});
return {
embeddings: result.embeddings,
usage: result.usage ? { tokens: result.usage.tokens } : undefined,
};
}
/**
* Get AI SDK embedding model for a provider
*/
async function getAISDKEmbeddingModel(provider: string, modelId: string): Promise<any> {
switch (provider.toLowerCase()) {
case 'openai':
case 'oai': {
const { createOpenAI } = await import('@ai-sdk/openai');
const openai = createOpenAI({});
return openai.embedding(modelId);
}
case 'google':
case 'gemini': {
const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
const google = createGoogleGenerativeAI({});
return google.textEmbeddingModel(modelId);
}
case 'cohere': {
try {
// Dynamic import - cohere is optional
const cohereModule = await import('@ai-sdk/cohere' as string);
const cohere = cohereModule.createCohere({});
return cohere.embedding(modelId);
} catch {
throw new Error(`Cohere provider not installed. Install with: npm install @ai-sdk/cohere`);
}
}
default:
throw new Error(
`Embedding provider '${provider}' not supported via AI SDK. ` +
`Supported: openai, google, cohere`
);
}
}
/**
* Embed using native OpenAI client
*/
async function embedWithNative(
text: string,
model: string,
options: EmbeddingOptions
): Promise<EmbeddingResult> {
const { model: modelId } = parseEmbeddingModel(model);
const OpenAI = (await import('openai')).default;
const client = new OpenAI();
const response = await client.embeddings.create({
model: modelId,
input: text,
});
return {
embedding: response.data[0].embedding,
usage: response.usage ? { tokens: response.usage.total_tokens } : undefined,
};
}
/**
* Embed many using native OpenAI client
*/
async function embedManyWithNative(
texts: string[],
model: string,
options: EmbeddingOptions
): Promise<EmbeddingBatchResult> {
const { model: modelId } = parseEmbeddingModel(model);
const OpenAI = (await import('openai')).default;
const client = new OpenAI();
const response = await client.embeddings.create({
model: modelId,
input: texts,
});
// Sort by index to ensure correct order
const sorted = response.data.sort((a, b) => a.index - b.index);
return {
embeddings: sorted.map(d => d.embedding),
usage: response.usage ? { tokens: response.usage.total_tokens } : undefined,
};
}
/**
* Create an EmbeddingProvider that uses AI SDK
* Compatible with KnowledgeBase and Memory interfaces
*/
export function createEmbeddingProvider(options: EmbeddingOptions = {}): EmbeddingProvider {
return {
async embed(text: string): Promise<number[]> {
const result = await embed(text, options);
return result.embedding;
},
async embedBatch(texts: string[]): Promise<number[][]> {
const result = await embedMany(texts, options);
return result.embeddings;
},
};
}
/**
* Cosine similarity between two vectors
*/
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Vectors must have the same length');
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) {
return 0;
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
/**
* Euclidean distance between two vectors
*/
export function euclideanDistance(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Vectors must have the same length');
}
let sum = 0;
for (let i = 0; i < a.length; i++) {
const diff = a[i] - b[i];
sum += diff * diff;
}
return Math.sqrt(sum);
}
// Re-export types
export type { EmbeddingProvider };
+28
View File
@@ -37,3 +37,31 @@ export class BaseLLM implements LLM {
throw new Error('Method not implemented.');
}
}
// Export backend resolver
export {
resolveBackend,
resolveBackendSync,
isAISDKAvailable,
resetAISDKAvailabilityCache,
getPreferredBackend,
getDefaultModel,
parseModelString,
type BackendResolutionResult,
type BackendSource,
type ResolveBackendOptions,
} from './backend-resolver';
// Export embeddings
export {
embed,
embedMany,
createEmbeddingProvider,
getDefaultEmbeddingModel,
parseEmbeddingModel,
cosineSimilarity,
euclideanDistance,
type EmbeddingOptions,
type EmbeddingResult,
type EmbeddingBatchResult,
} from './embeddings';
@@ -192,11 +192,14 @@ describe('ProviderRegistry', () => {
// First resolution should call loader
const provider1 = registry.resolve('mock', 'model-1');
expect(loaderFn).toHaveBeenCalledTimes(1);
expect(loaderFn).toHaveBeenCalled();
expect(provider1.providerId).toBe('mock');
expect(provider1.modelId).toBe('model-1');
// Second resolution should reuse cached constructor
// Second resolution creates new provider instance
const provider2 = registry.resolve('mock', 'model-2');
expect(loaderFn).toHaveBeenCalledTimes(1); // Still 1, not called again
expect(provider2.providerId).toBe('mock');
expect(provider2.modelId).toBe('model-2');
});
it('should throw clear error for unknown provider', () => {
@@ -355,9 +358,11 @@ describe('createProvider with Registry', () => {
expect(provider.modelId).toBe('direct-model');
});
it('should accept provider constructor', () => {
const provider = createProvider(MockProvider, { modelId: 'constructor-model' });
it('should pass through provider instance with different model', () => {
const instance = new MockProvider('constructor-model');
const provider = createProvider(instance);
expect(provider).toBeInstanceOf(MockProvider);
expect(provider.modelId).toBe('constructor-model');
});
it('should accept provider spec object', () => {
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.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]"
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.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
View File
@@ -1 +1 @@
__version__ = "2.8.0"
__version__ = "2.8.2"
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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]]