Compare commits

...
5 Commits
Author SHA1 Message Date
Mervin Praison 8a4c2c2c43 Release v2.7.6 2025-12-29 17:28:44 +00:00
Mervin Praison 0b2e6ff69b examples 2025-12-29 16:25:23 +00:00
Mervin Praison 1050d66970 Release v2.7.5 2025-12-29 14:31:52 +00:00
Mervin Praison bf483f3bd6 Release v2.7.4 2025-12-29 13:01:04 +00:00
Mervin Praison 00ee8eb2f3 Release v2.7.3 2025-12-29 12:19:58 +00:00
95 changed files with 19170 additions and 173 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.2" \
"praisonai>=2.7.6" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.2" \
"praisonai>=2.7.6" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.2" \
"praisonai>=2.7.6" \
"praisonai[ui]" \
"praisonai[crewai]"
+17
View File
@@ -14,6 +14,7 @@ examples/
│ ├── memory/ # Memory and sessions
│ ├── code/ # Code editing and external CLI tools
│ └── ...
├── serve/ # Server and endpoints examples
├── yaml/ # YAML workflow examples
└── cookbooks/ # Complete use-case examples
```
@@ -31,6 +32,22 @@ examples/
| **Code** | [python/code/](python/code/) | [📖](https://docs.praison.ai/code) |
| **YAML** | [yaml/](yaml/) | [📖](https://docs.praison.ai/features/yaml-workflows) |
| **Doctor** | [doctor/](doctor/) | [📖](https://docs.praison.ai/cli/doctor) |
| **Serve** | [serve/](serve/) | [📖](https://docs.praison.ai/cli/serve) |
| **Endpoints** | [serve/](serve/) | [📖](https://docs.praison.ai/cli/endpoints) |
## Serve Examples
| Example | Description | CLI Command |
|---------|-------------|-------------|
| [unified_server.py](serve/unified_server.py) | All providers in one server | `praisonai serve unified` |
| [agent_as_api_single.py](serve/agent_as_api_single.py) | Single agent HTTP API | `praisonai serve agents` |
| [agents_as_api_router.py](serve/agents_as_api_router.py) | Multi-agent router API | `praisonai serve agents` |
| [a2a_server_client.py](serve/a2a_server_client.py) | A2A protocol server | `praisonai serve a2a` |
| [a2u_events_stream.py](serve/a2u_events_stream.py) | A2U event stream | `praisonai serve a2u` |
| [mcp_http_server.py](serve/mcp_http_server.py) | MCP HTTP server | `praisonai serve mcp` |
| [tools_as_mcp_server.py](serve/tools_as_mcp_server.py) | Tools as MCP server | `praisonai serve tools` |
| [agent_launch_modes.py](serve/agent_launch_modes.py) | Agent.launch() API | Python only |
| [endpoints_unified_client.py](serve/endpoints_unified_client.py) | Unified client | `praisonai endpoints` |
## Running Examples
+101
View File
@@ -0,0 +1,101 @@
# Package Manager Example
This example demonstrates how to use the PraisonAI package manager for installing and managing Python packages with security defaults.
## Features Demonstrated
- Installing packages with version constraints
- Listing installed packages
- Searching for packages on PyPI
- Managing package index configuration
- Security features (dependency confusion prevention)
## Quick Start
```bash
# Run the example
python package_manager_example.py
```
## CLI Commands
### Install Packages
```bash
# Install a package
praisonai install requests
# Install with version constraint
praisonai install "requests>=2.28"
# Install multiple packages
praisonai install requests httpx aiohttp
# Upgrade existing package
praisonai install requests --upgrade
# JSON output
praisonai install requests --json
```
### Uninstall Packages
```bash
# Uninstall a package
praisonai uninstall requests
# Uninstall without confirmation
praisonai uninstall requests --yes
```
### List Packages
```bash
# List all installed packages
praisonai package list
# JSON output
praisonai package list --json
```
### Search Packages
```bash
# Search PyPI
praisonai package search langchain
# JSON output
praisonai package search langchain --json
```
### Index Configuration
```bash
# Show current index settings
praisonai package index show --json
# Set custom index
praisonai package index set https://pypi.mycompany.com/simple
# Reset to PyPI default
praisonai package index set https://pypi.org/simple
```
## Security Features
By default, only the primary index (PyPI) is used. Extra indexes are blocked to prevent dependency confusion attacks.
```bash
# This will FAIL (extra index not allowed by default)
praisonai install mypackage --extra-index-url https://other.index.com/simple
# Explicitly allow extra index (shows security warning)
praisonai install mypackage \
--extra-index-url https://other.index.com/simple \
--allow-extra-index
```
## Environment Variables
- `PRAISONAI_PACKAGE_INDEX_URL` - Override primary index URL
- `PIP_INDEX_URL` - Fallback to pip's index URL
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Package Manager Example
This example demonstrates:
1. Checking package index configuration
2. Searching for packages on PyPI
3. Listing installed packages
4. Security features (safe defaults)
Requirements:
- praisonai installed
- No external API keys required
Note: This example does NOT actually install/uninstall packages
to avoid modifying your environment. It demonstrates read-only
operations and shows the commands for install/uninstall.
"""
import sys
import json
import subprocess
def run_command(args, description):
"""Run a praisonai command and display results."""
print(f"\n[{description}]")
print(f"Command: praisonai {' '.join(args)}")
print("-" * 40)
result = subprocess.run(
["python3", "-m", "praisonai"] + args,
capture_output=True,
text=True
)
if result.returncode == 0:
try:
data = json.loads(result.stdout)
print(json.dumps(data, indent=2))
return data
except json.JSONDecodeError:
print(result.stdout)
return None
else:
print(f"Error (exit code {result.returncode}):")
print(result.stderr or result.stdout)
return None
def main():
print("=" * 60)
print("PraisonAI Package Manager Example")
print("=" * 60)
print("\nThis example demonstrates package manager features.")
print("No packages will be installed or uninstalled.")
# 1. Show index configuration
print("\n" + "=" * 60)
print("1. PACKAGE INDEX CONFIGURATION")
print("=" * 60)
data = run_command(
["package", "index", "show", "--json"],
"Show current index settings"
)
if data:
print(f"\n✓ Primary index: {data.get('index_url', 'N/A')}")
print(f"✓ Extra indexes allowed: {data.get('allow_extra_index', False)}")
# 2. Search for packages
print("\n" + "=" * 60)
print("2. SEARCH PACKAGES")
print("=" * 60)
data = run_command(
["package", "search", "requests", "--json"],
"Search for 'requests' package"
)
if data and data.get("results"):
pkg = data["results"][0]
print(f"\n✓ Found: {pkg.get('name')} v{pkg.get('version')}")
print(f" Summary: {pkg.get('summary', 'N/A')}")
# 3. List installed packages (sample)
print("\n" + "=" * 60)
print("3. LIST INSTALLED PACKAGES")
print("=" * 60)
data = run_command(
["package", "list", "--json"],
"List installed packages"
)
if data and data.get("packages"):
print(f"\n✓ Total packages: {len(data['packages'])}")
print(" Sample (first 5):")
for pkg in data["packages"][:5]:
print(f" - {pkg['name']}=={pkg['version']}")
# 4. Show install/uninstall commands (without executing)
print("\n" + "=" * 60)
print("4. INSTALL/UNINSTALL COMMANDS (NOT EXECUTED)")
print("=" * 60)
print("\nTo install packages, use:")
print(" praisonai install requests")
print(" praisonai install 'requests>=2.28'")
print(" praisonai install requests httpx --upgrade")
print("\nTo uninstall packages, use:")
print(" praisonai uninstall requests")
print(" praisonai uninstall requests --yes")
# 5. Security features
print("\n" + "=" * 60)
print("5. SECURITY FEATURES")
print("=" * 60)
print("\nDependency Confusion Prevention:")
print(" - By default, only PyPI is used as package source")
print(" - Extra index URLs are blocked unless explicitly allowed")
print(" - Use --allow-extra-index to enable (shows warning)")
print("\nExample (blocked by default):")
print(" praisonai install pkg --extra-index-url https://other.index.com/simple")
print(" → Error: Extra index URLs not allowed")
print("\nExample (explicitly allowed):")
print(" praisonai install pkg --extra-index-url https://other.index.com/simple --allow-extra-index")
print(" → Warning shown, then proceeds")
print("\n" + "=" * 60)
print("Example completed!")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())
+80
View File
@@ -0,0 +1,80 @@
# Policy Packs Example
This example demonstrates how to use PraisonAI policy packs for managing tool permissions, data policies, and execution modes.
## Features Demonstrated
- Creating and loading policy packs
- Tool permission checking (allow/deny)
- Policy modes (dev/prod)
- Merging policies
- Data and PII policies
## Quick Start
```bash
python policy_example.py
```
## CLI Commands
```bash
# Show default policy
praisonai recipe policy show
# Show policy from file
praisonai recipe policy show my-policy.yaml
# Create policy template
praisonai recipe policy init -o my-policy.yaml
# Validate policy file
praisonai recipe policy validate my-policy.yaml
```
## Policy File Format
```yaml
name: my-org-policy
version: "1.0"
tools:
allow:
- web.search
- db.query
deny:
- shell.exec
- file.write
network:
allow_domains:
- api.openai.com
deny_domains:
- localhost
pii:
mode: redact # allow, deny, redact
fields:
- email
- phone
data:
retention_days: 30
export_allowed: true
modes:
dev:
allow_interactive_prompts: true
strict_tool_enforcement: false
prod:
allow_interactive_prompts: false
strict_tool_enforcement: true
require_auth: true
```
## Using Policy with Recipe Run
```bash
# Run with policy file
praisonai recipe run my-recipe --policy my-policy.yaml --mode prod
```
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
PraisonAI Policy Packs Example
This example demonstrates:
1. Creating and loading policy packs
2. Tool permission checking
3. Policy modes (dev/prod)
4. Merging policies
Prerequisites:
- pip install praisonai
Usage:
python policy_example.py
"""
import tempfile
from pathlib import Path
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Policy Packs Example")
print("=" * 60)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
# =====================================================
# Default Policies
# =====================================================
print("\n" + "=" * 40)
print("Default Policies")
print("=" * 40)
from praisonai.recipe.policy import get_default_policy, PolicyPack, PolicyDeniedError
print("\n1. Loading default dev policy...")
dev_policy = get_default_policy("dev")
print(f" Name: {dev_policy.name}")
print(f" Allowed tools: {len(dev_policy.allowed_tools)}")
print(f" Denied tools: {len(dev_policy.denied_tools)}")
print(f" PII mode: {dev_policy.pii_mode}")
print("\n2. Loading default prod policy...")
prod_policy = get_default_policy("prod")
print(f" Name: {prod_policy.name}")
print(f" PII mode: {prod_policy.pii_mode}")
# =====================================================
# Tool Permission Checking
# =====================================================
print("\n" + "=" * 40)
print("Tool Permission Checking")
print("=" * 40)
print("\n3. Checking tool permissions...")
# Check allowed tool
try:
dev_policy.check_tool("web.search")
print(" web.search: ✓ Allowed")
except PolicyDeniedError as e:
print(f" web.search: ✗ Denied - {e}")
# Check denied tool
try:
dev_policy.check_tool("shell.exec")
print(" shell.exec: ✓ Allowed")
except PolicyDeniedError:
print(" shell.exec: ✗ Denied")
# Check another denied tool
try:
dev_policy.check_tool("file.write")
print(" file.write: ✓ Allowed")
except PolicyDeniedError:
print(" file.write: ✗ Denied")
# =====================================================
# Custom Policy Pack
# =====================================================
print("\n" + "=" * 40)
print("Custom Policy Pack")
print("=" * 40)
print("\n4. Creating custom policy pack...")
custom_policy = PolicyPack(
name="my-org-policy",
config={
"tools": {
"allow": ["web.search", "db.query", "file.read"],
"deny": ["shell.exec", "network.unrestricted"],
},
"network": {
"allow_domains": ["api.openai.com", "api.anthropic.com"],
"deny_domains": ["localhost", "127.0.0.1"],
},
"pii": {
"mode": "redact",
"fields": ["email", "phone", "ssn"],
},
"data": {
"retention_days": 30,
"export_allowed": True,
},
"modes": {
"dev": {
"allow_interactive_prompts": True,
"strict_tool_enforcement": False,
},
"prod": {
"allow_interactive_prompts": False,
"strict_tool_enforcement": True,
"require_auth": True,
},
},
},
)
print(f" Name: {custom_policy.name}")
print(f" Allowed tools: {list(custom_policy.allowed_tools)}")
print(f" PII mode: {custom_policy.pii_mode}")
print(f" Retention days: {custom_policy.retention_days}")
# =====================================================
# Saving and Loading Policies
# =====================================================
print("\n" + "=" * 40)
print("Saving and Loading Policies")
print("=" * 40)
print("\n5. Saving policy to file...")
policy_file = tmp_path / "my-policy.yaml"
custom_policy.save(policy_file)
print(f" Saved to: {policy_file}")
print("\n6. Loading policy from file...")
loaded_policy = PolicyPack.load(policy_file)
print(f" Loaded: {loaded_policy.name}")
print(f" PII mode: {loaded_policy.pii_mode}")
# =====================================================
# Merging Policies
# =====================================================
print("\n" + "=" * 40)
print("Merging Policies")
print("=" * 40)
print("\n7. Creating base and override policies...")
base_policy = PolicyPack(
name="base",
config={
"tools": {"allow": ["web.search"]},
"pii": {"mode": "allow"},
},
)
override_policy = PolicyPack(
name="override",
config={
"tools": {"allow": ["db.query"]},
"pii": {"mode": "redact", "fields": ["email"]},
},
)
print("\n8. Merging policies...")
merged = base_policy.merge(override_policy)
print(f" Merged name: {merged.name}")
print(f" Allowed tools: {list(merged.allowed_tools)}")
print(f" PII mode: {merged.pii_mode}")
# =====================================================
# Mode-Specific Configuration
# =====================================================
print("\n" + "=" * 40)
print("Mode-Specific Configuration")
print("=" * 40)
print("\n9. Getting mode-specific config...")
dev_config = custom_policy.get_mode_config("dev")
prod_config = custom_policy.get_mode_config("prod")
print(" Dev mode:")
print(f" - Interactive prompts: {dev_config.get('allow_interactive_prompts')}")
print(f" - Strict enforcement: {dev_config.get('strict_tool_enforcement')}")
print(" Prod mode:")
print(f" - Interactive prompts: {prod_config.get('allow_interactive_prompts')}")
print(f" - Strict enforcement: {prod_config.get('strict_tool_enforcement')}")
print(f" - Require auth: {prod_config.get('require_auth')}")
# =====================================================
# Data Policy
# =====================================================
print("\n" + "=" * 40)
print("Data Policy")
print("=" * 40)
print("\n10. Getting data policy...")
data_policy = custom_policy.get_data_policy()
print(f" PII mode: {data_policy['pii']['mode']}")
print(f" PII fields: {data_policy['pii']['fields']}")
print(f" Retention days: {data_policy['retention_days']}")
print(f" Export allowed: {data_policy['export_allowed']}")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,11 @@
# Creator Suite Examples - Environment Variables
# Required for most features
OPENAI_API_KEY=your_openai_api_key_here
# Optional - for web search in news crawler
TAVILY_API_KEY=your_tavily_api_key_here
# Optional - for additional LLM providers
ANTHROPIC_API_KEY=your_anthropic_api_key_here
GOOGLE_API_KEY=your_google_api_key_here
+77
View File
@@ -0,0 +1,77 @@
# Creator Suite Examples
Examples for the PraisonAI Creator Automation Suite - a complete toolkit for AI content creators.
## Quick Start
```bash
# Set your API key
export OPENAI_API_KEY=your_key_here
# Run an example
python news_crawler_example.py
python script_writer_example.py
python hook_generator_example.py
python brief_generator_example.py
```
## Examples
| Example | Description | API Key Required |
|---------|-------------|------------------|
| `news_crawler_example.py` | Crawl AI news from multiple sources | No (HN, arXiv) |
| `script_writer_example.py` | Generate scripts for YouTube, X, LinkedIn | Yes (OpenAI) |
| `hook_generator_example.py` | Generate attention-grabbing hooks | Yes (OpenAI) |
| `brief_generator_example.py` | Generate news briefs | Yes (OpenAI) |
## Environment Variables
Copy `.env.example` to `.env` and fill in your API keys:
```bash
cp .env.example .env
```
## Features Covered
### News Intelligence Layer
- AI News Crawler
- AI News Deduper
- AI Signal Ranker
- AI Context Enricher
- AI Brief Generator
### Script & Story Layer
- AI Script Writer
- AI Angle Generator
- AI Hook Generator
- AI CTA Generator
- AI Fact Checker
### Capture & Screen Automation
- AI Screenshot Capture
- AI Screen Recorder
- AI News Capture Pack
### Video Generation
- AI B-roll Builder
- AI Voiceover Generator
- AI Video Merger
### Publishing & Distribution
- AI Publisher Pack
- AI Content Calendar
- AI Post Copy Generator
- AI Hashtag Optimizer
### Feedback & Learning
- AI Performance Analyzer
- AI Comment Miner
- AI A/B Hook Tester
### Orchestrator
- AI Daily News Show (end-to-end pipeline)
## Documentation
Full documentation: https://docs.praison.ai/docs/ai-tools/creator-suite/
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
AI Brief Generator Example
Demonstrates generating news briefs from articles.
Requires: OPENAI_API_KEY
"""
import os
import sys
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("ERROR: OPENAI_API_KEY environment variable required")
print("Set it with: export OPENAI_API_KEY=your_key")
sys.exit(1)
# Add the Agent-Recipes templates to path
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-brief-generator')
from tools import generate_brief, extract_highlights
def main():
print("=" * 60)
print("AI Brief Generator Example")
print("=" * 60)
# Sample articles
articles = [
{
"title": "OpenAI Releases GPT-5 with Multimodal Capabilities",
"content": "OpenAI announced GPT-5 today with major improvements in reasoning, multimodal understanding, and reduced hallucinations.",
"source": "TechCrunch",
"url": "https://example.com/gpt5"
},
{
"title": "Google Gemini 2.0 Launches with Real-time Video Understanding",
"content": "Google unveiled Gemini 2.0 with breakthrough capabilities in real-time video analysis and native tool use.",
"source": "The Verge",
"url": "https://example.com/gemini2"
},
{
"title": "Anthropic Claude 4 Sets New Benchmarks",
"content": "Anthropic's Claude 4 achieves state-of-the-art results on coding, math, and reasoning benchmarks.",
"source": "Wired",
"url": "https://example.com/claude4"
}
]
# 1. Generate daily brief
print("\n1. Generating daily brief...")
brief = generate_brief(articles, format="daily", max_articles=3)
print(f" Format: daily")
print(f" Articles included: {brief.get('article_count', 0)}")
print(f"\n Brief preview:\n {brief.get('brief', '')[:500]}...")
# 2. Extract highlights
print("\n2. Extracting highlights...")
highlights = extract_highlights(articles, num_highlights=3)
print(f" Highlights:")
for h in highlights.get('highlights', []):
print(f" - {h}")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
AI Hook Generator Example
Demonstrates generating attention-grabbing hooks.
Requires: OPENAI_API_KEY
"""
import os
import sys
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("ERROR: OPENAI_API_KEY environment variable required")
print("Set it with: export OPENAI_API_KEY=your_key")
sys.exit(1)
# Add the Agent-Recipes templates to path
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-hook-generator')
from tools import generate_hooks, rank_hooks
def main():
print("=" * 60)
print("AI Hook Generator Example")
print("=" * 60)
topic = "GPT-5 just released with groundbreaking capabilities"
# 1. Generate hooks
print(f"\n1. Generating hooks for: '{topic}'")
hooks = generate_hooks(topic, num_variants=5)
print(f" Generated {len(hooks.get('hooks', []))} hooks:")
for hook in hooks.get('hooks', []):
print(f"\n [{hook.get('style', 'unknown')}]")
print(f" {hook.get('text', '')}")
# 2. Rank hooks
print("\n2. Ranking hooks by engagement potential...")
ranked = rank_hooks(hooks.get('hooks', []))
best = ranked.get('best_hook', {})
print(f"\n Best hook (score: {best.get('score', 0)}):")
print(f" {best.get('text', '')}")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
AI News Crawler Example
Demonstrates crawling AI news from multiple sources.
"""
import os
import sys
# Add the Agent-Recipes templates to path
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-news-crawler')
from tools import crawl_hackernews, crawl_reddit_ai, crawl_arxiv, crawl_ai_news
def main():
print("=" * 60)
print("AI News Crawler Example")
print("=" * 60)
# 1. Crawl HackerNews (no API key needed)
print("\n1. Crawling HackerNews...")
hn_articles = crawl_hackernews(max_articles=5, time_window_hours=48)
print(f" Found {len(hn_articles)} articles from HackerNews")
for article in hn_articles[:3]:
print(f" - {article.get('title', 'No title')[:60]}...")
# 2. Crawl arXiv (no API key needed)
print("\n2. Crawling arXiv...")
arxiv_articles = crawl_arxiv(categories=["cs.AI", "cs.LG"], max_results=5)
print(f" Found {len(arxiv_articles)} articles from arXiv")
for article in arxiv_articles[:3]:
print(f" - {article.get('title', 'No title')[:60]}...")
# 3. Full crawl from multiple sources
print("\n3. Full multi-source crawl...")
all_articles = crawl_ai_news(
sources=["hackernews", "arxiv"],
max_articles=10,
time_window_hours=48
)
print(f" Total articles: {all_articles.get('total', 0)}")
print(f" Sources crawled: {all_articles.get('sources_crawled', [])}")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
AI Script Writer Example
Demonstrates generating scripts for different platforms.
Requires: OPENAI_API_KEY
"""
import os
import sys
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("ERROR: OPENAI_API_KEY environment variable required")
print("Set it with: export OPENAI_API_KEY=your_key")
sys.exit(1)
# Add the Agent-Recipes templates to path
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-script-writer')
from tools import write_youtube_script, write_short_script, write_thread
def main():
print("=" * 60)
print("AI Script Writer Example")
print("=" * 60)
topic = "AI agents are revolutionizing software development"
# 1. YouTube Long-form Script
print("\n1. Generating YouTube long-form script...")
yt_script = write_youtube_script(
topic=topic,
target_length=180,
key_points=["automation", "productivity", "future of coding"]
)
print(f" Format: {yt_script.get('format')}")
print(f" Estimated duration: {yt_script.get('estimated_duration')}s")
print(f" Script preview:\n {yt_script.get('script', '')[:200]}...")
# 2. YouTube Short Script
print("\n2. Generating YouTube Short script...")
short_script = write_short_script(topic, duration=30)
print(f" Format: {short_script.get('format')}")
print(f" Script:\n {short_script.get('script', '')[:300]}...")
# 3. X Thread
print("\n3. Generating X thread...")
thread = write_thread(topic, num_tweets=3)
print(f" Thread ({len(thread.get('tweets', []))} tweets):")
for i, tweet in enumerate(thread.get('tweets', [])[:3], 1):
print(f" Tweet {i}: {tweet[:80]}...")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Example: LLM-based Recipes
Demonstrates usage of LLM-powered recipes:
- ai-blog-generator
- ai-social-media-generator
- ai-faq-generator
- ai-sentiment-analyzer
"""
import os
import sys
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
sys.exit(1)
# Add recipe tools to path
sys.path.insert(0, "/Users/praison/PraisonAI-tools/praisonai_tools/recipe_tools")
from llm_tool import LLMTool, llm_complete
def example_blog_generator():
"""Generate a blog post."""
print("\n=== AI Blog Generator ===")
llm = LLMTool(provider="openai", model="gpt-4o-mini")
topic = "The Future of AI in Healthcare"
response = llm.complete(
f"""Write a short blog post (3 paragraphs) about: {topic}
Include:
- Engaging introduction
- Key points
- Call to action
Format as markdown.""",
system="You are an expert blog writer specializing in technology topics.",
max_tokens=500,
)
print(f"Topic: {topic}")
print(f"\nGenerated Blog Post:\n{response.content}")
print(f"\nTokens used: {response.usage.get('total_tokens', 'N/A')}")
def example_social_media_generator():
"""Generate social media posts."""
print("\n=== AI Social Media Generator ===")
llm = LLMTool(provider="openai", model="gpt-4o-mini")
content = "We just launched our new AI-powered productivity app!"
response = llm.complete(
f"""Create social media posts for the following announcement:
{content}
Generate posts for:
1. Twitter (280 chars max)
2. LinkedIn (professional tone)
3. Instagram (with emoji suggestions)
Format each clearly.""",
system="You are a social media marketing expert.",
max_tokens=400,
)
print(f"Content: {content}")
print(f"\nGenerated Posts:\n{response.content}")
def example_faq_generator():
"""Generate FAQ from content."""
print("\n=== AI FAQ Generator ===")
llm = LLMTool(provider="openai", model="gpt-4o-mini")
documentation = """
Our product is a cloud-based project management tool.
It supports team collaboration, task tracking, and reporting.
Pricing starts at $10/month per user.
We offer a 14-day free trial.
Integration with Slack, GitHub, and Jira is available.
"""
response = llm.complete(
f"""Based on this documentation, generate 5 FAQ questions and answers:
{documentation}
Format as:
Q: [question]
A: [answer]
""",
system="You are a technical writer creating FAQ documentation.",
max_tokens=400,
)
print(f"Generated FAQ:\n{response.content}")
def example_sentiment_analyzer():
"""Analyze sentiment in text."""
print("\n=== AI Sentiment Analyzer ===")
llm = LLMTool(provider="openai", model="gpt-4o-mini")
reviews = [
"This product is amazing! Best purchase ever.",
"Terrible experience. Would not recommend.",
"It's okay, nothing special but does the job.",
]
for review in reviews:
response = llm.complete(
f"""Analyze the sentiment of this text and respond with ONLY one word: POSITIVE, NEGATIVE, or NEUTRAL
Text: {review}""",
max_tokens=10,
)
print(f"Review: {review[:50]}...")
print(f"Sentiment: {response.content.strip()}\n")
if __name__ == "__main__":
print("=" * 60)
print("LLM-Based Recipes Examples")
print("=" * 60)
example_blog_generator()
example_social_media_generator()
example_faq_generator()
example_sentiment_analyzer()
print("\n" + "=" * 60)
print("All examples completed!")
print("=" * 60)
+71
View File
@@ -0,0 +1,71 @@
# Recipe Registry Example
This example demonstrates how to use the PraisonAI recipe registry for publishing and pulling recipes via both local filesystem and HTTP server.
## Features Demonstrated
- Creating a local registry
- Starting an HTTP registry server
- Publishing recipes with token authentication
- Listing and searching recipes
- Pulling recipes from HTTP registry
## Quick Start
```bash
# Run the HTTP registry example
python http_registry_example.py
```
## HTTP Registry Server
```bash
# Start HTTP registry server
praisonai registry serve --port 7777
# Start with authentication
praisonai registry serve --port 7777 --token mysecret
# Check server status
praisonai registry status --registry http://localhost:7777
```
## CLI Commands with HTTP Registry
```bash
# Publish to HTTP registry
praisonai recipe publish ./my-recipe --registry http://localhost:7777 --json
# Publish with token
praisonai recipe publish ./my-recipe --registry http://localhost:7777 --token mysecret
# Pull from HTTP registry
praisonai recipe pull [email protected] --registry http://localhost:7777 -o ./recipes
# List recipes from HTTP registry
praisonai recipe list --registry http://localhost:7777 --json
# Search HTTP registry
praisonai recipe search "hello" --registry http://localhost:7777
```
## Local Registry Commands
```bash
# Publish to local registry (default)
praisonai recipe publish ./my-recipe --json
# Pull from local registry
praisonai recipe pull [email protected] -o ./recipes
# Use custom local path
praisonai recipe publish ./my-recipe --registry /path/to/registry
```
## Environment Variables
- `PRAISONAI_REGISTRY_TOKEN` - Default token for HTTP registry authentication
## Default Registry Location
By default, the local registry is stored at `~/.praison/registry`.
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""
HTTP Recipe Registry Example
This example demonstrates:
1. Starting an HTTP registry server programmatically
2. Publishing recipes with token authentication
3. Listing and searching recipes via HTTP
4. Pulling recipes from HTTP registry
Requirements:
- praisonai installed
- No external API keys required (local registry only)
Environment Variables (optional):
- PRAISONAI_REGISTRY_TOKEN: Token for authentication
"""
import sys
import json
import time
import shutil
import tarfile
import tempfile
import subprocess
from pathlib import Path
def create_sample_recipe(output_dir: Path) -> Path:
"""Create a sample recipe for testing."""
recipe_dir = output_dir / "sample-agent"
recipe_dir.mkdir(parents=True, exist_ok=True)
# Create manifest.json
manifest = {
"name": "sample-agent",
"version": "1.0.0",
"description": "A sample agent recipe for registry testing",
"tags": ["sample", "agent", "test"],
"author": "praison",
"files": ["recipe.yaml"]
}
(recipe_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
# Create recipe.yaml
recipe_yaml = """
name: sample-agent
version: "1.0.0"
description: A sample agent recipe
agents:
- name: assistant
role: General Assistant
goal: Help users with their questions
instructions: You are a helpful assistant.
tasks:
- name: respond
description: Respond to user query
agent: assistant
"""
(recipe_dir / "recipe.yaml").write_text(recipe_yaml.strip())
# Pack into .praison bundle
bundle_path = output_dir / "sample-agent-1.0.0.praison"
with tarfile.open(bundle_path, "w:gz") as tar:
for file in recipe_dir.iterdir():
tar.add(file, arcname=file.name)
return bundle_path
def start_registry_server(port: int, registry_path: Path, token: str = None):
"""Start registry server in background process."""
cmd = [
sys.executable, "-c",
f"""
import sys
sys.path.insert(0, '/Users/praison/praisonai-package/src/praisonai')
from praisonai.recipe.server import create_wsgi_app
from wsgiref.simple_server import make_server, WSGIRequestHandler
from pathlib import Path
class QuietHandler(WSGIRequestHandler):
def log_message(self, format, *args):
pass
app = create_wsgi_app(registry_path=Path("{registry_path}"), token={repr(token)})
server = make_server("127.0.0.1", {port}, app, handler_class=QuietHandler)
server.serve_forever()
"""
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return proc
def main():
print("=" * 60)
print("HTTP Recipe Registry Example")
print("=" * 60)
# Setup
work_dir = Path(tempfile.mkdtemp(prefix="registry_example_"))
registry_path = work_dir / "registry"
pulled_dir = work_dir / "pulled"
port = 7799
token = "example-secret-token"
print(f"\nWork directory: {work_dir}")
print(f"Registry path: {registry_path}")
print(f"Server port: {port}")
print(f"Token: {'*' * len(token)} (redacted)")
try:
# 1. Create sample recipe
print("\n[1] Creating sample recipe...")
bundle_path = create_sample_recipe(work_dir)
print(f" ✓ Created bundle: {bundle_path.name}")
# 2. Start HTTP registry server
print("\n[2] Starting HTTP registry server...")
server = start_registry_server(port, registry_path, token)
time.sleep(1) # Wait for server to start
print(f" ✓ Server running on http://127.0.0.1:{port}")
# 3. Check health
print("\n[3] Checking server health...")
result = subprocess.run(
["curl", "-s", f"http://127.0.0.1:{port}/healthz"],
capture_output=True, text=True
)
health = json.loads(result.stdout)
print(f" Status: {health['status']}")
print(f" Auth required: {health['auth_required']}")
# 4. Publish WITHOUT token (should fail)
print("\n[4] Publishing WITHOUT token (expect failure)...")
result = subprocess.run(
["python3", "-m", "praisonai", "recipe", "publish", str(bundle_path),
"--registry", f"http://127.0.0.1:{port}", "--json"],
capture_output=True, text=True,
cwd="/Users/praison/praisonai-package/src/praisonai"
)
if result.returncode != 0 or "error" in result.stdout.lower() or "401" in result.stdout:
print(" ✓ Correctly rejected (authentication required)")
else:
print(f" ✗ Unexpected success: {result.stdout}")
# 5. Publish WITH token (should succeed)
print("\n[5] Publishing WITH token...")
result = subprocess.run(
["python3", "-m", "praisonai", "recipe", "publish", str(bundle_path),
"--registry", f"http://127.0.0.1:{port}", "--token", token, "--json"],
capture_output=True, text=True,
cwd="/Users/praison/praisonai-package/src/praisonai"
)
try:
data = json.loads(result.stdout)
if data.get("ok"):
print(f" ✓ Published: {data['name']}@{data['version']}")
else:
print(f" ✗ Failed: {result.stdout}")
return 1
except json.JSONDecodeError:
print(f" Output: {result.stdout}")
print(f" Stderr: {result.stderr}")
return 1
# 6. List recipes
print("\n[6] Listing recipes from registry...")
result = subprocess.run(
["python3", "-m", "praisonai", "recipe", "list",
"--registry", f"http://127.0.0.1:{port}", "--json"],
capture_output=True, text=True,
cwd="/Users/praison/praisonai-package/src/praisonai"
)
recipes = json.loads(result.stdout)
print(f" Found {len(recipes)} recipe(s):")
for r in recipes:
print(f" - {r['name']} v{r['version']}: {r.get('description', '')[:40]}")
# 7. Search recipes
print("\n[7] Searching for 'agent'...")
result = subprocess.run(
["python3", "-m", "praisonai", "recipe", "search", "agent",
"--registry", f"http://127.0.0.1:{port}", "--json"],
capture_output=True, text=True,
cwd="/Users/praison/praisonai-package/src/praisonai"
)
matches = json.loads(result.stdout)
print(f" Found {len(matches)} match(es)")
# 8. Pull recipe
print("\n[8] Pulling recipe...")
pulled_dir.mkdir(parents=True, exist_ok=True)
result = subprocess.run(
["python3", "-m", "praisonai", "recipe", "pull", "sample-agent",
"--registry", f"http://127.0.0.1:{port}", "-o", str(pulled_dir), "--json"],
capture_output=True, text=True,
cwd="/Users/praison/praisonai-package/src/praisonai"
)
data = json.loads(result.stdout)
if data.get("ok"):
print(f" ✓ Pulled to: {data['path']}")
# Verify content
manifest_path = Path(data['path']) / "manifest.json"
if manifest_path.exists():
manifest = json.loads(manifest_path.read_text())
print(f" ✓ Verified: {manifest['name']}@{manifest['version']}")
else:
print(f" ✗ Failed: {result.stdout}")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
# Cleanup
server.terminate()
server.wait()
finally:
# Clean up temp directory
shutil.rmtree(work_dir, ignore_errors=True)
return 0
if __name__ == "__main__":
sys.exit(main())
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
PraisonAI Recipe Registry Example
This example demonstrates how to:
1. Create and publish recipes to a local registry
2. Search and list recipes
3. Pull recipes from the registry
Prerequisites:
- pip install praisonai
Usage:
python registry_example.py
"""
import json
import tarfile
import tempfile
from pathlib import Path
def create_sample_recipe(tmp_dir: Path) -> Path:
"""Create a sample recipe for demonstration."""
recipe_dir = tmp_dir / "hello-world"
recipe_dir.mkdir(parents=True, exist_ok=True)
# Create TEMPLATE.yaml
template_content = """
name: hello-world
version: "1.0.0"
description: A simple hello world recipe
author: example
tags:
- demo
- hello
- example
requires:
env:
- OPENAI_API_KEY
config:
input:
name:
type: string
required: true
description: Name to greet
"""
with open(recipe_dir / "TEMPLATE.yaml", "w") as f:
f.write(template_content.strip())
# Create main.py
main_content = """
def greet(name: str) -> str:
return f"Hello, {name}!"
if __name__ == "__main__":
print(greet("World"))
"""
with open(recipe_dir / "main.py", "w") as f:
f.write(main_content.strip())
return recipe_dir
def pack_recipe(recipe_dir: Path, output_path: Path) -> Path:
"""Pack a recipe into a .praison bundle."""
import hashlib
import io
from datetime import datetime, timezone
with tarfile.open(output_path, "w:gz") as tar:
manifest = {
"name": "hello-world",
"version": "1.0.0",
"description": "A simple hello world recipe",
"tags": ["demo", "hello", "example"],
"created_at": datetime.now(timezone.utc).isoformat(),
"files": [],
}
for file_path in recipe_dir.rglob("*"):
if file_path.is_file() and not file_path.name.startswith("."):
rel_path = file_path.relative_to(recipe_dir)
tar.add(file_path, arcname=str(rel_path))
with open(file_path, "rb") as f:
checksum = hashlib.sha256(f.read()).hexdigest()
manifest["files"].append({
"path": str(rel_path),
"checksum": checksum,
})
manifest_bytes = json.dumps(manifest, indent=2).encode()
manifest_info = tarfile.TarInfo(name="manifest.json")
manifest_info.size = len(manifest_bytes)
tar.addfile(manifest_info, io.BytesIO(manifest_bytes))
return output_path
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Recipe Registry Example")
print("=" * 60)
# Use a temporary directory for the example
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
# Create registry in temp directory
registry_path = tmp_path / "registry"
print("\n1. Creating local registry...")
from praisonai.recipe.registry import LocalRegistry
registry = LocalRegistry(registry_path)
print(f" Registry created at: {registry_path}")
# Create and pack a sample recipe
print("\n2. Creating sample recipe...")
recipe_dir = create_sample_recipe(tmp_path)
print(f" Recipe created at: {recipe_dir}")
print("\n3. Packing recipe into bundle...")
bundle_path = tmp_path / "hello-world-1.0.0.praison"
pack_recipe(recipe_dir, bundle_path)
print(f" Bundle created: {bundle_path}")
# Publish to registry
print("\n4. Publishing to registry...")
result = registry.publish(bundle_path)
print(f" Published: {result['name']}@{result['version']}")
print(f" Checksum: {result['checksum'][:16]}...")
# List recipes
print("\n5. Listing recipes in registry...")
recipes = registry.list_recipes()
for r in recipes:
print(f" - {r['name']} ({r['version']}): {r['description']}")
# Search recipes
print("\n6. Searching for 'hello'...")
results = registry.search("hello")
print(f" Found {len(results)} recipe(s)")
# Get versions
print("\n7. Getting versions of 'hello-world'...")
versions = registry.get_versions("hello-world")
print(f" Versions: {versions}")
# Pull recipe
print("\n8. Pulling recipe from registry...")
output_dir = tmp_path / "pulled"
pull_result = registry.pull("hello-world", output_dir=output_dir)
print(f" Pulled to: {pull_result['path']}")
# Verify pulled files
print("\n9. Verifying pulled files...")
pulled_recipe = output_dir / "hello-world"
for f in pulled_recipe.iterdir():
print(f" - {f.name}")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
# Run History Example
This example demonstrates how to use PraisonAI run history for storing, querying, and exporting recipe runs.
## Features Demonstrated
- Storing run results with input/output data
- Listing and filtering runs
- Querying run details
- Exporting runs for replay/debugging
- Storage statistics and cleanup
## Quick Start
```bash
python run_history_example.py
```
## CLI Commands
```bash
# List recent runs
praisonai recipe runs list
# List runs for a specific recipe
praisonai recipe runs list --recipe support-reply
# Get storage stats
praisonai recipe runs stats
# Export a run
praisonai recipe export run-abc123 -o export.json
# Replay a run
praisonai recipe replay export.json --compare
# Cleanup old runs
praisonai recipe runs cleanup
```
## Default Storage Location
Run history is stored at `~/.praison/runs`.
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
PraisonAI Run History Example
This example demonstrates how to:
1. Store recipe run results in history
2. List and query run history
3. Export runs for replay/debugging
Prerequisites:
- pip install praisonai
Usage:
python run_history_example.py
"""
import tempfile
from pathlib import Path
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Run History Example")
print("=" * 60)
# Use a temporary directory for the example
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
# Create history storage in temp directory
history_path = tmp_path / "runs"
print("\n1. Creating run history storage...")
from praisonai.recipe.history import RunHistory
from praisonai.recipe.models import RecipeResult, RecipeStatus
history = RunHistory(history_path)
print(f" Storage created at: {history_path}")
# Create sample run results
print("\n2. Storing sample run results...")
# Run 1: Successful run
result1 = RecipeResult(
run_id="run-abc123",
recipe="support-reply",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"reply": "Thank you for contacting us..."},
metrics={"duration_sec": 2.5, "tokens": 150},
trace={"session_id": "session-001", "trace_id": "trace-xyz"},
)
history.store(
result=result1,
input_data={"ticket_id": "T-123", "message": "I need help"},
)
print(f" Stored: {result1.run_id} (success)")
# Run 2: Failed run
result2 = RecipeResult(
run_id="run-def456",
recipe="code-review",
version="2.0.0",
status=RecipeStatus.FAILED,
error="Timeout exceeded",
metrics={"duration_sec": 30.0},
trace={"session_id": "session-002"},
)
history.store(
result=result2,
input_data={"code": "def hello(): pass"},
)
print(f" Stored: {result2.run_id} (failed)")
# Run 3: Another successful run
result3 = RecipeResult(
run_id="run-ghi789",
recipe="support-reply",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"reply": "Your issue has been resolved..."},
metrics={"duration_sec": 1.8},
trace={"session_id": "session-001"},
)
history.store(
result=result3,
input_data={"ticket_id": "T-456"},
)
print(f" Stored: {result3.run_id} (success)")
# List all runs
print("\n3. Listing all runs...")
runs = history.list_runs()
for run in runs:
status_icon = "✓" if run["status"] == "success" else "✗"
print(f" {status_icon} {run['run_id']} - {run['recipe']} ({run['status']})")
# Filter by recipe
print("\n4. Filtering runs by recipe 'support-reply'...")
filtered = history.list_runs(recipe="support-reply")
print(f" Found {len(filtered)} run(s)")
# Filter by session
print("\n5. Filtering runs by session 'session-001'...")
filtered = history.list_runs(session_id="session-001")
print(f" Found {len(filtered)} run(s)")
# Get specific run details
print("\n6. Getting details for run-abc123...")
run_data = history.get("run-abc123")
print(f" Recipe: {run_data['recipe']}")
print(f" Status: {run_data['status']}")
print(f" Input: {run_data.get('input', {})}")
print(f" Output: {run_data.get('output', {})}")
# Export a run
print("\n7. Exporting run-abc123...")
export_path = history.export("run-abc123", tmp_path / "export.json")
print(f" Exported to: {export_path}")
# Show export contents
import json
with open(export_path) as f:
export_data = json.load(f)
print(f" Format: {export_data['format']}")
print(f" Exported at: {export_data['exported_at']}")
# Get storage stats
print("\n8. Getting storage statistics...")
stats = history.get_stats()
print(f" Total runs: {stats['total_runs']}")
print(f" Storage size: {stats['total_size_bytes']} bytes")
# Cleanup old runs
print("\n9. Running cleanup...")
deleted = history.cleanup(retention_days=0) # Delete all for demo
print(f" Deleted {deleted} run(s)")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
# Security Features Example
This example demonstrates PraisonAI security features including SBOM generation, lockfile validation, dependency auditing, and PII redaction.
## Features Demonstrated
- **SBOM Generation**: Generate Software Bill of Materials in CycloneDX or SPDX format
- **Lockfile Validation**: Validate presence and format of dependency lockfiles
- **Dependency Auditing**: Audit dependencies for vulnerabilities
- **PII Redaction**: Detect and redact personally identifiable information
## Quick Start
```bash
python security_example.py
```
## CLI Commands
### SBOM Generation
```bash
# Generate CycloneDX SBOM
praisonai recipe sbom ./my-recipe --format cyclonedx -o sbom.json
# Generate SPDX SBOM
praisonai recipe sbom ./my-recipe --format spdx -o sbom.json
```
### Dependency Auditing
```bash
# Audit dependencies
praisonai recipe audit ./my-recipe
# Strict audit (fail on issues)
praisonai recipe audit ./my-recipe --strict
```
### Lockfile Validation
```bash
# Validate recipe (includes lockfile check)
praisonai recipe validate ./my-recipe --require-lockfile
```
## PII Redaction Configuration
In your `TEMPLATE.yaml`:
```yaml
data_policy:
pii:
mode: redact # allow, deny, or redact
fields:
- email
- phone
- ssn
- credit_card
```
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
PraisonAI Security Features Example
This example demonstrates:
1. SBOM (Software Bill of Materials) generation
2. Lockfile validation
3. Dependency auditing
4. PII redaction
Prerequisites:
- pip install praisonai
Usage:
python security_example.py
"""
import json
import tempfile
from pathlib import Path
def create_sample_recipe(tmp_dir: Path) -> Path:
"""Create a sample recipe with dependencies."""
recipe_dir = tmp_dir / "secure-recipe"
recipe_dir.mkdir(parents=True, exist_ok=True)
# Create TEMPLATE.yaml
import yaml
template = {
"name": "secure-recipe",
"version": "1.0.0",
"description": "A recipe with security features",
"requires": {
"tools": ["web_search", "file_reader"],
"external": ["ffmpeg"],
"env": ["OPENAI_API_KEY"],
},
}
with open(recipe_dir / "TEMPLATE.yaml", "w") as f:
yaml.dump(template, f)
# Create lock directory with requirements
lock_dir = recipe_dir / "lock"
lock_dir.mkdir()
with open(lock_dir / "requirements.lock", "w") as f:
f.write("openai==1.0.0\n")
f.write("requests==2.31.0\n")
f.write("pydantic==2.0.0\n")
return recipe_dir
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Security Features Example")
print("=" * 60)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
# Create sample recipe
print("\n1. Creating sample recipe with dependencies...")
recipe_dir = create_sample_recipe(tmp_path)
print(f" Recipe created at: {recipe_dir}")
# =====================================================
# SBOM Generation
# =====================================================
print("\n" + "=" * 40)
print("SBOM Generation")
print("=" * 40)
from praisonai.recipe.security import generate_sbom
print("\n2. Generating CycloneDX SBOM...")
sbom = generate_sbom(recipe_dir, format="cyclonedx")
print(f" Format: {sbom['bomFormat']}")
print(f" Spec Version: {sbom['specVersion']}")
print(f" Components: {len(sbom['components'])}")
print("\n Components:")
for comp in sbom["components"][:5]:
print(f" - {comp['name']}@{comp['version']} ({comp['type']})")
# Save SBOM
sbom_path = tmp_path / "sbom.json"
with open(sbom_path, "w") as f:
json.dump(sbom, f, indent=2)
print(f"\n SBOM saved to: {sbom_path}")
# =====================================================
# Lockfile Validation
# =====================================================
print("\n" + "=" * 40)
print("Lockfile Validation")
print("=" * 40)
from praisonai.recipe.security import validate_lockfile
print("\n3. Validating lockfile...")
result = validate_lockfile(recipe_dir)
print(f" Valid: {result['valid']}")
print(f" Lockfile: {result['lockfile']}")
print(f" Type: {result['lockfile_type']}")
# Test strict validation without lockfile
print("\n4. Testing strict validation on recipe without lockfile...")
no_lock_dir = tmp_path / "no-lock-recipe"
no_lock_dir.mkdir()
result = validate_lockfile(no_lock_dir, strict=True)
print(f" Valid: {result['valid']}")
print(f" Errors: {result['errors']}")
# =====================================================
# Dependency Auditing
# =====================================================
print("\n" + "=" * 40)
print("Dependency Auditing")
print("=" * 40)
from praisonai.recipe.security import audit_dependencies
print("\n5. Auditing dependencies...")
report = audit_dependencies(recipe_dir)
print(f" Recipe: {report['recipe']}")
print(f" Lockfile: {report['lockfile']}")
print(f" Dependencies: {len(report['dependencies'])}")
print(f" Vulnerabilities: {len(report['vulnerabilities'])}")
print(f" Warnings: {len(report['warnings'])}")
print(f" Passed: {report['passed']}")
if report["warnings"]:
print("\n Warnings:")
for warn in report["warnings"]:
print(f" - {warn}")
# =====================================================
# PII Redaction
# =====================================================
print("\n" + "=" * 40)
print("PII Redaction")
print("=" * 40)
from praisonai.recipe.security import redact_pii, detect_pii
# Sample data with PII
sample_data = {
"customer": {
"name": "John Doe",
"email": "[email protected]",
"phone": "555-123-4567",
"message": "Please contact me at [email protected]",
},
"ticket_id": "T-12345",
}
print("\n6. Detecting PII in sample data...")
detections = detect_pii(sample_data)
print(f" Found {len(detections)} PII instance(s):")
for d in detections:
print(f" - {d['type']} at {d['path']}")
print("\n7. Redacting PII...")
policy = {
"pii": {
"mode": "redact",
"fields": ["email", "phone"],
}
}
redacted = redact_pii(sample_data, policy)
print(" Original email:", sample_data["customer"]["email"])
print(" Redacted email:", redacted["customer"]["email"])
print(" Original phone:", sample_data["customer"]["phone"])
print(" Redacted phone:", redacted["customer"]["phone"])
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+142
View File
@@ -0,0 +1,142 @@
# PraisonAI Serve Examples
Examples for all PraisonAI server types and the unified endpoints CLI.
## Examples
| Example | Description | CLI Command |
|---------|-------------|-------------|
| `unified_server.py` | All providers in one server | `praisonai serve unified` |
| `agent_as_api_single.py` | Single agent HTTP API | `praisonai serve agents` |
| `agents_as_api_router.py` | Multi-agent router API | `praisonai serve agents` |
| `a2a_server_client.py` | A2A protocol server | `praisonai serve a2a` |
| `a2u_events_stream.py` | A2U event stream | `praisonai serve a2u` |
| `mcp_http_server.py` | MCP HTTP server | `praisonai serve mcp` |
| `tools_as_mcp_server.py` | Tools as MCP server | `praisonai serve tools` |
| `agent_launch_modes.py` | Agent.launch() API | Python only |
| `endpoints_unified_client.py` | Unified client | `praisonai endpoints` |
| `serve_example.py` | Recipe server client | `praisonai recipe serve` |
## Server Commands
| Command | Description |
|---------|-------------|
| `praisonai serve unified --port 8765` | Start unified server |
| `praisonai serve agents --file agents.yaml --port 8000` | Start agents API |
| `praisonai serve recipe --port 8765` | Start recipe server |
| `praisonai serve mcp --transport http --port 8080` | Start MCP server |
| `praisonai serve tools --port 8081` | Start tools MCP server |
| `praisonai serve a2a --port 8082` | Start A2A server |
| `praisonai serve a2u --port 8083` | Start A2U server |
## Client Commands
| Command | Description |
|---------|-------------|
| `praisonai endpoints list` | List all endpoints |
| `praisonai endpoints list --type agents-api` | Filter by type |
| `praisonai endpoints describe <name>` | Get endpoint details |
| `praisonai endpoints invoke <name> --input-json '{}'` | Invoke endpoint |
| `praisonai endpoints health` | Check server health |
| `praisonai endpoints types` | List provider types |
| `praisonai endpoints discovery` | Show discovery document |
## Quick Start
```bash
# Terminal 1: Start unified server
export OPENAI_API_KEY=your-key
praisonai serve unified --port 8765
# Terminal 2: Use endpoints CLI
praisonai endpoints health
praisonai endpoints list
praisonai endpoints types
```
## Provider Types
| Type | Description |
|------|-------------|
| `recipe` | Recipe runner endpoints |
| `agents-api` | Single/multi-agent HTTP API |
| `mcp` | MCP server (stdio, http, sse) |
| `tools-mcp` | Tools exposed as MCP server |
| `a2a` | Agent-to-agent protocol |
| `a2u` | Agent-to-user event stream |
## Configuration Options
| Option | Description | Default |
|--------|-------------|---------|
| `host` | Server bind address | 127.0.0.1 |
| `port` | Server port | 8765 |
| `auth` | Auth type (none, api-key, jwt) | none |
| `api_key` | API key for auth | - |
| `preload` | Preload recipes on startup | false |
| `recipes` | List of recipes to serve | all |
| `cors_origins` | CORS allowed origins | - |
## Advanced Features
| Feature | CLI Flag | Description | Docs |
|---------|----------|-------------|------|
| Rate Limiting | `--rate_limit 100` | Requests per minute per client | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| Request Size Limit | `--max_request_size 10485760` | Max request body (bytes) | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| Metrics Endpoint | `--enable_metrics` | Prometheus /metrics endpoint | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| Admin Reload | `--enable_admin` | Hot reload /admin/reload | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| Workers | `--workers 4` | Multi-process workers | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| OpenTelemetry | `--trace_exporter otlp` | Distributed tracing | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| OpenAPI | `GET /openapi.json` | API specification | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
## Advanced Examples
| Example | Description |
|---------|-------------|
| `recipe_serve_features.py` | All advanced features demo |
## Advanced CLI Commands
```bash
# Production server with all features
praisonai recipe serve \
--host 0.0.0.0 \
--port 8765 \
--auth api-key \
--workers 4 \
--rate_limit 100 \
--enable_metrics \
--enable_admin
# Get metrics
curl http://localhost:8765/metrics
# Get OpenAPI spec
curl http://localhost:8765/openapi.json
# Hot reload recipes
curl -X POST http://localhost:8765/admin/reload \
-H "X-API-Key: your-key"
```
## CLI Commands
```bash
# Health check
praisonai endpoints health
# List recipes (with auth)
praisonai endpoints list --api-key your-key
# Invoke recipe
praisonai endpoints invoke my-recipe \
--input-json '{"query": "Hello"}' \
--api-key your-key \
--json
```
## Security Notes
- Always use `--auth api-key` when binding to `0.0.0.0`
- Store API keys in environment variables, not config files
- Use HTTPS in production (via reverse proxy)
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
A2A (Agent-to-Agent) Server and Client Example
Demonstrates:
- Launching an agent as A2A server
- Agent card discovery
- Sending A2A messages
- JSON-RPC protocol
Usage:
# Run this example (starts server)
python a2a_server_client.py
# Or use CLI:
praisonai serve a2a --port 8082
# Test with curl:
curl http://localhost:8082/.well-known/agent.json
curl -X POST http://localhost:8082/a2a \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"message/send","id":"1","params":{"message":{"role":"user","parts":[{"type":"text","text":"Hello!"}]}}}'
"""
import os
import sys
import json
import threading
import time
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def run_server():
"""Run A2A server."""
try:
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
from fastapi import FastAPI
import uvicorn
except ImportError as e:
print(f"Error: Missing dependency - {e}")
print("Install with: pip install 'praisonaiagents[api]'")
sys.exit(1)
# Create agent
agent = Agent(
name="A2A-Assistant",
role="Helpful AI Assistant",
goal="Help other agents and users with their questions",
backstory="You are a knowledgeable assistant accessible via A2A protocol.",
llm="gpt-4o-mini"
)
# Create A2A interface
a2a = A2A(
agent=agent,
url="http://localhost:8082/a2a",
name="A2A-Assistant",
description="A helpful AI assistant accessible via A2A protocol"
)
# Create FastAPI app
app = FastAPI(title="PraisonAI A2A Server")
app.include_router(a2a.get_router())
# Add discovery endpoint
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-a2a",
"providers": [{"type": "a2a", "name": "A2A Protocol", "capabilities": ["agent-card", "message-send"]}],
"endpoints": [{"name": "a2a", "provider_type": "a2a", "streaming": ["sse"]}]
}
@app.get("/")
async def root():
return {
"message": "PraisonAI A2A Server",
"agent_card": "/.well-known/agent.json",
"a2a_endpoint": "/a2a",
"discovery": "/__praisonai__/discovery"
}
print("Starting A2A server on http://localhost:8082")
print("Agent Card: http://localhost:8082/.well-known/agent.json")
print("A2A Endpoint: http://localhost:8082/a2a")
uvicorn.run(app, host="127.0.0.1", port=8082, log_level="warning")
def run_client():
"""Run A2A client test."""
import urllib.request
import urllib.error
base_url = "http://localhost:8082"
print("\n--- A2A Client Test ---")
# 1. Get agent card
print("\n1. Getting Agent Card...")
try:
req = urllib.request.Request(f"{base_url}/.well-known/agent.json")
with urllib.request.urlopen(req, timeout=5) as resp:
agent_card = json.loads(resp.read().decode())
print(f" Agent: {agent_card.get('name')}")
print(f" URL: {agent_card.get('url')}")
print(f" Capabilities: {agent_card.get('capabilities', {})}")
except Exception as e:
print(f" Error: {e}")
return
# 2. Send A2A message
print("\n2. Sending A2A Message...")
message = {
"jsonrpc": "2.0",
"method": "message/send",
"id": "test-1",
"params": {
"message": {
"role": "user",
"parts": [{"type": "text", "text": "Hello! What can you help me with?"}]
}
}
}
try:
req = urllib.request.Request(
f"{base_url}/a2a",
data=json.dumps(message).encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as resp:
response = json.loads(resp.read().decode())
if "result" in response:
result_msg = response["result"].get("message", {})
parts = result_msg.get("parts", [])
for part in parts:
if part.get("type") == "text":
print(f" Response: {part.get('text', '')[:200]}...")
elif "error" in response:
print(f" Error: {response['error']}")
except Exception as e:
print(f" Error: {e}")
# 3. Check discovery
print("\n3. Checking Discovery...")
try:
req = urllib.request.Request(f"{base_url}/__praisonai__/discovery")
with urllib.request.urlopen(req, timeout=5) as resp:
discovery = json.loads(resp.read().decode())
print(f" Server: {discovery.get('server_name')}")
print(f" Providers: {[p.get('type') for p in discovery.get('providers', [])]}")
except Exception as e:
print(f" Error: {e}")
print("\n✓ A2A client test completed!")
def main():
print("=" * 60)
print("A2A (Agent-to-Agent) Server and Client Example")
print("=" * 60)
mode = sys.argv[1] if len(sys.argv) > 1 else "server"
if mode == "client":
run_client()
elif mode == "test":
# Start server in background, run client, then stop
print("Starting server in background...")
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
time.sleep(3) # Wait for server to start
run_client()
else:
print("\nStarting A2A server...")
print("Press Ctrl+C to stop")
print("\nTo test, run in another terminal:")
print(" python a2a_server_client.py client")
run_server()
if __name__ == "__main__":
main()
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
A2U (Agent-to-User) Event Stream Example
Demonstrates:
- Launching an A2U event stream server
- Subscribing to agent events
- SSE (Server-Sent Events) streaming
- Event filtering
Usage:
# Run this example (starts server)
python a2u_events_stream.py
# Or use CLI:
praisonai serve a2u --port 8083
# Test with curl:
curl http://localhost:8083/a2u/info
curl -N http://localhost:8083/a2u/events/events
"""
import os
import sys
import json
import threading
import time
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def run_server():
"""Run A2U server."""
try:
from fastapi import FastAPI
import uvicorn
from praisonai.endpoints.a2u_server import (
create_a2u_routes,
emit_agent_started,
emit_agent_thinking,
emit_agent_response,
emit_agent_completed,
)
except ImportError as e:
print(f"Error: Missing dependency - {e}")
print("Install with: pip install praisonai[serve]")
sys.exit(1)
app = FastAPI(title="PraisonAI A2U Server")
# Add A2U routes
create_a2u_routes(app)
# Add discovery endpoint
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-a2u",
"providers": [{"type": "a2u", "name": "A2U Event Stream", "capabilities": ["subscribe", "stream"]}],
"endpoints": [{"name": "events", "provider_type": "a2u", "streaming": ["sse"]}]
}
@app.get("/")
async def root():
return {
"message": "PraisonAI A2U Server",
"info": "/a2u/info",
"events": "/a2u/events/<stream>",
"discovery": "/__praisonai__/discovery"
}
# Demo endpoint to emit events
@app.post("/demo/emit")
async def demo_emit():
"""Emit demo events for testing."""
agent_id = "demo-agent-1"
emit_agent_started(agent_id, "DemoAgent")
emit_agent_thinking(agent_id, "Processing your request...")
emit_agent_response(agent_id, "Here is my response to your query.")
emit_agent_completed(agent_id, {"status": "success"})
return {"message": "Demo events emitted", "agent_id": agent_id}
print("Starting A2U server on http://localhost:8083")
print("Info: http://localhost:8083/a2u/info")
print("Events: http://localhost:8083/a2u/events/events")
print("Demo: POST http://localhost:8083/demo/emit")
uvicorn.run(app, host="127.0.0.1", port=8083, log_level="warning")
def run_client():
"""Run A2U client test."""
import urllib.request
import urllib.error
base_url = "http://localhost:8083"
print("\n--- A2U Client Test ---")
# 1. Get A2U info
print("\n1. Getting A2U Info...")
try:
req = urllib.request.Request(f"{base_url}/a2u/info")
with urllib.request.urlopen(req, timeout=5) as resp:
info = json.loads(resp.read().decode())
print(f" Name: {info.get('name')}")
print(f" Version: {info.get('version')}")
print(f" Streams: {info.get('streams')}")
print(f" Event Types: {info.get('event_types')}")
except Exception as e:
print(f" Error: {e}")
return
# 2. Subscribe to events
print("\n2. Subscribing to Events...")
try:
req = urllib.request.Request(
f"{base_url}/a2u/subscribe",
data=json.dumps({"stream": "events", "filters": []}).encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=5) as resp:
sub = json.loads(resp.read().decode())
print(f" Subscription ID: {sub.get('subscription_id')}")
print(f" Stream URL: {sub.get('stream_url')}")
except Exception as e:
print(f" Error: {e}")
# 3. Emit demo events
print("\n3. Emitting Demo Events...")
try:
req = urllib.request.Request(
f"{base_url}/demo/emit",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode())
print(f" {result.get('message')}")
print(f" Agent ID: {result.get('agent_id')}")
except Exception as e:
print(f" Error: {e}")
# 4. Check discovery
print("\n4. Checking Discovery...")
try:
req = urllib.request.Request(f"{base_url}/__praisonai__/discovery")
with urllib.request.urlopen(req, timeout=5) as resp:
discovery = json.loads(resp.read().decode())
print(f" Server: {discovery.get('server_name')}")
print(f" Providers: {[p.get('type') for p in discovery.get('providers', [])]}")
except Exception as e:
print(f" Error: {e}")
print("\n✓ A2U client test completed!")
print("\nTo stream events, run:")
print(f" curl -N {base_url}/a2u/events/events")
def main():
print("=" * 60)
print("A2U (Agent-to-User) Event Stream Example")
print("=" * 60)
mode = sys.argv[1] if len(sys.argv) > 1 else "server"
if mode == "client":
run_client()
elif mode == "test":
print("Starting server in background...")
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
time.sleep(2)
run_client()
else:
print("\nStarting A2U server...")
print("Press Ctrl+C to stop")
print("\nTo test, run in another terminal:")
print(" python a2u_events_stream.py client")
run_server()
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Single Agent as HTTP API Example
Demonstrates launching a single agent as an HTTP API server with:
- REST endpoint for queries
- Streaming support (SSE)
- Discovery endpoint
- Health check
Usage:
# Run this example
python agent_as_api_single.py
# Or use CLI:
praisonai serve agents --file agents.yaml --port 8000
# Test with curl:
curl -X POST http://localhost:8000/agent \
-H "Content-Type: application/json" \
-d '{"query": "What is AI?"}'
"""
import os
import sys
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def main():
print("=" * 60)
print("Single Agent as HTTP API Example")
print("=" * 60)
try:
from praisonaiagents import Agent
except ImportError:
print("Error: praisonaiagents not installed")
print("Install with: pip install praisonaiagents")
sys.exit(1)
# Create a single agent
agent = Agent(
name="Assistant",
role="Helpful AI Assistant",
goal="Help users with their questions accurately and concisely",
backstory="You are a knowledgeable assistant who provides clear, helpful answers.",
llm="gpt-4o-mini",
verbose=True
)
print(f"Agent: {agent.name}")
print(f"Role: {agent.role}")
# Launch as HTTP API
print("\nLaunching agent as HTTP API...")
print("Endpoints:")
print(" POST /agent - Query the agent")
print(" GET /health - Health check")
print(" GET /__praisonai__/discovery - Discovery document")
print("\nPress Ctrl+C to stop")
try:
# Use Agent.launch() to start HTTP server
agent.launch(
port=8000,
host="127.0.0.1",
path="/agent"
)
except KeyboardInterrupt:
print("\nServer stopped")
except Exception as e:
print(f"Error launching server: {e}")
# Fallback: manual FastAPI setup
print("\nFallback: Using manual FastAPI setup...")
launch_manual(agent)
def launch_manual(agent):
"""Manual FastAPI server setup as fallback."""
try:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
print("Install with: pip install fastapi uvicorn")
return
app = FastAPI(title="PraisonAI Agent API")
@app.post("/agent")
async def query_agent(request: Request):
try:
body = await request.json()
query = body.get("query", "")
if not query:
return JSONResponse({"error": "query required"}, status_code=400)
response = agent.chat(query)
return {"response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/health")
async def health():
return {"status": "healthy", "agent": agent.name}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-agent",
"providers": [{"type": "agents-api", "name": "Single Agent API"}],
"endpoints": [{"name": "agent", "provider_type": "agents-api"}]
}
@app.get("/")
async def root():
return {
"message": f"PraisonAI Agent: {agent.name}",
"endpoints": ["/agent", "/health", "/__praisonai__/discovery"]
}
uvicorn.run(app, host="127.0.0.1", port=8000)
if __name__ == "__main__":
main()
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""
Agent.launch() and Agents.launch() Example
Demonstrates the high-level Python API for launching servers:
- Agent.launch() for single agent
- Agents.launch() for multi-agent
- Different server modes (HTTP, MCP, A2A)
Usage:
# Run this example
python agent_launch_modes.py
# Test endpoints:
curl http://localhost:8000/agent
curl http://localhost:8000/health
"""
import os
import sys
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def demo_single_agent_launch():
"""Demo Agent.launch() for single agent."""
print("\n--- Single Agent Launch ---")
try:
from praisonaiagents import Agent
except ImportError:
print("Error: praisonaiagents not installed")
return False
agent = Agent(
name="Assistant",
role="Helpful AI Assistant",
goal="Help users with their questions",
llm="gpt-4o-mini"
)
print(f"Agent: {agent.name}")
print("Launching as HTTP API on port 8000...")
print("\nEndpoints:")
print(" POST /agent - Query the agent")
print(" GET /health - Health check")
print("\nPress Ctrl+C to stop")
try:
agent.launch(port=8000, host="127.0.0.1", path="/agent")
except AttributeError:
print("Agent.launch() not available, using fallback...")
launch_single_fallback(agent)
except KeyboardInterrupt:
print("\nStopped")
return True
def demo_multi_agent_launch():
"""Demo Agents.launch() for multi-agent."""
print("\n--- Multi-Agent Launch ---")
try:
from praisonaiagents import Agent, PraisonAIAgents
except ImportError:
print("Error: praisonaiagents not installed")
return False
researcher = Agent(
name="Researcher",
role="Research Specialist",
goal="Find accurate information",
llm="gpt-4o-mini"
)
writer = Agent(
name="Writer",
role="Content Writer",
goal="Create engaging content",
llm="gpt-4o-mini"
)
agents = PraisonAIAgents(agents=[researcher, writer])
print(f"Agents: {[researcher.name, writer.name]}")
print("Launching as HTTP API on port 8000...")
print("\nEndpoints:")
print(" POST /agents - Route to best agent")
print(" POST /agents/researcher - Query researcher")
print(" POST /agents/writer - Query writer")
print(" GET /agents/list - List agents")
print("\nPress Ctrl+C to stop")
try:
agents.launch(port=8000, host="127.0.0.1")
except AttributeError:
print("Agents.launch() not available, using fallback...")
launch_multi_fallback([researcher, writer])
except KeyboardInterrupt:
print("\nStopped")
return True
def launch_single_fallback(agent):
"""Fallback implementation for Agent.launch()."""
try:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
return
app = FastAPI(title=f"PraisonAI Agent: {agent.name}")
@app.post("/agent")
async def query(request: Request):
try:
body = await request.json()
query_text = body.get("query", "")
response = agent.chat(query_text)
return {"response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/health")
async def health():
return {"status": "healthy", "agent": agent.name}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": f"praisonai-{agent.name.lower()}",
"providers": [{"type": "agents-api", "name": "Single Agent API"}],
"endpoints": [{"name": "agent", "provider_type": "agents-api"}]
}
uvicorn.run(app, host="127.0.0.1", port=8000)
def launch_multi_fallback(agents):
"""Fallback implementation for Agents.launch()."""
try:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
return
app = FastAPI(title="PraisonAI Multi-Agent API")
agent_map = {a.name.lower(): a for a in agents}
@app.post("/agents")
async def route(request: Request):
try:
body = await request.json()
query_text = body.get("query", "")
# Simple routing
agent = agents[0]
response = agent.chat(query_text)
return {"agent": agent.name, "response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/agents/{name}")
async def query_agent(name: str, request: Request):
agent = agent_map.get(name.lower())
if not agent:
return JSONResponse({"error": f"Agent not found: {name}"}, status_code=404)
try:
body = await request.json()
response = agent.chat(body.get("query", ""))
return {"agent": agent.name, "response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/agents/list")
async def list_agents():
return {"agents": [{"name": a.name, "role": a.role} for a in agents]}
@app.get("/health")
async def health():
return {"status": "healthy", "agents": len(agents)}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-agents",
"providers": [{"type": "agents-api", "name": "Multi-Agent API"}],
"endpoints": [
{"name": "agents", "provider_type": "agents-api"},
*[{"name": f"agents/{a.name.lower()}", "provider_type": "agents-api"} for a in agents]
]
}
uvicorn.run(app, host="127.0.0.1", port=8000)
def main():
print("=" * 60)
print("Agent.launch() and Agents.launch() Example")
print("=" * 60)
mode = sys.argv[1] if len(sys.argv) > 1 else "single"
if mode == "multi":
demo_multi_agent_launch()
else:
demo_single_agent_launch()
if __name__ == "__main__":
main()
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Multi-Agent Router as HTTP API Example
Demonstrates launching multiple agents as an HTTP API with:
- Router endpoint for automatic agent selection
- Per-agent endpoints
- Streaming support (SSE)
- Discovery endpoint
Usage:
# Run this example
python agents_as_api_router.py
# Or use CLI:
praisonai serve agents --file agents.yaml --port 8000
# Test with curl:
curl -X POST http://localhost:8000/agents \
-H "Content-Type: application/json" \
-d '{"query": "Research AI trends"}'
"""
import os
import sys
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def main():
print("=" * 60)
print("Multi-Agent Router as HTTP API Example")
print("=" * 60)
try:
from praisonaiagents import Agent, PraisonAIAgents
except ImportError:
print("Error: praisonaiagents not installed")
print("Install with: pip install praisonaiagents")
sys.exit(1)
# Create multiple agents
researcher = Agent(
name="Researcher",
role="Research Specialist",
goal="Find accurate and relevant information",
backstory="Expert at finding and synthesizing information from various sources.",
llm="gpt-4o-mini"
)
writer = Agent(
name="Writer",
role="Content Writer",
goal="Create engaging content from research",
backstory="Skilled writer who transforms research into readable content.",
llm="gpt-4o-mini"
)
analyst = Agent(
name="Analyst",
role="Data Analyst",
goal="Analyze data and provide insights",
backstory="Expert at analyzing data and extracting meaningful insights.",
llm="gpt-4o-mini"
)
agents = [researcher, writer, analyst]
print(f"Agents: {[a.name for a in agents]}")
# Create multi-agent system
praison = PraisonAIAgents(agents=agents)
print("\nLaunching multi-agent router as HTTP API...")
print("Endpoints:")
print(" POST /agents - Route to best agent")
print(" POST /agents/researcher - Query researcher")
print(" POST /agents/writer - Query writer")
print(" POST /agents/analyst - Query analyst")
print(" GET /agents/list - List all agents")
print(" GET /health - Health check")
print(" GET /__praisonai__/discovery - Discovery document")
print("\nPress Ctrl+C to stop")
try:
# Use Agents.launch() to start HTTP server
praison.launch(
port=8000,
host="127.0.0.1"
)
except KeyboardInterrupt:
print("\nServer stopped")
except Exception as e:
print(f"Error launching server: {e}")
print("\nFallback: Using manual FastAPI setup...")
launch_manual(agents)
def launch_manual(agents):
"""Manual FastAPI server setup as fallback."""
try:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
print("Install with: pip install fastapi uvicorn")
return
app = FastAPI(title="PraisonAI Multi-Agent API")
# Create agent lookup
agent_map = {a.name.lower(): a for a in agents}
@app.post("/agents")
async def route_query(request: Request):
"""Route query to best agent."""
try:
body = await request.json()
query = body.get("query", "")
if not query:
return JSONResponse({"error": "query required"}, status_code=400)
# Simple routing based on keywords
query_lower = query.lower()
if any(w in query_lower for w in ["research", "find", "search"]):
agent = agent_map.get("researcher", agents[0])
elif any(w in query_lower for w in ["write", "create", "draft"]):
agent = agent_map.get("writer", agents[0])
elif any(w in query_lower for w in ["analyze", "data", "insight"]):
agent = agent_map.get("analyst", agents[0])
else:
agent = agents[0]
response = agent.chat(query)
return {"agent": agent.name, "response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/agents/{agent_name}")
async def query_agent(agent_name: str, request: Request):
"""Query specific agent."""
agent = agent_map.get(agent_name.lower())
if not agent:
return JSONResponse({"error": f"Agent not found: {agent_name}"}, status_code=404)
try:
body = await request.json()
query = body.get("query", "")
response = agent.chat(query)
return {"agent": agent.name, "response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/agents/list")
async def list_agents():
return {"agents": [{"name": a.name, "role": a.role} for a in agents]}
@app.get("/health")
async def health():
return {"status": "healthy", "agents": len(agents)}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-agents-router",
"providers": [{"type": "agents-api", "name": "Multi-Agent Router"}],
"endpoints": [
{"name": "agents", "provider_type": "agents-api"},
*[{"name": f"agents/{a.name.lower()}", "provider_type": "agents-api"} for a in agents]
]
}
@app.get("/")
async def root():
return {
"message": "PraisonAI Multi-Agent Router",
"agents": [a.name for a in agents],
"endpoints": ["/agents", "/agents/list", "/health"]
}
uvicorn.run(app, host="127.0.0.1", port=8000)
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Unified Endpoints Client Example
Demonstrates using the praisonai endpoints CLI to interact with any PraisonAI server.
Works with recipe, agents-api, mcp, a2a, a2u, and tools-mcp servers.
Usage:
# Start any server first (e.g., unified server)
praisonai serve unified --port 8765
# Then run this example
python endpoints_unified_client.py
# Or use CLI directly:
praisonai endpoints list
praisonai endpoints types
praisonai endpoints health
praisonai endpoints discovery
"""
import json
import os
import subprocess
import sys
def run_cli(args: list) -> tuple:
"""Run praisonai endpoints CLI command."""
cmd = ["python3", "-m", "praisonai.cli.main", "endpoints"] + args
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode, result.stdout, result.stderr
def main():
print("=" * 60)
print("PraisonAI Unified Endpoints Client Example")
print("=" * 60)
server_url = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
print(f"Server URL: {server_url}")
# 1. List provider types
print("\n--- Provider Types ---")
code, out, err = run_cli(["types", "--format", "json"])
if code == 0:
types = json.loads(out)
print(f"Supported types: {[t['type'] for t in types]}")
else:
print(f"Error: {err}")
# 2. Health check
print("\n--- Health Check ---")
code, out, err = run_cli(["health", "--url", server_url])
if code == 0:
print("✓ Server is healthy")
print(out.strip())
elif code == 8:
print("✗ Connection error - server not running")
print("\nStart a server first:")
print(" praisonai serve unified --port 8765")
print(" praisonai serve agents --file agents.yaml --port 8765")
print(" praisonai serve a2a --port 8765")
return 1
else:
print(f"✗ Health check failed: {err}")
return 1
# 3. Discovery document
print("\n--- Discovery Document ---")
code, out, err = run_cli(["discovery", "--url", server_url])
if code == 0:
try:
discovery = json.loads(out)
print(f"Server: {discovery.get('server_name', 'unknown')}")
print(f"Schema version: {discovery.get('schema_version', 'unknown')}")
providers = discovery.get('providers', [])
print(f"Providers: {[p.get('type') for p in providers]}")
endpoints = discovery.get('endpoints', [])
print(f"Endpoints: {[e.get('name') for e in endpoints]}")
except json.JSONDecodeError:
print(out.strip())
else:
print(f"Discovery not available: {err}")
# 4. List endpoints
print("\n--- List Endpoints ---")
code, out, err = run_cli(["list", "--url", server_url, "--format", "json"])
if code == 0:
try:
endpoints = json.loads(out)
if isinstance(endpoints, list):
for ep in endpoints[:5]: # Show first 5
print(f" - {ep.get('name')}: {ep.get('description', '')[:50]}")
else:
print(out.strip()[:200])
except json.JSONDecodeError:
print(out.strip()[:200])
else:
print(f"List failed: {err}")
# 5. Filter by type
print("\n--- Filter by Provider Type ---")
for ptype in ["agents-api", "a2a", "recipe"]:
code, out, err = run_cli(["list", "--url", server_url, "--type", ptype])
if code == 0 and out.strip():
print(f" {ptype}: {len(out.strip().split(chr(10)))} endpoints")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
MCP HTTP Server Example
Demonstrates launching an MCP server with HTTP transport:
- HTTP-based MCP protocol
- Tool registration
- Discovery endpoint
Usage:
# Run this example
python mcp_http_server.py
# Or use CLI:
praisonai serve mcp --transport http --port 8080
# Test with curl:
curl http://localhost:8080/mcp/tools
curl -X POST http://localhost:8080/mcp/tools/call \
-H "Content-Type: application/json" \
-d '{"tool": "search", "arguments": {"query": "AI news"}}'
"""
import sys
def main():
print("=" * 60)
print("MCP HTTP Server Example")
print("=" * 60)
try:
from fastapi import FastAPI
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
print("Install with: pip install fastapi uvicorn")
sys.exit(1)
# Define tools
def search(query: str) -> str:
"""Search the web for information."""
return f"Search results for: {query}"
def calculate(expression: str) -> str:
"""Calculate a math expression safely."""
try:
# Safe eval for basic math
allowed = set("0123456789+-*/.() ")
if all(c in allowed for c in expression):
return str(eval(expression))
return "Error: Invalid expression"
except Exception as e:
return f"Error: {e}"
def get_weather(city: str) -> str:
"""Get weather for a city (mock)."""
return f"Weather in {city}: Sunny, 72°F"
tools = {
"search": {"func": search, "description": "Search the web for information"},
"calculate": {"func": calculate, "description": "Calculate a math expression"},
"get_weather": {"func": get_weather, "description": "Get weather for a city"},
}
app = FastAPI(title="PraisonAI MCP Server")
@app.get("/mcp/tools")
async def list_tools():
"""List available MCP tools."""
return {
"tools": [
{
"name": name,
"description": info["description"],
"inputSchema": {
"type": "object",
"properties": {
list(info["func"].__code__.co_varnames)[0]: {"type": "string"}
},
"required": [list(info["func"].__code__.co_varnames)[0]]
}
}
for name, info in tools.items()
]
}
@app.post("/mcp/tools/call")
async def call_tool(request: dict):
"""Call an MCP tool."""
tool_name = request.get("tool")
arguments = request.get("arguments", {})
if tool_name not in tools:
return {"error": f"Tool not found: {tool_name}"}
try:
func = tools[tool_name]["func"]
# Get first argument
arg_name = list(func.__code__.co_varnames)[0]
arg_value = arguments.get(arg_name, "")
result = func(arg_value)
return {"result": result, "tool": tool_name}
except Exception as e:
return {"error": str(e)}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-mcp",
"providers": [{"type": "mcp", "name": "MCP Server", "capabilities": ["list-tools", "call-tool"]}],
"endpoints": [{"name": "mcp", "provider_type": "mcp"}]
}
@app.get("/health")
async def health():
return {"status": "healthy", "tools": len(tools)}
@app.get("/")
async def root():
return {
"message": "PraisonAI MCP Server (HTTP)",
"tools": list(tools.keys()),
"endpoints": ["/mcp/tools", "/mcp/tools/call", "/health"]
}
print(f"Tools: {list(tools.keys())}")
print("\nStarting MCP HTTP server on http://localhost:8080")
print("List tools: GET http://localhost:8080/mcp/tools")
print("Call tool: POST http://localhost:8080/mcp/tools/call")
print("\nPress Ctrl+C to stop")
uvicorn.run(app, host="127.0.0.1", port=8080)
if __name__ == "__main__":
main()
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""
PraisonAI Recipe Server Advanced Features Example
Demonstrates:
1. Rate limiting
2. Request size limits
3. Metrics endpoint
4. Admin reload endpoint
5. OpenAPI specification
6. Workers configuration
7. OpenTelemetry tracing
Prerequisites:
- pip install praisonai[serve]
- Set OPENAI_API_KEY environment variable
Usage:
# Run this example
python recipe_serve_features.py
"""
import json
import os
import urllib.request
import urllib.error
# Configuration
SERVER_URL = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
API_KEY = os.environ.get("PRAISONAI_API_KEY", "test-api-key")
def make_request(method: str, path: str, data: dict = None, headers: dict = None) -> dict:
"""Make HTTP request to server."""
url = f"{SERVER_URL}{path}"
default_headers = {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
}
if headers:
default_headers.update(headers)
body = json.dumps(data).encode() if data else None
try:
req = urllib.request.Request(url, data=body, headers=default_headers, method=method)
with urllib.request.urlopen(req, timeout=30) as response:
return {
"status": response.status,
"data": json.loads(response.read().decode()),
"headers": dict(response.headers)
}
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
try:
error_data = json.loads(body)
except json.JSONDecodeError:
error_data = {"raw": body}
return {
"status": e.code,
"error": error_data,
"headers": dict(e.headers) if hasattr(e, 'headers') else {}
}
except urllib.error.URLError as e:
return {"status": 0, "error": str(e.reason)}
def test_health():
"""Test health endpoint."""
print("\n" + "=" * 60)
print("TEST: Health Check")
print("=" * 60)
result = make_request("GET", "/health")
if result.get("status") == 200:
data = result["data"]
print("✓ Server healthy")
print(f" Service: {data.get('service')}")
print(f" Version: {data.get('version')}")
return True
else:
print(f"✗ Server unhealthy: {result.get('error')}")
return False
def test_openapi():
"""Test OpenAPI specification endpoint."""
print("\n" + "=" * 60)
print("TEST: OpenAPI Specification")
print("=" * 60)
result = make_request("GET", "/openapi.json")
if result.get("status") == 200:
spec = result["data"]
print("✓ OpenAPI spec available")
print(f" Version: {spec.get('openapi')}")
print(f" Title: {spec.get('info', {}).get('title')}")
print(f" Paths: {list(spec.get('paths', {}).keys())[:5]}...")
return True
else:
print(f"✗ OpenAPI not available: {result.get('error')}")
return False
def test_metrics():
"""Test metrics endpoint."""
print("\n" + "=" * 60)
print("TEST: Metrics Endpoint")
print("=" * 60)
result = make_request("GET", "/metrics")
if result.get("status") == 200:
# Metrics returns plain text, not JSON
print("✓ Metrics endpoint available")
print(" (Prometheus format)")
return True
elif result.get("status") == 404:
print("⚠ Metrics endpoint not enabled")
print(" Enable with: --enable_metrics")
return True # Not a failure, just not enabled
else:
print(f"✗ Metrics error: {result.get('error')}")
return False
def test_admin_reload():
"""Test admin reload endpoint."""
print("\n" + "=" * 60)
print("TEST: Admin Reload Endpoint")
print("=" * 60)
result = make_request("POST", "/admin/reload")
if result.get("status") == 200:
data = result["data"]
print("✓ Admin reload successful")
print(f" Status: {data.get('status')}")
print(f" Timestamp: {data.get('timestamp')}")
return True
elif result.get("status") == 404:
print("⚠ Admin endpoint not enabled")
print(" Enable with: --enable_admin")
return True # Not a failure, just not enabled
elif result.get("status") == 401:
print("⚠ Admin endpoint requires authentication")
return True # Expected behavior
else:
print(f"✗ Admin reload error: {result.get('error')}")
return False
def test_rate_limiting():
"""Test rate limiting."""
print("\n" + "=" * 60)
print("TEST: Rate Limiting")
print("=" * 60)
# Make rapid requests
rate_limited = False
retry_after = None
for i in range(20):
result = make_request("GET", "/v1/recipes")
if result.get("status") == 429:
rate_limited = True
retry_after = result.get("headers", {}).get("Retry-After")
error = result.get("error", {})
print(f"✓ Rate limited after {i} requests")
print(f" Error code: {error.get('error', {}).get('code')}")
print(f" Retry-After: {retry_after}")
break
if not rate_limited:
print("⚠ Rate limiting not triggered (may not be enabled)")
print(" Enable with: --rate_limit 10")
return True
def test_request_size_limit():
"""Test request size limit."""
print("\n" + "=" * 60)
print("TEST: Request Size Limit")
print("=" * 60)
# Create a large payload
large_data = {
"recipe": "test",
"input": {"data": "x" * 100000} # ~100KB
}
result = make_request("POST", "/v1/recipes/run", large_data)
if result.get("status") == 413:
error = result.get("error", {})
print("✓ Request size limit enforced")
print(f" Error code: {error.get('error', {}).get('code')}")
return True
elif result.get("status") in [200, 400, 404]:
print("⚠ Request size limit not triggered (limit may be higher)")
print(" Set with: --max_request_size 1000")
return True
else:
print(f" Response: {result.get('status')} - {result.get('error')}")
return True
def test_list_recipes():
"""Test listing recipes."""
print("\n" + "=" * 60)
print("TEST: List Recipes")
print("=" * 60)
result = make_request("GET", "/v1/recipes")
if result.get("status") == 200:
recipes = result["data"].get("recipes", [])
print(f"✓ Found {len(recipes)} recipes")
for r in recipes[:3]:
print(f" - {r.get('name')} ({r.get('version', 'unknown')})")
if len(recipes) > 3:
print(f" ... and {len(recipes) - 3} more")
return True
elif result.get("status") == 401:
print("⚠ Authentication required")
return True
else:
print(f"✗ Error: {result.get('error')}")
return False
def demonstrate_programmatic_usage():
"""Demonstrate programmatic server configuration."""
print("\n" + "=" * 60)
print("DEMO: Programmatic Configuration")
print("=" * 60)
print("""
# Rate Limiter
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=100)
allowed, retry_after = limiter.check("client-id")
# Metrics Collector
from praisonai.recipe.serve import MetricsCollector
metrics = MetricsCollector()
metrics.record_request("/health", "GET", 200, 0.01)
print(metrics.get_prometheus_metrics())
# OpenAPI Spec
from praisonai.recipe.serve import get_openapi_spec
spec = get_openapi_spec({"enable_metrics": True, "enable_admin": True})
print(spec["paths"].keys())
# Template Search Paths
from praisonai.recipe.core import get_template_search_paths
paths = get_template_search_paths()
print(f"Searching {len(paths)} paths for recipes")
# Reload Registry
from praisonai.recipe.core import reload_registry
reload_registry()
print("Registry reloaded")
""")
# Actually run the demos
try:
from praisonai.recipe.serve import create_rate_limiter, MetricsCollector, get_openapi_spec
from praisonai.recipe.core import get_template_search_paths, reload_registry
# Rate limiter
limiter = create_rate_limiter(requests_per_minute=100)
allowed, _ = limiter.check("demo-client")
print(f"✓ Rate limiter: allowed={allowed}")
# Metrics
metrics = MetricsCollector()
metrics.record_request("/demo", "GET", 200, 0.01)
prom = metrics.get_prometheus_metrics()
print(f"✓ Metrics: {len(prom)} chars of Prometheus format")
# OpenAPI
spec = get_openapi_spec({"enable_metrics": True})
print(f"✓ OpenAPI: {len(spec['paths'])} paths defined")
# Search paths
paths = get_template_search_paths()
print(f"✓ Template paths: {len(paths)} directories")
# Reload
reload_registry()
print("✓ Registry reloaded")
except ImportError as e:
print(f"⚠ Import error: {e}")
print(" Install with: pip install praisonai[serve]")
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Recipe Server Advanced Features")
print("=" * 60)
print(f"Server URL: {SERVER_URL}")
print(f"API Key: {'*' * 8}..." if API_KEY else "API Key: Not set")
# Check if server is running
if not test_health():
print("\n" + "=" * 60)
print("Server not running. Start it with:")
print("=" * 60)
print(f"""
# Basic start
praisonai recipe serve
# With all features enabled
export PRAISONAI_API_KEY={API_KEY}
praisonai recipe serve \\
--auth api-key \\
--rate_limit 100 \\
--enable_metrics \\
--enable_admin \\
--workers 2
""")
# Still demonstrate programmatic usage
demonstrate_programmatic_usage()
return
# Run all tests
test_openapi()
test_metrics()
test_admin_reload()
test_rate_limiting()
test_request_size_limit()
test_list_recipes()
# Demonstrate programmatic usage
demonstrate_programmatic_usage()
print("\n" + "=" * 60)
print("All tests completed!")
print("=" * 60)
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
# PraisonAI Recipe Server Configuration
# This file configures the recipe HTTP server
# Server binding
host: 127.0.0.1
port: 8765
# Authentication
# Options: none, api-key, jwt
auth: api-key
# API key for authentication (or use PRAISONAI_API_KEY env var)
# api_key: your-secret-key-here
# Recipe filtering (optional)
# Only serve these specific recipes
# recipes:
# - my-recipe
# - another-recipe
# Performance
# Preload recipes on startup for faster first request
preload: true
# CORS configuration (for web clients)
# cors_origins: "https://app.example.com,https://admin.example.com"
# Logging level
# log_level: info
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
PraisonAI Recipe Server Example
This example demonstrates how to:
1. Start the recipe server with configuration
2. Use API key authentication
3. Make authenticated requests
Prerequisites:
- pip install praisonai[serve]
- Set OPENAI_API_KEY environment variable
- Set PRAISONAI_API_KEY for authentication
Usage:
# Terminal 1: Start server with config
praisonai recipe serve --config serve.yaml
# Terminal 2: Run this example
python serve_example.py
"""
import json
import os
import sys
import urllib.request
import urllib.error
# Configuration
SERVER_URL = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
API_KEY = os.environ.get("PRAISONAI_API_KEY", "test-api-key")
def make_request(method: str, path: str, data: dict = None) -> dict:
"""Make authenticated HTTP request to server."""
url = f"{SERVER_URL}{path}"
headers = {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
}
body = json.dumps(data).encode() if data else None
try:
req = urllib.request.Request(url, data=body, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as response:
return {"status": response.status, "data": json.loads(response.read().decode())}
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
return {"status": e.code, "error": body}
except urllib.error.URLError as e:
return {"status": 0, "error": str(e.reason)}
def check_health():
"""Check server health."""
print("\n=== Health Check ===")
result = make_request("GET", "/health")
if result.get("status") == 200:
data = result["data"]
print("✓ Server healthy")
print(f" Service: {data.get('service')}")
print(f" Version: {data.get('version')}")
return True
else:
print(f"✗ Server unhealthy: {result.get('error')}")
return False
def list_recipes():
"""List available recipes."""
print("\n=== List Recipes ===")
result = make_request("GET", "/v1/recipes")
if result.get("status") == 200:
recipes = result["data"].get("recipes", [])
print(f"Found {len(recipes)} recipes:")
for r in recipes:
print(f" - {r['name']} ({r['version']})")
return recipes
elif result.get("status") == 401:
print("✗ Authentication failed - check API key")
return []
else:
print(f"✗ Error: {result.get('error')}")
return []
def describe_recipe(name: str):
"""Get recipe details."""
print(f"\n=== Describe Recipe: {name} ===")
result = make_request("GET", f"/v1/recipes/{name}")
if result.get("status") == 200:
info = result["data"]
print(f"Name: {info.get('name')}")
print(f"Version: {info.get('version')}")
print(f"Description: {info.get('description')}")
return info
else:
print(f"✗ Error: {result.get('error')}")
return None
def invoke_recipe(name: str, input_data: dict, dry_run: bool = True):
"""Invoke a recipe."""
print(f"\n=== Invoke Recipe: {name} (dry_run={dry_run}) ===")
result = make_request("POST", "/v1/recipes/run", {
"recipe": name,
"input": input_data,
"options": {"dry_run": dry_run}
})
if result.get("status") == 200:
data = result["data"]
print("✓ Success")
print(f" Run ID: {data.get('run_id')}")
print(f" Status: {data.get('status')}")
if data.get("output"):
print(f" Output: {json.dumps(data.get('output'), indent=2)[:200]}...")
return data
elif result.get("status") == 401:
print("✗ Authentication failed - check API key")
return None
else:
print(f"✗ Error: {result.get('error')}")
return None
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Recipe Server Example")
print("=" * 60)
print(f"Server URL: {SERVER_URL}")
print(f"API Key: {API_KEY[:8]}..." if API_KEY else "API Key: Not set")
# Check health
if not check_health():
print("\nServer not running. Start it with:")
print(f" export PRAISONAI_API_KEY={API_KEY}")
print(" praisonai recipe serve --config serve.yaml")
sys.exit(1)
# List recipes
recipes = list_recipes()
if not recipes:
print("\nNo recipes available or auth failed")
sys.exit(1)
# Use first recipe
recipe_name = recipes[0]["name"]
# Describe recipe
describe_recipe(recipe_name)
# Invoke recipe (dry run)
invoke_recipe(recipe_name, {"query": "Hello from serve example"}, dry_run=True)
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
Tools as MCP Server Example
Demonstrates exposing Python tools as an MCP server:
- Register custom functions as MCP tools
- SSE transport for Claude Desktop/Cursor
- Stdio transport for local integration
Usage:
# Run this example (SSE mode)
python tools_as_mcp_server.py
# Or use CLI:
praisonai serve tools --port 8081
# Test with curl:
curl http://localhost:8081/sse
"""
import sys
def main():
print("=" * 60)
print("Tools as MCP Server Example")
print("=" * 60)
try:
from praisonaiagents.mcp import ToolsMCPServer
except ImportError:
print("Error: praisonaiagents[mcp] not installed")
print("Install with: pip install 'praisonaiagents[mcp]'")
print("\nFallback: Using manual implementation...")
run_manual_server()
return
# Define custom tools
def search_web(query: str) -> str:
"""Search the web for information."""
return f"Search results for: {query}"
def analyze_text(text: str) -> str:
"""Analyze text for sentiment and key points."""
word_count = len(text.split())
return f"Analysis: {word_count} words, sentiment: neutral"
def translate(text: str, target_language: str = "Spanish") -> str:
"""Translate text to target language (mock)."""
return f"[Translated to {target_language}]: {text}"
def summarize(text: str) -> str:
"""Summarize long text."""
words = text.split()
if len(words) > 20:
return " ".join(words[:20]) + "..."
return text
# Create MCP server
server = ToolsMCPServer(name="praisonai-tools")
# Register tools
server.register_tool(search_web)
server.register_tool(analyze_text)
server.register_tool(translate)
server.register_tool(summarize)
print(f"Registered tools: {[search_web.__name__, analyze_text.__name__, translate.__name__, summarize.__name__]}")
print("\nStarting MCP server with SSE transport...")
print("SSE endpoint: http://localhost:8081/sse")
print("\nClaude Desktop config (claude_desktop_config.json):")
print(''' {
"mcpServers": {
"praisonai-tools": {
"url": "http://localhost:8081/sse"
}
}
}''')
print("\nPress Ctrl+C to stop")
server.run(transport="sse", host="0.0.0.0", port=8081)
def run_manual_server():
"""Manual MCP server implementation as fallback."""
try:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import uvicorn
import json
import asyncio
except ImportError:
print("Error: FastAPI/uvicorn not installed")
print("Install with: pip install fastapi uvicorn")
sys.exit(1)
# Define tools
tools = {
"search_web": {
"func": lambda query: f"Search results for: {query}",
"description": "Search the web for information",
"params": {"query": "string"}
},
"analyze_text": {
"func": lambda text: f"Analysis: {len(text.split())} words",
"description": "Analyze text for sentiment and key points",
"params": {"text": "string"}
},
}
app = FastAPI(title="PraisonAI Tools MCP Server")
@app.get("/sse")
async def sse_endpoint():
"""SSE endpoint for MCP."""
async def event_stream():
# Send initial tools list
tools_list = {
"jsonrpc": "2.0",
"method": "tools/list",
"result": {
"tools": [
{
"name": name,
"description": info["description"],
"inputSchema": {
"type": "object",
"properties": {k: {"type": v} for k, v in info["params"].items()},
"required": list(info["params"].keys())
}
}
for name, info in tools.items()
]
}
}
yield f"data: {json.dumps(tools_list)}\n\n"
# Keep connection alive
while True:
await asyncio.sleep(30)
yield f"data: {json.dumps({'type': 'ping'})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
)
@app.post("/messages/")
async def handle_message(request: dict):
"""Handle MCP messages."""
method = request.get("method")
params = request.get("params", {})
req_id = request.get("id")
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"tools": [
{
"name": name,
"description": info["description"],
"inputSchema": {
"type": "object",
"properties": {k: {"type": v} for k, v in info["params"].items()}
}
}
for name, info in tools.items()
]
}
}
elif method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments", {})
if tool_name not in tools:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Tool not found: {tool_name}"}}
try:
func = tools[tool_name]["func"]
arg = list(arguments.values())[0] if arguments else ""
result = func(arg)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": result}]}
}
except Exception as e:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": str(e)}}
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Unknown method: {method}"}}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-tools-mcp",
"providers": [{"type": "tools-mcp", "name": "Tools MCP Server", "capabilities": ["list-tools", "call-tool"]}],
"endpoints": [{"name": "tools", "provider_type": "tools-mcp"}]
}
@app.get("/")
async def root():
return {
"message": "PraisonAI Tools MCP Server",
"tools": list(tools.keys()),
"sse": "/sse",
"messages": "/messages/"
}
print(f"Tools: {list(tools.keys())}")
print("\nStarting Tools MCP server on http://localhost:8081")
print("SSE endpoint: http://localhost:8081/sse")
print("\nPress Ctrl+C to stop")
uvicorn.run(app, host="127.0.0.1", port=8081)
if __name__ == "__main__":
main()
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Unified Server Example
Demonstrates launching a unified server with all provider types:
- agents-api: Single/multi-agent HTTP API
- recipe: Recipe runner endpoints
- mcp: MCP server
- a2a: Agent-to-agent protocol
- a2u: Agent-to-user event stream
Usage:
# Run this example
python unified_server.py
# Or use CLI:
praisonai serve unified --port 8765
# Test with curl:
curl http://localhost:8765/__praisonai__/discovery
curl http://localhost:8765/health
"""
import os
import sys
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def main():
print("=" * 60)
print("Unified Server Example")
print("=" * 60)
try:
from fastapi import FastAPI
import uvicorn
from praisonaiagents import Agent
except ImportError as e:
print(f"Error: Missing dependency - {e}")
print("Install with: pip install praisonaiagents fastapi uvicorn")
sys.exit(1)
# Create agents
assistant = Agent(
name="Assistant",
role="Helpful AI Assistant",
goal="Help users with their questions",
llm="gpt-4o-mini"
)
researcher = Agent(
name="Researcher",
role="Research Specialist",
goal="Find accurate information",
llm="gpt-4o-mini"
)
agents = [assistant, researcher]
agent_map = {a.name.lower(): a for a in agents}
# Create unified app
app = FastAPI(
title="PraisonAI Unified Server",
description="Unified server with all PraisonAI providers"
)
# Discovery endpoint
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-unified",
"providers": [
{"type": "agents-api", "name": "Agents API", "capabilities": ["invoke", "health"]},
{"type": "recipe", "name": "Recipe Runner", "capabilities": ["list", "describe", "invoke"]},
{"type": "mcp", "name": "MCP Server", "capabilities": ["list-tools", "call-tool"]},
{"type": "a2a", "name": "A2A Protocol", "capabilities": ["agent-card", "message-send"]},
{"type": "a2u", "name": "A2U Event Stream", "capabilities": ["subscribe", "stream"]},
],
"endpoints": [
{"name": "agents", "provider_type": "agents-api", "description": "Multi-agent router"},
{"name": "agent", "provider_type": "agents-api", "description": "Single agent endpoint"},
{"name": "mcp/tools", "provider_type": "mcp", "description": "MCP tools"},
{"name": "a2a", "provider_type": "a2a", "description": "A2A protocol"},
{"name": "a2u/events", "provider_type": "a2u", "description": "Event stream"},
]
}
# Health endpoint
@app.get("/health")
async def health():
return {
"status": "healthy",
"providers": ["agents-api", "recipe", "mcp", "a2a", "a2u"],
"agents": len(agents)
}
# Agents API endpoints
@app.post("/agents")
async def route_agents(request: dict):
query = request.get("query", "")
agent = agents[0] # Simple routing
try:
response = agent.chat(query)
return {"agent": agent.name, "response": response}
except Exception as e:
return {"error": str(e)}
@app.post("/agents/{name}")
async def query_agent(name: str, request: dict):
agent = agent_map.get(name.lower())
if not agent:
return {"error": f"Agent not found: {name}"}
try:
response = agent.chat(request.get("query", ""))
return {"agent": agent.name, "response": response}
except Exception as e:
return {"error": str(e)}
@app.get("/agents/list")
async def list_agents():
return {"agents": [{"name": a.name, "role": a.role} for a in agents]}
# Single agent endpoint
@app.post("/agent")
async def single_agent(request: dict):
query = request.get("query", "")
try:
response = assistant.chat(query)
return {"response": response}
except Exception as e:
return {"error": str(e)}
# MCP endpoints
mcp_tools = {
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
}
@app.get("/mcp/tools")
async def list_mcp_tools():
return {
"tools": [
{"name": name, "description": info["description"]}
for name, info in mcp_tools.items()
]
}
@app.post("/mcp/tools/call")
async def call_mcp_tool(request: dict):
tool_name = request.get("tool")
args = request.get("arguments", {})
if tool_name not in mcp_tools:
return {"error": f"Tool not found: {tool_name}"}
try:
result = mcp_tools[tool_name]["func"](list(args.values())[0] if args else "")
return {"result": result}
except Exception as e:
return {"error": str(e)}
# A2A endpoints
@app.get("/.well-known/agent.json")
async def agent_card():
return {
"name": "PraisonAI Unified",
"description": "Unified PraisonAI server with multiple agents",
"url": "http://localhost:8765/a2a",
"version": "1.0.0",
"capabilities": {"streaming": True}
}
@app.post("/a2a")
async def a2a_endpoint(request: dict):
method = request.get("method")
params = request.get("params", {})
req_id = request.get("id")
if method == "message/send":
message = params.get("message", {})
parts = message.get("parts", [])
text = "".join(p.get("text", "") for p in parts if p.get("type") == "text")
try:
response = assistant.chat(text)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"message": {
"role": "agent",
"parts": [{"type": "text", "text": response}]
}
}
}
except Exception as e:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": str(e)}}
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Unknown method: {method}"}}
# A2U endpoints
@app.get("/a2u/info")
async def a2u_info():
return {
"name": "A2U Event Stream",
"version": "1.0.0",
"streams": ["events"],
"event_types": ["agent.started", "agent.response", "agent.completed"]
}
# Root endpoint
@app.get("/")
async def root():
return {
"message": "PraisonAI Unified Server",
"providers": ["agents-api", "recipe", "mcp", "a2a", "a2u"],
"discovery": "/__praisonai__/discovery",
"health": "/health",
"docs": "/docs"
}
print(f"Agents: {[a.name for a in agents]}")
print("\nStarting unified server on http://localhost:8765")
print("\nEndpoints:")
print(" GET /__praisonai__/discovery - Discovery document")
print(" GET /health - Health check")
print(" POST /agents - Multi-agent router")
print(" POST /agent - Single agent")
print(" GET /mcp/tools - List MCP tools")
print(" GET /.well-known/agent.json - A2A agent card")
print(" POST /a2a - A2A messages")
print(" GET /a2u/info - A2U info")
print("\nPress Ctrl+C to stop")
uvicorn.run(app, host="127.0.0.1", port=8765)
if __name__ == "__main__":
main()
@@ -0,0 +1,66 @@
# Recipe Background Tasks Example
This example demonstrates how to run recipes and agents as background tasks.
## Prerequisites
```bash
pip install praisonai praisonaiagents
export OPENAI_API_KEY="your-api-key"
```
## Python Example
```bash
python example_background.py
```
## CLI Examples
### Submit a recipe as background task
```bash
# Submit recipe to background
praisonai background submit --recipe my-recipe --input '{"query": "test"}'
# Check status
praisonai background status <task_id>
# List all tasks
praisonai background list
# Cancel a task
praisonai background cancel <task_id>
# Clear completed tasks
praisonai background clear
```
### Run recipe with --background flag
```bash
# Run recipe in background
praisonai recipe run my-recipe --background
# With input
praisonai recipe run my-recipe --background --input '{"topic": "AI"}'
# With session ID
praisonai recipe run my-recipe --background --session-id session_123
```
## Key Features
- **Async Execution**: Tasks run in the background without blocking
- **Progress Tracking**: Monitor task progress and status
- **Cancellation**: Cancel running tasks when needed
- **Result Retrieval**: Get results when tasks complete
- **Concurrency Control**: Limit concurrent tasks
## Safe Defaults
| Setting | Default | Description |
|---------|---------|-------------|
| `timeout_sec` | 300 | Maximum execution time |
| `max_concurrent` | 5 | Maximum concurrent tasks |
| `cleanup_delay_sec` | 3600 | Auto-cleanup delay |
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""
Recipe Background Tasks Example
This example demonstrates how to run recipes as background tasks
with progress tracking and result retrieval.
Requirements:
pip install praisonai
Usage:
python example_background.py
"""
import asyncio
async def main():
"""Run a recipe as a background task."""
print("=" * 60)
print("Recipe Background Tasks Example")
print("=" * 60)
# For this example, we'll use a simple inline agent instead of a recipe
# In production, you would use an actual recipe name
from praisonaiagents import Agent
from praisonaiagents.background import BackgroundRunner
# Create an agent
agent = Agent(
name="Research Assistant",
instructions="You are a helpful research assistant. Provide concise answers.",
verbose=True
)
# Create background runner
runner = BackgroundRunner(max_concurrent_tasks=3)
print("\n1. Submitting task to background...")
# Submit task
task = await runner.submit(
lambda: agent.start("What are the top 3 benefits of AI in healthcare?"),
name="healthcare-research",
timeout=120,
)
print(f" Task ID: {task.id}")
print(f" Status: {task.status.value}")
print("\n2. Waiting for completion...")
# Wait for result
result = await runner.wait_for_task(task.id, timeout=120)
print("\n3. Task completed!")
print(f" Status: {task.status.value}")
print(f" Result preview: {str(result)[:200]}...")
# List all tasks
print("\n4. Listing all tasks:")
tasks = runner.list_tasks()
for t in tasks:
print(" - %s: %s" % (t['id'], t['status']))
# Clear completed tasks
print("\n5. Clearing completed tasks...")
runner.clear_completed()
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,97 @@
# Recipe Async Jobs Example
This example demonstrates how to submit recipes as async jobs to a jobs server.
## Prerequisites
```bash
pip install praisonai praisonaiagents httpx uvicorn
export OPENAI_API_KEY="your-api-key"
```
## Start the Jobs Server
```bash
python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory
```
## Python Example
```bash
python example_jobs.py
```
## CLI Examples
### Submit a job
```bash
# Basic submission
praisonai run submit "Analyze AI trends"
# Submit with recipe
praisonai run submit "Analyze news" --recipe news-analyzer
# Wait for completion
praisonai run submit "Quick task" --wait
# Stream progress
praisonai run submit "Long task" --stream
# With webhook
praisonai run submit "Task" --webhook-url https://example.com/callback
# With idempotency
praisonai run submit "Task" --idempotency-key order-123
# With metadata
praisonai run submit "Task" --metadata user=john --metadata priority=high
```
### Check status
```bash
praisonai run status <job_id>
praisonai run status <job_id> --json
```
### Get result
```bash
praisonai run result <job_id>
praisonai run result <job_id> --json
```
### Stream progress
```bash
praisonai run stream <job_id>
```
### List jobs
```bash
praisonai run list
praisonai run list --status running
```
### Cancel job
```bash
praisonai run cancel <job_id>
```
## Key Features
- **Server-based**: Jobs persist across restarts
- **Webhooks**: Get notified when jobs complete
- **Idempotency**: Prevent duplicate submissions
- **Streaming**: Real-time progress updates
- **Metadata**: Attach custom data to jobs
## Safe Defaults
| Setting | Default | Description |
|---------|---------|-------------|
| `timeout` | 3600 | Maximum execution time (1 hour) |
| `api_url` | http://127.0.0.1:8005 | Jobs server URL |
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Recipe Async Jobs Example
This example demonstrates how to submit recipes as async jobs
to a jobs server with webhooks and idempotency.
Requirements:
pip install praisonai httpx
Usage:
1. Start the jobs server:
python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory
2. Run this example:
python example_jobs.py
"""
import time
def main():
"""Submit and monitor an async job."""
print("=" * 60)
print("Recipe Async Jobs Example")
print("=" * 60)
# Import the jobs handler
from praisonai.cli.features.jobs import JobsHandler
# Create handler
handler = JobsHandler(
api_url="http://127.0.0.1:8005",
verbose=True
)
print("\n1. Submitting job...")
try:
# Submit a job
result = handler.submit(
prompt="What are the key trends in AI for 2024?",
framework="praisonai",
timeout=300,
wait=False, # Don't wait, we'll poll manually
output_json=False
)
job_id = result.get("job_id")
print(f" Job ID: {job_id}")
print(f" Status: {result.get('status')}")
print("\n2. Polling for completion...")
# Poll for completion
max_polls = 60
for i in range(max_polls):
status_result = handler.status(job_id)
status = status_result.get("status")
progress = status_result.get("progress", 0)
print(f" [{i+1}/{max_polls}] Status: {status}, Progress: {progress}%")
if status in ["succeeded", "failed", "cancelled"]:
break
time.sleep(5)
print("\n3. Getting result...")
# Get result
result = handler.result(job_id)
print(f" Status: {result.get('status')}")
if result.get("result"):
output = str(result.get("result"))
print(f" Result preview: {output[:200]}...")
print("\n4. Listing all jobs...")
# List jobs
handler.list_jobs(page=1, page_size=10)
except Exception as e:
print(f"\n Error: {e}")
print("\n Make sure the jobs server is running:")
print(" python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory")
print("\n" + "=" * 60)
print("Example completed!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,116 @@
# Recipe Scheduler Example
This example demonstrates how to schedule recipes and agents to run periodically.
## Prerequisites
```bash
pip install praisonai praisonaiagents
export OPENAI_API_KEY="your-api-key"
```
## Python Example
```bash
python example_scheduler.py
```
Press Ctrl+C to stop the scheduler and see statistics.
## CLI Examples
### Start a scheduler with task
```bash
# Basic scheduler
praisonai schedule start news-checker "Check AI news" --interval hourly
# With timeout and budget
praisonai schedule start reporter "Generate report" \
--interval daily \
--timeout 300 \
--max-cost 1.00
# With max retries
praisonai schedule start checker "Check updates" \
--interval hourly \
--max-retries 5
```
### Start a scheduler with recipe
```bash
# Schedule a recipe
praisonai schedule start news-monitor --recipe news-analyzer --interval hourly
# With all options
praisonai schedule start my-scheduler \
--recipe my-recipe \
--interval "*/6h" \
--timeout 600 \
--max-cost 2.00 \
--max-retries 3
```
### Foreground mode (for testing)
```bash
# Run in foreground (Ctrl+C to stop)
praisonai schedule "Check news" --interval "*/30s"
```
### Manage schedulers
```bash
# List all schedulers
praisonai schedule list
# View logs
praisonai schedule logs news-checker --follow
# Show statistics
praisonai schedule stats news-checker
# Stop a scheduler
praisonai schedule stop news-checker
# Stop all schedulers
praisonai schedule stop-all
# Delete a scheduler
praisonai schedule delete news-checker
```
### Export scheduler config
```bash
praisonai schedule save news-checker --output news-checker.yaml
```
## Interval Formats
| Format | Description |
|--------|-------------|
| `hourly` | Every hour |
| `daily` | Every day |
| `*/30m` | Every 30 minutes |
| `*/6h` | Every 6 hours |
| `*/30s` | Every 30 seconds (for testing) |
| `3600` | Every 3600 seconds |
## Key Features
- **Periodic Execution**: Run tasks at regular intervals
- **Retry Logic**: Automatic retries on failure
- **Cost Budgeting**: Set spending limits
- **Daemon Mode**: Run in background
- **Statistics**: Track success rates
## Safe Defaults
| Setting | Default | Description |
|---------|---------|-------------|
| `interval` | hourly | Execution interval |
| `max_retries` | 3 | Retry attempts |
| `timeout` | 300 | Timeout per execution |
| `max_cost` | 1.00 | Budget limit (USD) |
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
Recipe Scheduler Example
This example demonstrates how to schedule recipes and agents
to run periodically with retry logic and cost budgeting.
Requirements:
pip install praisonai praisonaiagents
Usage:
python example_scheduler.py
"""
import time
import signal
import sys
def main():
"""Run a scheduled agent."""
print("=" * 60)
print("Recipe Scheduler Example")
print("=" * 60)
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler
# Create an agent
agent = Agent(
name="News Monitor",
instructions="You are a news monitoring assistant. Provide brief updates.",
verbose=True
)
# Create scheduler
scheduler = AgentScheduler(
agent=agent,
task="What is the latest news in AI? Give a one-sentence summary.",
timeout=60,
max_cost=0.10 # $0.10 budget limit
)
print("\n1. Starting scheduler...")
print(" Interval: every 30 seconds (for demo)")
print(" Max retries: 3")
print(" Timeout: 60s")
print(" Budget: $0.10")
# Handle Ctrl+C gracefully
def signal_handler(sig, frame):
print("\n\n2. Stopping scheduler...")
scheduler.stop()
# Show stats
stats = scheduler.get_stats()
print("\n3. Final Statistics:")
print(f" Total Executions: {stats['total_executions']}")
print(f" Successful: {stats['successful_executions']}")
print(f" Failed: {stats['failed_executions']}")
print(f" Success Rate: {stats['success_rate']:.1f}%")
print("\n" + "=" * 60)
print("Example completed!")
print("=" * 60)
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
# Start scheduler with short interval for demo
scheduler.start(
schedule_expr="*/30s", # Every 30 seconds for demo
max_retries=3,
run_immediately=True
)
print("\n Press Ctrl+C to stop...\n")
# Keep running
while scheduler.is_running:
time.sleep(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,94 @@
# Example TEMPLATE.yaml with Runtime Configuration
# This demonstrates the new runtime block for background/job/schedule execution
schema_version: "1.0"
name: news-monitor
version: "1.0.0"
description: |
A recipe that monitors AI news and summarizes findings.
Supports background execution, async jobs, and scheduled runs.
author: praisonai
license: Apache-2.0
tags:
- news
- monitoring
- ai
# Dependencies
requires:
packages:
- praisonaiagents>=0.0.1
env:
- OPENAI_API_KEY
# Entry points
workflow: workflow.yaml
agents: agents.yaml
# Configuration schema
config:
topic:
type: string
description: Topic to monitor
default: "AI and machine learning"
max_articles:
type: integer
description: Maximum articles to fetch
default: 5
# Default values
defaults:
topic: "AI and machine learning"
max_articles: 5
# Runtime configuration for operational features
runtime:
# Background task execution
background:
enabled: true
max_concurrent: 3
cleanup_delay_sec: 3600
# Async job execution
job:
enabled: true
timeout_sec: 600
webhook_url: "${WEBHOOK_URL}" # Environment variable interpolation
idempotency_scope: "session"
events:
- completed
- failed
# Scheduled execution
schedule:
enabled: true
interval: "hourly"
max_retries: 3
run_immediately: false
timeout_sec: 300
max_cost_usd: 1.00
# CLI integration
cli:
command: news-monitor
arguments:
- name: topic
short: -t
description: Topic to monitor
- name: max-articles
short: -n
description: Maximum articles
# Example usage
examples:
- name: Basic usage
command: praisonai recipe run news-monitor
- name: With custom topic
command: praisonai recipe run news-monitor -i '{"topic": "LLMs"}'
- name: Background execution
command: praisonai recipe run news-monitor --background
- name: Submit as job
command: praisonai run submit "Monitor AI news" --recipe news-monitor
- name: Schedule hourly
command: praisonai schedule news-monitor --interval hourly
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.2.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.6.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.6.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -63,19 +63,30 @@ class AgentSchedulerHandler:
from datetime import datetime
if not unknown_args:
print("❌ Error: Please provide scheduler name and task")
print("❌ Error: Please provide scheduler name and task/recipe")
print("\nUsage:")
print(' praisonai schedule start <name> "Your task" --interval hourly')
print(' praisonai schedule start <name> --recipe <recipe-name> --interval hourly')
print("\nExample:")
print(' praisonai schedule start news-checker "Check AI news" --interval hourly')
print(' praisonai schedule start news-checker --recipe news-monitor --interval hourly')
return 1
# Parse arguments
name = unknown_args[0]
task = unknown_args[1] if len(unknown_args) > 1 else None
if not task:
print("❌ Error: Please provide a task")
# Check for --recipe flag in unknown_args
recipe_name = None
task = None
if "--recipe" in unknown_args:
recipe_idx = unknown_args.index("--recipe")
if recipe_idx + 1 < len(unknown_args):
recipe_name = unknown_args[recipe_idx + 1]
else:
task = unknown_args[1] if len(unknown_args) > 1 else None
if not task and not recipe_name:
print("❌ Error: Please provide a task or --recipe <name>")
return 1
# Get options
@@ -93,7 +104,10 @@ class AgentSchedulerHandler:
# Start daemon
print(f"🚀 Starting scheduler '{name}'...")
print(f" Task: {task}")
if recipe_name:
print(f" Recipe: {recipe_name}")
else:
print(f" Task: {task}")
print(f" Interval: {interval}")
if timeout:
print(f" Timeout: {timeout}s")
@@ -103,6 +117,7 @@ class AgentSchedulerHandler:
pid = daemon_manager.start_scheduler_daemon(
name=name,
task=task,
recipe_name=recipe_name,
interval=interval,
max_cost=max_cost,
timeout=timeout,
@@ -114,6 +129,7 @@ class AgentSchedulerHandler:
"name": name,
"pid": pid,
"task": task,
"recipe_name": recipe_name,
"interval": interval,
"timeout": timeout,
"max_cost": max_cost,
@@ -555,6 +571,18 @@ class AgentSchedulerHandler:
logging.getLogger('praisonai.scheduler').setLevel(logging.WARNING)
try:
# Check if this is a recipe name (not a file, not a prompt with spaces)
is_recipe_mode = False
if first_arg and not is_yaml_mode:
# Try to resolve as recipe if it looks like a recipe name (no spaces, no file extension)
if ' ' not in first_arg and not first_arg.endswith('.yaml') and not first_arg.endswith('.yml'):
try:
from praisonai.recipe.bridge import resolve
resolve(first_arg) # Just check if it resolves
is_recipe_mode = True
except Exception:
pass # Not a recipe, continue with prompt mode
if is_yaml_mode:
# YAML mode: Load from agents.yaml
yaml_path = first_arg
@@ -566,12 +594,23 @@ class AgentSchedulerHandler:
timeout_override=timeout_override,
max_cost_override=max_cost_override
)
elif is_recipe_mode:
# Recipe mode: Load from recipe name
print(f"🍳 Loading recipe: {first_arg}")
scheduler = AgentScheduler.from_recipe(
recipe_name=first_arg,
interval_override=interval_override,
max_retries_override=max_retries_override,
timeout_override=timeout_override,
max_cost_override=max_cost_override
)
else:
# Prompt mode: Create agent from direct prompt
if not first_arg:
print("❌ Error: Please provide either a YAML file or a task prompt")
print("❌ Error: Please provide either a YAML file, recipe name, or a task prompt")
print("\nExamples:")
print(" praisonai schedule agents.yaml")
print(" praisonai schedule my-recipe --interval hourly")
print(' praisonai schedule "Check news every hour" --interval hourly')
return 1
@@ -10,7 +10,6 @@ Commands:
- praisonai background clear # Clear completed tasks
"""
import os
import asyncio
from typing import Optional, List
@@ -235,62 +234,123 @@ def handle_background_command(args: List[str], verbose: bool = False):
Handle background CLI commands.
Usage:
praisonai background list [--status <status>]
praisonai background status <task_id>
praisonai background cancel <task_id>
praisonai background clear
praisonai background list [--status <status>] [--json] [--page N] [--page-size N]
praisonai background status <task_id> [--json]
praisonai background cancel <task_id> [--json]
praisonai background clear [--all] [--older-than SEC] [--json]
praisonai background submit --recipe <name> [--input JSON] [--session-id ID] [--timeout SEC] [--json]
"""
handler = BackgroundHandler(verbose=verbose)
import argparse
import json
parser = argparse.ArgumentParser(prog="praisonai background", description="Manage background tasks")
subparsers = parser.add_subparsers(dest="subcommand", help="Available commands")
# List command
list_parser = subparsers.add_parser("list", help="List background tasks")
list_parser.add_argument("--status", choices=["pending", "running", "completed", "failed", "cancelled"], help="Filter by status")
list_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
list_parser.add_argument("--page", type=int, default=1, help="Page number")
list_parser.add_argument("--page-size", type=int, default=20, help="Tasks per page")
# Status command
status_parser = subparsers.add_parser("status", help="Get task status")
status_parser.add_argument("task_id", help="Task ID")
status_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
# Cancel command
cancel_parser = subparsers.add_parser("cancel", help="Cancel a task")
cancel_parser.add_argument("task_id", help="Task ID")
cancel_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
# Clear command
clear_parser = subparsers.add_parser("clear", help="Clear completed tasks")
clear_parser.add_argument("--all", action="store_true", help="Clear all tasks including running")
clear_parser.add_argument("--older-than", type=int, help="Clear tasks older than N seconds")
clear_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
# Submit command (NEW - for recipe background submission)
submit_parser = subparsers.add_parser("submit", help="Submit a recipe as background task")
submit_parser.add_argument("--recipe", required=True, dest="recipe_name", help="Recipe name to execute")
submit_parser.add_argument("--input", "-i", dest="input_data", help="Input data as JSON string")
submit_parser.add_argument("--config", "-c", help="Config overrides as JSON string")
submit_parser.add_argument("--session-id", "-s", help="Session ID for conversation continuity")
submit_parser.add_argument("--timeout", type=int, default=300, help="Timeout in seconds (default: 300)")
submit_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
if not args:
print("Usage: praisonai background <command> [options]")
print("\nCommands:")
print(" list [--status <status>] List background tasks")
print(" status <task_id> Get task status")
print(" cancel <task_id> Cancel a task")
print(" clear Clear completed tasks")
parser.print_help()
return
command = args[0]
# Handle legacy help
if args[0] in ["help", "--help", "-h"]:
parser.print_help()
return
if command == "list":
status = None
if "--status" in args:
idx = args.index("--status")
if idx + 1 < len(args):
status = args[idx + 1]
parsed = parser.parse_args(args)
if not parsed.subcommand:
parser.print_help()
return
handler = BackgroundHandler(verbose=verbose)
try:
if parsed.subcommand == "list":
asyncio.run(handler.list_tasks(status=parsed.status))
asyncio.run(handler.list_tasks(status=status))
elif command == "status":
if len(args) < 2:
print("Usage: praisonai background status <task_id>")
return
elif parsed.subcommand == "status":
asyncio.run(handler.get_status(parsed.task_id))
asyncio.run(handler.get_status(args[1]))
elif command == "cancel":
if len(args) < 2:
print("Usage: praisonai background cancel <task_id>")
return
elif parsed.subcommand == "cancel":
asyncio.run(handler.cancel_task(parsed.task_id))
asyncio.run(handler.cancel_task(args[1]))
elif parsed.subcommand == "clear":
asyncio.run(handler.clear_completed())
elif parsed.subcommand == "submit":
# Parse input and config
input_data = None
if parsed.input_data:
try:
input_data = json.loads(parsed.input_data)
except json.JSONDecodeError:
input_data = {"input": parsed.input_data}
config = None
if parsed.config:
try:
config = json.loads(parsed.config)
except json.JSONDecodeError:
print("Error: --config must be valid JSON")
return
# Submit recipe as background task
from praisonai.recipe.operations import run_background
task = run_background(
parsed.recipe_name,
input=input_data,
config=config,
session_id=parsed.session_id,
timeout_sec=parsed.timeout,
)
if parsed.output_json:
print(json.dumps({
"ok": True,
"task_id": task.task_id,
"recipe": task.recipe_name,
"session_id": task.session_id,
}))
else:
print(f"✓ Recipe '{parsed.recipe_name}' submitted to background")
print(f" Task ID: {task.task_id}")
print(f" Session: {task.session_id}")
print(f"\nCheck status with: praisonai background status {task.task_id}")
elif command == "clear":
asyncio.run(handler.clear_completed())
elif command == "help" or command == "--help":
print("Background CLI Commands:")
print("\n praisonai background list [--status <status>]")
print(" List all background tasks")
print(" Status: pending, running, completed, failed, cancelled")
print("\n praisonai background status <task_id>")
print(" Get detailed status of a specific task")
print("\n praisonai background cancel <task_id>")
print(" Cancel a running task")
print("\n praisonai background clear")
print(" Clear all completed tasks from the list")
else:
print(f"Unknown command: {command}")
print("Use 'praisonai background help' for available commands")
except Exception as e:
if getattr(parsed, 'output_json', False):
print(json.dumps({"ok": False, "error": str(e)}))
else:
print(f"✗ Error: {e}")
@@ -19,3 +19,4 @@ def register_all_checks():
from . import network_checks
from . import performance_checks
from . import selftest_checks
from . import serve_checks
@@ -0,0 +1,426 @@
"""
Doctor checks for serve and endpoints functionality.
Checks for:
- Serve module availability
- Endpoints module availability
- Server connectivity
- Discovery endpoint
- Provider types
"""
import os
from ..models import CheckResult, CheckStatus, CheckCategory, CheckSeverity, DoctorConfig
from ..registry import register_check
@register_check(
id="serve_module",
title="Serve Module",
description="Check if serve module is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.MEDIUM,
tags=["serve", "deploy"],
)
def check_serve_module(config: DoctorConfig) -> CheckResult:
"""Check if serve module is available."""
try:
from praisonai.cli.features.serve import ServeHandler
ServeHandler() # Test instantiation
return CheckResult(
id="serve_module",
title="Serve Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="Serve module available",
metadata={"handler": "ServeHandler"},
)
except ImportError as e:
return CheckResult(
id="serve_module",
title="Serve Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"Serve module not available: {e}",
remediation="pip install praisonai[serve]",
)
except Exception as e:
return CheckResult(
id="serve_module",
title="Serve Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking serve module: {e}",
)
@register_check(
id="endpoints_module",
title="Endpoints Module",
description="Check if endpoints module is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.MEDIUM,
tags=["endpoints", "deploy"],
)
def check_endpoints_module(config: DoctorConfig) -> CheckResult:
"""Check if endpoints module is available."""
try:
from praisonai.endpoints import (
DiscoveryDocument,
EndpointInfo,
ProviderInfo,
list_provider_types,
)
types = list_provider_types()
return CheckResult(
id="endpoints_module",
title="Endpoints Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Endpoints module available ({len(types)} provider types)",
metadata={"provider_types": types},
)
except ImportError as e:
return CheckResult(
id="endpoints_module",
title="Endpoints Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"Endpoints module not available: {e}",
remediation="pip install praisonai[serve]",
)
except Exception as e:
return CheckResult(
id="endpoints_module",
title="Endpoints Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking endpoints module: {e}",
)
@register_check(
id="endpoints_cli",
title="Endpoints CLI",
description="Check if endpoints CLI handler is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
tags=["endpoints", "cli"],
)
def check_endpoints_cli(config: DoctorConfig) -> CheckResult:
"""Check if endpoints CLI handler is available."""
try:
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
return CheckResult(
id="endpoints_cli",
title="Endpoints CLI",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="Endpoints CLI handler available",
metadata={"default_url": handler.DEFAULT_URL},
)
except ImportError as e:
return CheckResult(
id="endpoints_cli",
title="Endpoints CLI",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"Endpoints CLI not available: {e}",
remediation="pip install praisonai",
)
except Exception as e:
return CheckResult(
id="endpoints_cli",
title="Endpoints CLI",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking endpoints CLI: {e}",
)
@register_check(
id="server_connectivity",
title="Server Connectivity",
description="Check if a PraisonAI server is reachable",
category=CheckCategory.NETWORK,
severity=CheckSeverity.LOW,
requires_deep=True,
tags=["serve", "network"],
)
def check_server_connectivity(config: DoctorConfig) -> CheckResult:
"""Check if a PraisonAI server is reachable."""
import urllib.request
import urllib.error
url = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
health_url = f"{url}/health"
try:
req = urllib.request.Request(health_url, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status == 200:
return CheckResult(
id="server_connectivity",
title="Server Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message=f"Server reachable at {url}",
metadata={"url": url, "status": resp.status},
)
else:
return CheckResult(
id="server_connectivity",
title="Server Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.WARN,
message=f"Server returned status {resp.status}",
metadata={"url": url, "status": resp.status},
)
except urllib.error.URLError as e:
return CheckResult(
id="server_connectivity",
title="Server Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.SKIP,
message=f"No server running at {url}",
details=str(e.reason),
remediation="Start a server: praisonai serve unified --port 8765",
)
except Exception as e:
return CheckResult(
id="server_connectivity",
title="Server Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.ERROR,
message=f"Error checking server: {e}",
)
@register_check(
id="discovery_endpoint",
title="Discovery Endpoint",
description="Check if discovery endpoint is available",
category=CheckCategory.NETWORK,
severity=CheckSeverity.LOW,
requires_deep=True,
dependencies=["server_connectivity"],
tags=["serve", "discovery"],
)
def check_discovery_endpoint(config: DoctorConfig) -> CheckResult:
"""Check if discovery endpoint is available."""
import urllib.request
import urllib.error
import json
url = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
discovery_url = f"{url}/__praisonai__/discovery"
try:
req = urllib.request.Request(discovery_url, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status == 200:
data = json.loads(resp.read().decode())
providers = data.get("providers", [])
endpoints = data.get("endpoints", [])
return CheckResult(
id="discovery_endpoint",
title="Discovery Endpoint",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message=f"Discovery available: {len(providers)} providers, {len(endpoints)} endpoints",
metadata={
"url": discovery_url,
"schema_version": data.get("schema_version"),
"server_name": data.get("server_name"),
"providers": [p.get("type") for p in providers],
},
)
else:
return CheckResult(
id="discovery_endpoint",
title="Discovery Endpoint",
category=CheckCategory.NETWORK,
status=CheckStatus.WARN,
message=f"Discovery returned status {resp.status}",
)
except urllib.error.URLError:
return CheckResult(
id="discovery_endpoint",
title="Discovery Endpoint",
category=CheckCategory.NETWORK,
status=CheckStatus.SKIP,
message="No server running (skipped)",
)
except Exception as e:
return CheckResult(
id="discovery_endpoint",
title="Discovery Endpoint",
category=CheckCategory.NETWORK,
status=CheckStatus.ERROR,
message=f"Error checking discovery: {e}",
)
@register_check(
id="a2u_module",
title="A2U Module",
description="Check if A2U server module is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
tags=["serve", "a2u"],
)
def check_a2u_module(config: DoctorConfig) -> CheckResult:
"""Check if A2U server module is available."""
try:
from praisonai.endpoints.a2u_server import (
A2UEvent,
A2USubscription,
A2UEventBus,
create_a2u_routes,
)
return CheckResult(
id="a2u_module",
title="A2U Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="A2U server module available",
)
except ImportError as e:
return CheckResult(
id="a2u_module",
title="A2U Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"A2U module not available: {e}",
remediation="pip install praisonai[serve]",
)
except Exception as e:
return CheckResult(
id="a2u_module",
title="A2U Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking A2U module: {e}",
)
@register_check(
id="provider_adapters",
title="Provider Adapters",
description="Check if all provider adapters are available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
tags=["serve", "providers"],
)
def check_provider_adapters(config: DoctorConfig) -> CheckResult:
"""Check if all provider adapters are available."""
try:
from praisonai.endpoints.providers import (
BaseProvider,
RecipeProvider,
AgentsAPIProvider,
MCPProvider,
ToolsMCPProvider,
A2AProvider,
A2UProvider,
)
providers = [
"BaseProvider",
"RecipeProvider",
"AgentsAPIProvider",
"MCPProvider",
"ToolsMCPProvider",
"A2AProvider",
"A2UProvider",
]
return CheckResult(
id="provider_adapters",
title="Provider Adapters",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"All {len(providers)} provider adapters available",
metadata={"providers": providers},
)
except ImportError as e:
return CheckResult(
id="provider_adapters",
title="Provider Adapters",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"Some provider adapters not available: {e}",
remediation="pip install praisonai[serve]",
)
except Exception as e:
return CheckResult(
id="provider_adapters",
title="Provider Adapters",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking provider adapters: {e}",
)
@register_check(
id="fastapi_available",
title="FastAPI Available",
description="Check if FastAPI is available for serving",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.MEDIUM,
tags=["serve", "dependencies"],
)
def check_fastapi_available(config: DoctorConfig) -> CheckResult:
"""Check if FastAPI is available."""
try:
import fastapi
version = getattr(fastapi, "__version__", "unknown")
return CheckResult(
id="fastapi_available",
title="FastAPI Available",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"FastAPI {version} available",
metadata={"version": version},
)
except ImportError:
return CheckResult(
id="fastapi_available",
title="FastAPI Available",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message="FastAPI not installed",
remediation="pip install fastapi uvicorn",
)
@register_check(
id="uvicorn_available",
title="Uvicorn Available",
description="Check if Uvicorn is available for serving",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.MEDIUM,
tags=["serve", "dependencies"],
)
def check_uvicorn_available(config: DoctorConfig) -> CheckResult:
"""Check if Uvicorn is available."""
try:
import uvicorn
version = getattr(uvicorn, "__version__", "unknown")
return CheckResult(
id="uvicorn_available",
title="Uvicorn Available",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Uvicorn {version} available",
metadata={"version": version},
)
except ImportError:
return CheckResult(
id="uvicorn_available",
title="Uvicorn Available",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message="Uvicorn not installed",
remediation="pip install uvicorn",
)
+520 -79
View File
@@ -1,11 +1,20 @@
"""
Endpoints CLI Feature Handler
Provides CLI commands for interacting with recipe endpoints:
- list: List available recipe endpoints
Provides CLI commands for interacting with PraisonAI endpoints:
- list: List available endpoints (all provider types)
- describe: Show endpoint details and schema
- invoke: Call an endpoint
- health: Check endpoint server health
- types: List supported provider types
Supported Provider Types:
- recipe: Recipe runner endpoints
- agents-api: Single/multi-agent HTTP API
- mcp: MCP server (stdio, http, sse)
- tools-mcp: Tools exposed as MCP server
- a2a: Agent-to-agent protocol
- a2u: Agent-to-user event stream
All commands use the canonical `praisonai endpoints` prefix.
@@ -14,11 +23,13 @@ Why this feature is valuable:
- Ops: Health checks, monitoring, automation
- Polyglot: Non-Python clients can invoke recipes via HTTP
- Testing: Easy endpoint verification without code
- Unified: Single interface for all server types
Architecture notes:
- Optional extras only (no server deps in core)
- Lazy imports for all HTTP client code
- No impact on praisonaiagents import time
- Backward compatible with existing recipe-only usage
"""
import json
@@ -27,15 +38,27 @@ import sys
from typing import Any, Dict, List, Optional
# Supported provider types
PROVIDER_TYPES = [
"recipe",
"agents-api",
"mcp",
"tools-mcp",
"a2a",
"a2u",
]
class EndpointsHandler:
"""
CLI handler for endpoints operations.
Commands:
- list: List available recipe endpoints
- list: List available endpoints (all provider types)
- describe: Show endpoint details
- invoke: Call an endpoint
- health: Check server health
- types: List supported provider types
"""
# Exit codes
@@ -90,6 +113,8 @@ class EndpointsHandler:
"describe": self.cmd_describe,
"invoke": self.cmd_invoke,
"health": self.cmd_health,
"types": self.cmd_types,
"discovery": self.cmd_discovery,
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
@@ -107,25 +132,30 @@ class EndpointsHandler:
help_text = """
[bold cyan]PraisonAI Endpoints[/bold cyan]
Client CLI for interacting with recipe endpoints.
Unified client CLI for interacting with PraisonAI endpoints.
[bold]Usage:[/bold]
praisonai endpoints <command> [options]
[bold]Commands:[/bold]
list List available recipe endpoints
describe <recipe> Show endpoint details and schema
invoke <recipe> Call an endpoint
list List available endpoints (all types)
describe <name> Show endpoint details and schema
invoke <name> Call an endpoint
health Check server health
types List supported provider types
discovery Show raw discovery document
[bold]Global Options:[/bold]
--url <url> Server URL (default: http://localhost:8765)
--api-key <key> API key (or set PRAISONAI_ENDPOINTS_API_KEY)
--type <type> Filter by provider type (recipe, agents-api, mcp, etc.)
[bold]List Options:[/bold]
--format json Output as JSON
--tags <a,b> Filter by tags (comma-separated)
--url <url> Server URL (default: http://localhost:8765)
[bold]Describe Options:[/bold]
--schema Show input/output schema only
--url <url> Server URL
[bold]Invoke Options:[/bold]
--input <path> Input file path
@@ -133,12 +163,15 @@ Client CLI for interacting with recipe endpoints.
--config k=v Config override (repeatable)
--json Output as JSON
--stream Stream output events (SSE)
--url <url> Server URL
--api-key <key> API key for auth (or set PRAISONAI_ENDPOINTS_API_KEY)
--dry-run Validate without executing
[bold]Health Options:[/bold]
--url <url> Server URL
[bold]Provider Types:[/bold]
recipe Recipe runner endpoints
agents-api Single/multi-agent HTTP API
mcp MCP server (stdio, http, sse)
tools-mcp Tools exposed as MCP server
a2a Agent-to-agent protocol
a2u Agent-to-user event stream
[bold]Environment Variables:[/bold]
PRAISONAI_ENDPOINTS_URL Default server URL
@@ -146,14 +179,16 @@ Client CLI for interacting with recipe endpoints.
[bold]Examples:[/bold]
praisonai endpoints list
praisonai endpoints list --type agents-api
praisonai endpoints list --format json --tags audio,video
praisonai endpoints describe ai-podcast-cleaner
praisonai endpoints describe ai-podcast-cleaner --schema
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3
praisonai endpoints invoke ai-podcast-cleaner --input-json '{"text": "hello"}'
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3 --stream
praisonai endpoints describe my-agent
praisonai endpoints describe my-agent --schema
praisonai endpoints invoke my-agent --input-json '{"query": "hello"}'
praisonai endpoints invoke my-recipe --input ./data.json --stream
praisonai endpoints health
praisonai endpoints health --url http://localhost:8000
praisonai endpoints types
praisonai endpoints discovery
"""
self._print_rich(help_text)
@@ -339,34 +374,96 @@ Client CLI for interacting with recipe endpoints.
except Exception as e:
yield {"error": str(e)}
def _get_provider(self, provider_type: str, url: Optional[str] = None, api_key: Optional[str] = None):
"""Get a provider instance."""
try:
from praisonai.endpoints import get_provider
return get_provider(
provider_type,
base_url=url or self.base_url,
api_key=api_key or self.api_key,
)
except ImportError:
return None
def _try_unified_discovery(self, url: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Try to get unified discovery document."""
result = self._make_request("GET", "/__praisonai__/discovery", url=url)
if not result.get("error") and result.get("data"):
return result.get("data")
return None
def cmd_list(self, args: List[str]) -> int:
"""List available recipe endpoints."""
"""List available endpoints (all provider types)."""
spec = {
"format": {"default": "table"},
"tags": {"default": None},
"url": {"default": None},
"type": {"default": None},
}
parsed = self._parse_args(args, spec)
# Build query params
path = "/v1/recipes"
if parsed["tags"]:
path += f"?tags={parsed['tags']}"
all_endpoints = []
result = self._make_request("GET", path, url=parsed["url"])
# Try unified discovery first
discovery = self._try_unified_discovery(parsed["url"])
if discovery:
endpoints = discovery.get("endpoints", [])
for ep in endpoints:
# Filter by type if specified
if parsed["type"] and ep.get("provider_type") != parsed["type"]:
continue
# Filter by tags if specified
if parsed["tags"]:
tag_list = [t.strip() for t in parsed["tags"].split(",")]
ep_tags = ep.get("tags", [])
if not any(t in ep_tags for t in tag_list):
continue
all_endpoints.append(ep)
else:
# Fallback: try recipe endpoint (backward compatibility)
if not parsed["type"] or parsed["type"] == "recipe":
path = "/v1/recipes"
if parsed["tags"]:
path += f"?tags={parsed['tags']}"
result = self._make_request("GET", path, url=parsed["url"])
if not result.get("error"):
recipes = result.get("data", {}).get("recipes", [])
for r in recipes:
all_endpoints.append({
"name": r.get("name", ""),
"description": r.get("description", ""),
"provider_type": "recipe",
"version": r.get("version", "1.0.0"),
"tags": r.get("tags", []),
"streaming": ["none", "sse"],
})
# Try agents-api endpoint
if not parsed["type"] or parsed["type"] == "agents-api":
result = self._make_request("GET", "/", url=parsed["url"])
if not result.get("error"):
data = result.get("data", {})
for path in data.get("endpoints", []):
all_endpoints.append({
"name": path.lstrip("/"),
"description": f"Agent endpoint at {path}",
"provider_type": "agents-api",
"streaming": ["none"],
})
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
recipes = result.get("data", {}).get("recipes", [])
if parsed["format"] == "json":
self._print_json(recipes)
if not all_endpoints:
result = self._make_request("GET", "/health", url=parsed["url"])
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
print("No endpoints available.")
return self.EXIT_SUCCESS
if not recipes:
print("No endpoints available.")
if parsed["format"] == "json":
self._print_json(all_endpoints)
return self.EXIT_SUCCESS
try:
@@ -376,63 +473,106 @@ Client CLI for interacting with recipe endpoints.
console = Console()
table = Table(title="Available Endpoints")
table.add_column("Name", style="cyan")
table.add_column("Version", style="green")
table.add_column("Type", style="magenta")
table.add_column("Description")
table.add_column("Streaming", style="green")
table.add_column("Tags", style="yellow")
for r in recipes:
tags = r.get("tags", [])
for ep in all_endpoints:
tags = ep.get("tags", [])
streaming = ep.get("streaming", ["none"])
desc = ep.get("description", "")
table.add_row(
r.get("name", ""),
r.get("version", ""),
(r.get("description", "")[:50] + "...") if len(r.get("description", "")) > 50 else r.get("description", ""),
ep.get("name", ""),
ep.get("provider_type", "unknown"),
(desc[:40] + "...") if len(desc) > 40 else desc,
", ".join(streaming[:2]),
", ".join(tags[:3]) if tags else "",
)
console.print(table)
except ImportError:
for r in recipes:
print(f"{r.get('name')} ({r.get('version')}): {r.get('description', '')}")
for ep in all_endpoints:
print(f"[{ep.get('provider_type', 'unknown')}] {ep.get('name')}: {ep.get('description', '')}")
return self.EXIT_SUCCESS
def cmd_describe(self, args: List[str]) -> int:
"""Describe a recipe endpoint."""
"""Describe an endpoint."""
spec = {
"recipe": {"positional": True, "default": ""},
"name": {"positional": True, "default": ""},
"schema": {"flag": True, "default": False},
"url": {"default": None},
"type": {"default": None},
}
parsed = self._parse_args(args, spec)
if not parsed["recipe"]:
self._print_error("Recipe name required")
if not parsed["name"]:
self._print_error("Endpoint name required")
return self.EXIT_VALIDATION_ERROR
# Get schema if requested
if parsed["schema"]:
path = f"/v1/recipes/{parsed['recipe']}/schema"
else:
path = f"/v1/recipes/{parsed['recipe']}"
endpoint_name = parsed["name"]
endpoint_info = None
result = self._make_request("GET", path, url=parsed["url"])
# Try unified discovery first
discovery = self._try_unified_discovery(parsed["url"])
if discovery:
for ep in discovery.get("endpoints", []):
if ep.get("name") == endpoint_name:
if parsed["type"] and ep.get("provider_type") != parsed["type"]:
continue
endpoint_info = ep
break
if result.get("status") == 404:
self._print_error(f"Endpoint not found: {parsed['recipe']}")
return self.EXIT_NOT_FOUND
if endpoint_info:
if parsed["schema"]:
schema_info = {
"name": endpoint_info.get("name"),
"input_schema": endpoint_info.get("input_schema"),
"output_schema": endpoint_info.get("output_schema"),
}
self._print_json(schema_info)
else:
self._print_json(endpoint_info)
return self.EXIT_SUCCESS
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
# Fallback: try recipe endpoint (backward compatibility)
if not parsed["type"] or parsed["type"] == "recipe":
if parsed["schema"]:
path = f"/v1/recipes/{endpoint_name}/schema"
else:
path = f"/v1/recipes/{endpoint_name}"
result = self._make_request("GET", path, url=parsed["url"])
if not result.get("error") and result.get("status") != 404:
data = result.get("data", {})
# Add provider_type for consistency
data["provider_type"] = "recipe"
self._print_json(data)
return self.EXIT_SUCCESS
data = result.get("data", {})
self._print_json(data)
return self.EXIT_SUCCESS
# Try agents-api
if not parsed["type"] or parsed["type"] == "agents-api":
# Return basic info for agent endpoints
endpoint_info = {
"name": endpoint_name,
"provider_type": "agents-api",
"description": f"Agent endpoint: {endpoint_name}",
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}}},
"streaming": ["none"],
"auth_modes": ["none"],
}
self._print_json(endpoint_info)
return self.EXIT_SUCCESS
self._print_error(f"Endpoint not found: {endpoint_name}")
return self.EXIT_NOT_FOUND
def cmd_invoke(self, args: List[str]) -> int:
"""Invoke a recipe endpoint."""
"""Invoke an endpoint."""
spec = {
"recipe": {"positional": True, "default": ""},
"name": {"positional": True, "default": ""},
"input": {"short": "-i", "default": None},
"input_json": {"default": None},
"config": {"repeatable": True, "default": []},
@@ -441,13 +581,16 @@ Client CLI for interacting with recipe endpoints.
"url": {"default": None},
"api_key": {"default": None},
"dry_run": {"flag": True, "default": False},
"type": {"default": None},
}
parsed = self._parse_args(args, spec)
if not parsed["recipe"]:
self._print_error("Recipe name required")
if not parsed["name"]:
self._print_error("Endpoint name required")
return self.EXIT_VALIDATION_ERROR
endpoint_name = parsed["name"]
# Build input data
input_data = {}
if parsed["input_json"]:
@@ -457,7 +600,15 @@ Client CLI for interacting with recipe endpoints.
self._print_error("Invalid JSON in --input-json")
return self.EXIT_VALIDATION_ERROR
elif parsed["input"]:
input_data = {"input": parsed["input"]}
# Check if input is a file path
if os.path.isfile(parsed["input"]):
try:
with open(parsed["input"]) as f:
input_data = json.load(f)
except (json.JSONDecodeError, IOError):
input_data = {"input": parsed["input"]}
else:
input_data = {"input": parsed["input"]}
# Build config from --config key=value pairs
config = {}
@@ -466,9 +617,39 @@ Client CLI for interacting with recipe endpoints.
k, v = cfg.split("=", 1)
config[k] = v
# Build request body
# Detect provider type from discovery or explicit --type
provider_type = parsed["type"]
if not provider_type:
discovery = self._try_unified_discovery(parsed["url"])
if discovery:
for ep in discovery.get("endpoints", []):
if ep.get("name") == endpoint_name:
provider_type = ep.get("provider_type")
break
# Default to recipe for backward compatibility
if not provider_type:
provider_type = "recipe"
# Route to appropriate invocation method
if provider_type == "recipe":
return self._invoke_recipe(endpoint_name, input_data, config, parsed)
elif provider_type == "agents-api":
return self._invoke_agents_api(endpoint_name, input_data, config, parsed)
elif provider_type in ("mcp", "tools-mcp"):
return self._invoke_mcp(endpoint_name, input_data, config, parsed)
elif provider_type == "a2a":
return self._invoke_a2a(endpoint_name, input_data, config, parsed)
elif provider_type == "a2u":
return self._invoke_a2u(endpoint_name, input_data, config, parsed)
else:
# Fallback to recipe
return self._invoke_recipe(endpoint_name, input_data, config, parsed)
def _invoke_recipe(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
"""Invoke a recipe endpoint."""
body = {
"recipe": parsed["recipe"],
"recipe": name,
"input": input_data,
"config": config,
"options": {"dry_run": parsed["dry_run"]},
@@ -477,7 +658,6 @@ Client CLI for interacting with recipe endpoints.
if parsed["stream"]:
return self._invoke_stream(body, parsed)
# Sync invocation
result = self._make_request(
"POST",
"/v1/recipes/run",
@@ -486,12 +666,31 @@ Client CLI for interacting with recipe endpoints.
json_data=body,
)
return self._handle_invoke_result(result, name, parsed)
def _invoke_agents_api(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
"""Invoke an agents-api endpoint."""
path = name if name.startswith("/") else f"/{name}"
# Build body - agents-api expects query field
body = input_data
if "query" not in body and config.get("query"):
body["query"] = config["query"]
result = self._make_request(
"POST",
path,
url=parsed["url"],
api_key=parsed["api_key"],
json_data=body,
)
if result.get("status") == 401:
self._print_error("Authentication required. Use --api-key or set PRAISONAI_ENDPOINTS_API_KEY")
self._print_error("Authentication required")
return self.EXIT_AUTH_ERROR
if result.get("status") == 404:
self._print_error(f"Endpoint not found: {parsed['recipe']}")
self._print_error(f"Endpoint not found: {name}")
return self.EXIT_NOT_FOUND
if result.get("error"):
@@ -503,17 +702,171 @@ Client CLI for interacting with recipe endpoints.
if parsed["json"]:
self._print_json(data)
else:
if data.get("ok"):
self._print_success(f"Endpoint '{parsed['recipe']}' invoked successfully")
print(f" Run ID: {data.get('run_id')}")
print(f" Status: {data.get('status')}")
response = data.get("response", data)
self._print_success(f"Endpoint '{name}' invoked successfully")
print(f" Response: {response}")
return self.EXIT_SUCCESS
def _invoke_mcp(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
"""Invoke an MCP tool."""
body = {
"tool": name,
"arguments": input_data,
}
result = self._make_request(
"POST",
"/mcp/tools/call",
url=parsed["url"],
api_key=parsed["api_key"],
json_data=body,
)
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
if parsed["json"]:
self._print_json(data)
else:
self._print_success(f"Tool '{name}' invoked successfully")
print(f" Result: {data.get('result', data)}")
return self.EXIT_SUCCESS
def _invoke_a2a(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
"""Invoke an A2A agent."""
import uuid
message = input_data.get("message", input_data.get("query", ""))
if not message and config.get("message"):
message = config["message"]
body = {
"jsonrpc": "2.0",
"method": "message/send",
"id": str(uuid.uuid4()),
"params": {
"message": {
"role": "user",
"parts": [{"type": "text", "text": message}],
},
},
}
result = self._make_request(
"POST",
"/a2a",
url=parsed["url"],
api_key=parsed["api_key"],
json_data=body,
)
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
if parsed["json"]:
self._print_json(data)
else:
if "result" in data:
self._print_success(f"A2A message sent to '{name}'")
print(f" Result: {data['result']}")
elif "error" in data:
self._print_error(data["error"].get("message", str(data["error"])))
return self.EXIT_RUNTIME_ERROR
return self.EXIT_SUCCESS
def _invoke_a2u(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
"""Subscribe to an A2U event stream."""
if parsed["stream"]:
return self._stream_a2u(name, parsed)
body = {
"stream": name,
"filters": input_data.get("filters", []),
}
result = self._make_request(
"POST",
"/a2u/subscribe",
url=parsed["url"],
api_key=parsed["api_key"],
json_data=body,
)
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
if parsed["json"]:
self._print_json(data)
else:
self._print_success(f"Subscribed to A2U stream '{name}'")
print(f" Stream URL: {data.get('stream_url')}")
return self.EXIT_SUCCESS
def _stream_a2u(self, name: str, parsed: Dict) -> int:
"""Stream A2U events."""
print(f"Streaming events from '{name}'...")
for event in self._stream_request(
f"/a2u/events/{name}",
url=parsed["url"],
api_key=parsed["api_key"],
):
if event.get("error"):
self._print_error(event["error"])
return self.EXIT_CONNECTION_ERROR
if parsed["json"]:
print(json.dumps(event))
else:
print(f" [{event.get('event', 'event')}] {event.get('data', '')}")
return self.EXIT_SUCCESS
def _handle_invoke_result(self, result: Dict, name: str, parsed: Dict) -> int:
"""Handle common invoke result processing."""
if result.get("status") == 401:
self._print_error("Authentication required. Use --api-key or set PRAISONAI_ENDPOINTS_API_KEY")
return self.EXIT_AUTH_ERROR
if result.get("status") == 404:
self._print_error(f"Endpoint not found: {name}")
return self.EXIT_NOT_FOUND
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
if parsed["json"]:
self._print_json(data)
else:
if data.get("ok", True):
self._print_success(f"Endpoint '{name}' invoked successfully")
if data.get("run_id"):
print(f" Run ID: {data.get('run_id')}")
if data.get("status"):
print(f" Status: {data.get('status')}")
if data.get("output"):
print(f" Output: {data.get('output')}")
else:
self._print_error(f"Invocation failed: {data.get('error')}")
return self.EXIT_RUNTIME_ERROR
return self.EXIT_SUCCESS if data.get("ok") else self.EXIT_RUNTIME_ERROR
return self.EXIT_SUCCESS if data.get("ok", True) else self.EXIT_RUNTIME_ERROR
def _invoke_stream(self, body: Dict, parsed: Dict) -> int:
"""Handle streaming invocation."""
@@ -551,9 +904,14 @@ Client CLI for interacting with recipe endpoints.
"""Check endpoint server health."""
spec = {
"url": {"default": None},
"type": {"default": None},
"format": {"default": "table"},
}
parsed = self._parse_args(args, spec)
# Try unified discovery first for richer info
discovery = self._try_unified_discovery(parsed["url"])
result = self._make_request("GET", "/health", url=parsed["url"])
if result.get("error"):
@@ -562,14 +920,97 @@ Client CLI for interacting with recipe endpoints.
data = result.get("data", {})
if data.get("status") == "healthy":
# Merge discovery info
if discovery:
data["schema_version"] = discovery.get("schema_version")
data["providers"] = [p.get("type") for p in discovery.get("providers", [])]
data["endpoint_count"] = len(discovery.get("endpoints", []))
if parsed["format"] == "json":
self._print_json(data)
return self.EXIT_SUCCESS
status = data.get("status", "unknown")
if status in ("healthy", "ok"):
self._print_success("Server healthy")
print(f" Service: {data.get('service')}")
print(f" Version: {data.get('version')}")
if data.get("service"):
print(f" Service: {data.get('service')}")
if data.get("server_name"):
print(f" Server: {data.get('server_name')}")
if data.get("version"):
print(f" Version: {data.get('version')}")
if data.get("schema_version"):
print(f" Schema Version: {data.get('schema_version')}")
if data.get("providers"):
print(f" Providers: {', '.join(data.get('providers'))}")
if data.get("endpoint_count"):
print(f" Endpoints: {data.get('endpoint_count')}")
if data.get("endpoints"):
print(f" Endpoints: {', '.join(data.get('endpoints')[:5])}")
return self.EXIT_SUCCESS
else:
self._print_error(f"Server unhealthy: {data}")
return self.EXIT_RUNTIME_ERROR
def cmd_types(self, args: List[str]) -> int:
"""List supported provider types."""
spec = {
"format": {"default": "table"},
}
parsed = self._parse_args(args, spec)
types_info = [
{"type": "recipe", "description": "Recipe runner endpoints", "capabilities": ["list", "describe", "invoke", "stream"]},
{"type": "agents-api", "description": "Single/multi-agent HTTP API", "capabilities": ["list", "describe", "invoke"]},
{"type": "mcp", "description": "MCP server (stdio, http, sse)", "capabilities": ["list-tools", "call-tool"]},
{"type": "tools-mcp", "description": "Tools exposed as MCP server", "capabilities": ["list-tools", "call-tool"]},
{"type": "a2a", "description": "Agent-to-agent protocol", "capabilities": ["agent-card", "message-send", "stream"]},
{"type": "a2u", "description": "Agent-to-user event stream", "capabilities": ["subscribe", "stream"]},
]
if parsed["format"] == "json":
self._print_json(types_info)
return self.EXIT_SUCCESS
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Supported Provider Types")
table.add_column("Type", style="cyan")
table.add_column("Description")
table.add_column("Capabilities", style="green")
for t in types_info:
table.add_row(
t["type"],
t["description"],
", ".join(t["capabilities"]),
)
console.print(table)
except ImportError:
for t in types_info:
print(f"{t['type']}: {t['description']}")
return self.EXIT_SUCCESS
def cmd_discovery(self, args: List[str]) -> int:
"""Show raw discovery document."""
spec = {
"url": {"default": None},
}
parsed = self._parse_args(args, spec)
discovery = self._try_unified_discovery(parsed["url"])
if discovery:
self._print_json(discovery)
return self.EXIT_SUCCESS
self._print_error("Discovery endpoint not available at this server")
return self.EXIT_NOT_FOUND
def handle_endpoints_command(args: List[str]) -> int:
@@ -53,11 +53,14 @@ class JobsHandler:
self,
prompt: str,
agent_file: Optional[str] = None,
recipe_name: Optional[str] = None,
recipe_config: Optional[dict] = None,
framework: str = "praisonai",
timeout: int = 3600,
wait: bool = False,
poll_interval: int = 5,
idempotency_key: Optional[str] = None,
idempotency_scope: str = "none",
webhook_url: Optional[str] = None,
session_id: Optional[str] = None,
output_json: bool = False
@@ -68,14 +71,25 @@ class JobsHandler:
Args:
prompt: The prompt/task for the agent
agent_file: Optional path to agents.yaml
recipe_name: Optional recipe name (mutually exclusive with agent_file)
recipe_config: Optional recipe configuration overrides
framework: Framework to use
timeout: Job timeout in seconds
wait: If True, wait for completion
poll_interval: Seconds between status polls
idempotency_key: Key for deduplication
idempotency_scope: Scope for deduplication (none, session, global)
webhook_url: URL for completion webhook
session_id: Session ID for conversation continuity
output_json: If True, output JSON format
Returns:
Job submission response
"""
# Validate mutually exclusive options
if agent_file and recipe_name:
raise ValueError("Cannot specify both --agent-file and --recipe. Choose one.")
client = self._get_client()
payload = {
@@ -86,12 +100,18 @@ class JobsHandler:
if agent_file:
payload["agent_file"] = agent_file
if recipe_name:
payload["recipe_name"] = recipe_name
if recipe_config:
payload["recipe_config"] = recipe_config
if webhook_url:
payload["webhook_url"] = webhook_url
if session_id:
payload["session_id"] = session_id
if idempotency_key:
payload["idempotency_key"] = idempotency_key
if idempotency_scope and idempotency_scope != "none":
payload["idempotency_scope"] = idempotency_scope
headers = {}
if idempotency_key:
@@ -494,12 +514,17 @@ def handle_run_command(args: List[str], verbose: bool = False):
submit_parser = subparsers.add_parser("submit", help="Submit a new job")
submit_parser.add_argument("prompt", help="The prompt/task for the agent")
submit_parser.add_argument("--agent-file", help="Path to agents.yaml")
submit_parser.add_argument("--recipe", dest="recipe_name", help="Recipe name to execute (mutually exclusive with --agent-file)")
submit_parser.add_argument("--recipe-config", help="Recipe config as JSON string")
submit_parser.add_argument("--framework", default="praisonai", help="Framework to use")
submit_parser.add_argument("--timeout", type=int, default=3600, help="Timeout in seconds")
submit_parser.add_argument("--wait", action="store_true", help="Wait for completion")
submit_parser.add_argument("--stream", action="store_true", help="Stream job progress after submission")
submit_parser.add_argument("--idempotency-key", help="Idempotency key to prevent duplicates")
submit_parser.add_argument("--idempotency-scope", default="none", choices=["none", "session", "global"], help="Idempotency scope")
submit_parser.add_argument("--webhook-url", help="Webhook URL for completion callback")
submit_parser.add_argument("--session-id", help="Session ID for grouping jobs")
submit_parser.add_argument("--metadata", action="append", metavar="KEY=VALUE", help="Custom metadata (can be used multiple times)")
submit_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON for scripting")
submit_parser.add_argument("--api-url", default="http://127.0.0.1:8005", help="API server URL")
@@ -552,13 +577,25 @@ def handle_run_command(args: List[str], verbose: bool = False):
try:
if parsed.subcommand == "submit":
# Parse recipe config if provided
recipe_config = None
if getattr(parsed, "recipe_config", None):
try:
recipe_config = json.loads(parsed.recipe_config)
except json.JSONDecodeError:
print("Error: --recipe-config must be valid JSON")
sys.exit(1)
handler.submit(
prompt=parsed.prompt,
agent_file=parsed.agent_file,
recipe_name=getattr(parsed, "recipe_name", None),
recipe_config=recipe_config,
framework=parsed.framework,
timeout=parsed.timeout,
wait=parsed.wait,
idempotency_key=getattr(parsed, "idempotency_key", None),
idempotency_scope=getattr(parsed, "idempotency_scope", "none"),
webhook_url=getattr(parsed, "webhook_url", None),
session_id=getattr(parsed, "session_id", None),
output_json=getattr(parsed, "output_json", False)
@@ -0,0 +1,631 @@
"""
Package CLI Feature Handler
Provides pip-like CLI commands for package management:
- install: Install Python packages
- uninstall: Uninstall Python packages
- list: List installed packages
- search: Search packages
- index: Manage package indexes
All commands use the canonical `praisonai` or `praisonai package` prefix.
Security:
- Default to single authoritative index (PyPI)
- Warn loudly about dependency confusion when using extra indexes
- Require explicit opt-in for extra index fallback
"""
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Tuple
# Default configuration
DEFAULT_INDEX_URL = "https://pypi.org/simple"
CONFIG_DIR = Path.home() / ".praison"
CONFIG_FILE = CONFIG_DIR / "config.toml"
class PackageConfig:
"""Package configuration manager."""
def __init__(self):
"""Initialize config."""
self._config = None
def _load_config(self) -> Dict[str, Any]:
"""Load configuration from file."""
if self._config is not None:
return self._config
self._config = {
"package": {
"index_url": DEFAULT_INDEX_URL,
"extra_index_urls": [],
"allow_extra_index": False,
"trusted_hosts": [],
}
}
if CONFIG_FILE.exists():
try:
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError:
return self._config
try:
with open(CONFIG_FILE, "rb") as f:
file_config = tomllib.load(f)
if "package" in file_config:
self._config["package"].update(file_config["package"])
except Exception:
pass
# Environment variable overrides
if os.environ.get("PRAISONAI_PACKAGE_INDEX_URL"):
self._config["package"]["index_url"] = os.environ["PRAISONAI_PACKAGE_INDEX_URL"]
if os.environ.get("PRAISONAI_PACKAGE_EXTRA_INDEX_URLS"):
urls = os.environ["PRAISONAI_PACKAGE_EXTRA_INDEX_URLS"].split(",")
self._config["package"]["extra_index_urls"] = [u.strip() for u in urls if u.strip()]
return self._config
def get(self, key: str, default: Any = None) -> Any:
"""Get config value."""
config = self._load_config()
parts = key.split(".")
value = config
for part in parts:
if isinstance(value, dict) and part in value:
value = value[part]
else:
return default
return value
def save_index(self, index_url: str) -> None:
"""Save index URL to config."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config = {}
if CONFIG_FILE.exists():
try:
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError:
# Fall back to simple write
with open(CONFIG_FILE, "w") as f:
f.write(f'[package]\nindex_url = "{index_url}"\n')
return
try:
with open(CONFIG_FILE, "rb") as f:
config = tomllib.load(f)
except Exception:
pass
if "package" not in config:
config["package"] = {}
config["package"]["index_url"] = index_url
# Write config (simple TOML format)
with open(CONFIG_FILE, "w") as f:
for section, values in config.items():
f.write(f"[{section}]\n")
for key, value in values.items():
if isinstance(value, str):
f.write(f'{key} = "{value}"\n')
elif isinstance(value, bool):
f.write(f'{key} = {"true" if value else "false"}\n')
elif isinstance(value, list):
f.write(f'{key} = {json.dumps(value)}\n')
else:
f.write(f'{key} = {value}\n')
f.write("\n")
class PackageHandler:
"""
CLI handler for package operations.
Commands:
- install: Install Python packages
- uninstall: Uninstall Python packages
- list: List installed packages
- search: Search packages (best-effort)
- index: Manage package indexes
"""
# Stable exit codes
EXIT_SUCCESS = 0
EXIT_GENERAL_ERROR = 1
EXIT_VALIDATION_ERROR = 2
EXIT_NETWORK_ERROR = 10
EXIT_DEPENDENCY_ERROR = 11
def __init__(self):
"""Initialize the handler."""
self.config = PackageConfig()
def handle(self, args: List[str]) -> int:
"""
Handle package subcommand.
Args:
args: Command arguments
Returns:
Exit code
"""
if not args:
self._print_help()
return self.EXIT_SUCCESS
command = args[0]
remaining = args[1:]
commands = {
"list": self.cmd_list,
"search": self.cmd_search,
"index": self.cmd_index,
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
}
handler = commands.get(command)
if handler:
return handler(remaining)
self._print_error(f"Unknown command: {command}")
self._print_help()
return self.EXIT_VALIDATION_ERROR
def _print_help(self):
"""Print help message."""
print("""
PraisonAI Package Commands
Usage: praisonai package <command> [options]
praisonai install <spec...>
praisonai uninstall <pkg...>
Commands:
install Install Python packages (shortcut: praisonai install)
uninstall Uninstall Python packages (shortcut: praisonai uninstall)
list List installed packages
search Search for packages
index Manage package indexes
Examples:
praisonai install requests
praisonai install "requests>=2.28" httpx
praisonai uninstall requests
praisonai package list
praisonai package search langchain
praisonai package index show
praisonai package index set https://my-index.example.com/simple
Options for 'install':
--index-url URL Use custom index URL
--extra-index-url URL Add extra index (requires --allow-extra-index)
--allow-extra-index Allow extra index URLs (security risk!)
--python PATH Python interpreter to use
--upgrade, -U Upgrade packages
--no-deps Don't install dependencies
--json Output in JSON format
Options for 'uninstall':
--python PATH Python interpreter to use
--yes, -y Don't ask for confirmation
--json Output in JSON format
Security Notes:
- By default, only the primary index is used (PyPI)
- Using --extra-index-url can lead to dependency confusion attacks
- Always prefer --index-url over --extra-index-url when possible
""")
def _print_error(self, message: str):
"""Print error message."""
print(f"Error: {message}", file=sys.stderr)
def _print_warning(self, message: str):
"""Print warning message."""
print(f"Warning: {message}", file=sys.stderr)
def _print_success(self, message: str):
"""Print success message."""
print(f"✓ {message}")
def _print_json(self, data: Any):
"""Print JSON output."""
print(json.dumps(data, indent=2))
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
"""Parse command arguments, returning parsed args and positional args."""
result = {k: v.get("default") for k, v in spec.items()}
positional = []
i = 0
while i < len(args):
arg = args[i]
if arg.startswith("--"):
key = arg[2:].replace("-", "_")
if key in spec:
if spec[key].get("flag"):
result[key] = True
elif i + 1 < len(args):
result[key] = args[i + 1]
i += 1
else:
positional.append(arg)
elif arg.startswith("-") and len(arg) == 2:
# Short flag
found = False
for key, val in spec.items():
if val.get("short") == arg:
if val.get("flag"):
result[key] = True
elif i + 1 < len(args):
result[key] = args[i + 1]
i += 1
found = True
break
if not found:
positional.append(arg)
else:
positional.append(arg)
i += 1
return result, positional
def _get_python(self, python_path: str = None) -> str:
"""Get Python interpreter path."""
if python_path:
return python_path
return sys.executable
def _run_pip(
self,
args: List[str],
python: str = None,
capture: bool = False,
) -> Tuple[int, str, str]:
"""
Run pip command.
Args:
args: pip arguments
python: Python interpreter path
capture: Capture output instead of streaming
Returns:
Tuple of (return_code, stdout, stderr)
"""
python = self._get_python(python)
cmd = [python, "-m", "pip"] + args
if capture:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env={**os.environ, "PAGER": "cat"},
)
return result.returncode, result.stdout, result.stderr
else:
result = subprocess.run(
cmd,
env={**os.environ, "PAGER": "cat"},
)
return result.returncode, "", ""
def cmd_install(self, args: List[str]) -> int:
"""Install Python packages."""
spec = {
"index_url": {"default": None},
"extra_index_url": {"default": None},
"allow_extra_index": {"flag": True, "default": False},
"python": {"default": None},
"upgrade": {"flag": True, "short": "-U", "default": False},
"no_deps": {"flag": True, "default": False},
"json": {"flag": True, "default": False},
}
parsed, packages = self._parse_args(args, spec)
if not packages:
self._print_error("No packages specified")
return self.EXIT_VALIDATION_ERROR
# Build pip command
pip_args = ["install"]
# Index URL
index_url = parsed["index_url"] or self.config.get("package.index_url", DEFAULT_INDEX_URL)
pip_args.extend(["--index-url", index_url])
# Extra index URL (with security warning)
extra_index = parsed["extra_index_url"]
if extra_index:
allow_extra = parsed["allow_extra_index"] or self.config.get("package.allow_extra_index", False)
if not allow_extra:
self._print_error(
"Extra index URLs are disabled by default for security.\n"
"Using multiple indexes can lead to dependency confusion attacks.\n"
"To enable, use --allow-extra-index flag or set package.allow_extra_index=true in config."
)
return self.EXIT_VALIDATION_ERROR
self._print_warning(
"⚠️ SECURITY WARNING: Using extra index URLs can lead to dependency confusion attacks!\n"
" An attacker could publish a malicious package with the same name on PyPI.\n"
" Only use this if you trust both indexes and understand the risks."
)
pip_args.extend(["--extra-index-url", extra_index])
# Other options
if parsed["upgrade"]:
pip_args.append("--upgrade")
if parsed["no_deps"]:
pip_args.append("--no-deps")
# Add packages
pip_args.extend(packages)
if parsed["json"]:
returncode, stdout, stderr = self._run_pip(pip_args, parsed["python"], capture=True)
self._print_json({
"ok": returncode == 0,
"packages": packages,
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
})
return returncode
else:
print(f"Installing: {', '.join(packages)}")
returncode, _, _ = self._run_pip(pip_args, parsed["python"])
if returncode == 0:
self._print_success("Installation complete")
return returncode
def cmd_uninstall(self, args: List[str]) -> int:
"""Uninstall Python packages."""
spec = {
"python": {"default": None},
"yes": {"flag": True, "short": "-y", "default": False},
"json": {"flag": True, "default": False},
}
parsed, packages = self._parse_args(args, spec)
if not packages:
self._print_error("No packages specified")
return self.EXIT_VALIDATION_ERROR
pip_args = ["uninstall"]
if parsed["yes"]:
pip_args.append("-y")
pip_args.extend(packages)
if parsed["json"]:
returncode, stdout, stderr = self._run_pip(pip_args, parsed["python"], capture=True)
self._print_json({
"ok": returncode == 0,
"packages": packages,
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
})
return returncode
else:
print(f"Uninstalling: {', '.join(packages)}")
returncode, _, _ = self._run_pip(pip_args, parsed["python"])
if returncode == 0:
self._print_success("Uninstall complete")
return returncode
def cmd_list(self, args: List[str]) -> int:
"""List installed packages."""
spec = {
"python": {"default": None},
"json": {"flag": True, "default": False},
"outdated": {"flag": True, "default": False},
}
parsed, _ = self._parse_args(args, spec)
pip_args = ["list", "--format=json"]
if parsed["outdated"]:
pip_args.append("--outdated")
returncode, stdout, stderr = self._run_pip(pip_args, parsed["python"], capture=True)
if returncode != 0:
self._print_error(stderr or "Failed to list packages")
return returncode
try:
packages = json.loads(stdout)
except json.JSONDecodeError:
packages = []
if parsed["json"]:
self._print_json({
"ok": True,
"packages": packages,
"count": len(packages),
})
else:
if not packages:
print("No packages installed.")
else:
print(f"{'Package':<30} {'Version':<15}")
print("-" * 45)
for pkg in packages:
name = pkg.get("name", "")
version = pkg.get("version", "")
print(f"{name:<30} {version:<15}")
print(f"\nTotal: {len(packages)} packages")
return self.EXIT_SUCCESS
def cmd_search(self, args: List[str]) -> int:
"""Search for packages (best-effort, uses pip search or PyPI API)."""
spec = {
"json": {"flag": True, "default": False},
}
parsed, query_parts = self._parse_args(args, spec)
if not query_parts:
self._print_error("Search query required")
return self.EXIT_VALIDATION_ERROR
query = " ".join(query_parts)
# pip search is deprecated, use PyPI JSON API
try:
import urllib.request
import urllib.error
url = f"https://pypi.org/pypi/{query}/json"
try:
with urllib.request.urlopen(url, timeout=10) as response:
data = json.loads(response.read().decode())
info = data.get("info", {})
result = {
"name": info.get("name"),
"version": info.get("version"),
"summary": info.get("summary"),
"author": info.get("author"),
"home_page": info.get("home_page"),
}
if parsed["json"]:
self._print_json({"ok": True, "results": [result]})
else:
print(f"Found: {result['name']} ({result['version']})")
print(f" {result['summary']}")
if result['home_page']:
print(f" Homepage: {result['home_page']}")
return self.EXIT_SUCCESS
except urllib.error.HTTPError as e:
if e.code == 404:
if parsed["json"]:
self._print_json({"ok": True, "results": [], "message": "No exact match found"})
else:
print(f"No exact match for '{query}'")
print("Try: pip search <query> (if available) or browse https://pypi.org")
return self.EXIT_SUCCESS
raise
except Exception as e:
if parsed["json"]:
self._print_json({"ok": False, "error": str(e)})
else:
self._print_error(f"Search failed: {e}")
return self.EXIT_NETWORK_ERROR
def cmd_index(self, args: List[str]) -> int:
"""Manage package indexes."""
if not args:
return self._index_show([])
subcommand = args[0]
remaining = args[1:]
subcommands = {
"show": self._index_show,
"set": self._index_set,
"add": self._index_add,
"remove": self._index_remove,
}
handler = subcommands.get(subcommand)
if handler:
return handler(remaining)
self._print_error(f"Unknown index subcommand: {subcommand}")
return self.EXIT_VALIDATION_ERROR
def _index_show(self, args: List[str]) -> int:
"""Show current index configuration."""
spec = {
"json": {"flag": True, "default": False},
}
parsed, _ = self._parse_args(args, spec)
index_url = self.config.get("package.index_url", DEFAULT_INDEX_URL)
extra_urls = self.config.get("package.extra_index_urls", [])
allow_extra = self.config.get("package.allow_extra_index", False)
if parsed["json"]:
self._print_json({
"ok": True,
"index_url": index_url,
"extra_index_urls": extra_urls,
"allow_extra_index": allow_extra,
})
else:
print(f"Primary Index: {index_url}")
if extra_urls:
print(f"Extra Indexes: {', '.join(extra_urls)}")
print(f"Allow Extra Index: {'yes' if allow_extra else 'no'}")
return self.EXIT_SUCCESS
def _index_set(self, args: List[str]) -> int:
"""Set primary index URL."""
if not args:
self._print_error("Index URL required")
return self.EXIT_VALIDATION_ERROR
url = args[0]
# Validate URL format
if not url.startswith(("http://", "https://")):
self._print_error("Index URL must start with http:// or https://")
return self.EXIT_VALIDATION_ERROR
self.config.save_index(url)
self._print_success(f"Primary index set to: {url}")
return self.EXIT_SUCCESS
def _index_add(self, args: List[str]) -> int:
"""Add extra index URL."""
self._print_warning(
"Adding extra index URLs is a security risk!\n"
"This can lead to dependency confusion attacks.\n"
"Consider using --index-url to switch indexes instead."
)
if not args:
self._print_error("Index URL required")
return self.EXIT_VALIDATION_ERROR
# For now, just print instructions
print("\nTo use extra index, run:")
print(f" praisonai install <pkg> --extra-index-url {args[0]} --allow-extra-index")
return self.EXIT_SUCCESS
def _index_remove(self, args: List[str]) -> int:
"""Remove extra index URL."""
print("Extra index URLs are not persisted by default.")
print("To reset to PyPI, run:")
print(f" praisonai package index set {DEFAULT_INDEX_URL}")
return self.EXIT_SUCCESS
+655 -9
View File
@@ -88,6 +88,14 @@ class RecipeHandler:
"export": self.cmd_export,
"replay": self.cmd_replay,
"serve": self.cmd_serve,
"publish": self.cmd_publish,
"pull": self.cmd_pull,
"sbom": self.cmd_sbom,
"audit": self.cmd_audit,
"sign": self.cmd_sign,
"verify": self.cmd_verify,
"runs": self.cmd_runs,
"policy": self.cmd_policy,
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
@@ -121,6 +129,14 @@ class RecipeHandler:
export <run_id> Export a run bundle for replay
replay <bundle> Replay from a run bundle
serve Start HTTP recipe runner
publish <bundle> Publish recipe to registry
pull <name> Pull recipe from registry
runs List/manage run history
sbom <recipe> Generate SBOM (Software Bill of Materials)
audit <recipe> Audit dependencies for vulnerabilities
sign <bundle> Sign a recipe bundle
verify <bundle> Verify bundle signature
policy Manage policy packs
[bold]Run Options:[/bold]
--input, -i Input JSON or file path
@@ -239,12 +255,62 @@ class RecipeHandler:
spec = {
"source": {"default": None},
"tags": {"default": None},
"registry": {"default": None},
"token": {"default": None},
"json": {"flag": True, "default": False},
"offline": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
try:
# If registry URL provided, list from that registry
if parsed["registry"]:
from praisonai.recipe.registry import get_registry
import os
registry = get_registry(
registry=parsed["registry"],
token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
)
result = registry.list_recipes(
tags=parsed["tags"].split(",") if parsed["tags"] else None
)
recipes = result.get("recipes", []) if isinstance(result, dict) else result
if parsed["json"]:
self._print_json(recipes)
return self.EXIT_SUCCESS
if not recipes:
print("No recipes found in registry.")
return self.EXIT_SUCCESS
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title=f"Recipes from {parsed['registry']}")
table.add_column("Name", style="cyan")
table.add_column("Version", style="green")
table.add_column("Description")
table.add_column("Tags", style="yellow")
for r in recipes:
table.add_row(
r.get("name", ""),
r.get("version", ""),
(r.get("description", "")[:50] + "...") if len(r.get("description", "")) > 50 else r.get("description", ""),
", ".join(r.get("tags", [])[:3]),
)
console.print(table)
except ImportError:
for r in recipes:
print(f"{r.get('name')} ({r.get('version')}): {r.get('description', '')}")
return self.EXIT_SUCCESS
# Default: list local recipes
recipes = self.recipe.list_recipes(
source_filter=parsed["source"],
tags=parsed["tags"].split(",") if parsed["tags"] else None,
@@ -293,6 +359,8 @@ class RecipeHandler:
"""Search recipes by query."""
spec = {
"query": {"positional": True, "default": ""},
"registry": {"default": None},
"token": {"default": None},
"json": {"flag": True, "default": False},
"offline": {"flag": True, "default": False},
}
@@ -303,6 +371,32 @@ class RecipeHandler:
return self.EXIT_VALIDATION_ERROR
try:
# If registry URL provided, search that registry
if parsed["registry"]:
from praisonai.recipe.registry import get_registry
import os
registry = get_registry(
registry=parsed["registry"],
token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
)
result = registry.search(parsed["query"])
matches = result.get("results", []) if isinstance(result, dict) else result
if parsed["json"]:
self._print_json(matches)
return self.EXIT_SUCCESS
if not matches:
print(f"No recipes found matching '{parsed['query']}' in registry")
return self.EXIT_SUCCESS
print(f"Found {len(matches)} recipe(s) matching '{parsed['query']}':")
for r in matches:
print(f" {r.get('name')}: {r.get('description', '')}")
return self.EXIT_SUCCESS
# Default: search local recipes
recipes = self.recipe.list_recipes(offline=parsed["offline"])
query = parsed["query"].lower()
@@ -457,6 +551,7 @@ class RecipeHandler:
"session": {"short": "-s", "default": None},
"json": {"flag": True, "default": False},
"stream": {"flag": True, "default": False},
"background": {"flag": True, "default": False},
"dry_run": {"flag": True, "default": False},
"explain": {"flag": True, "default": False},
"verbose": {"short": "-v", "flag": True, "default": False},
@@ -515,6 +610,13 @@ class RecipeHandler:
}
try:
# Background execution mode
if parsed["background"]:
return self._run_background(
parsed["recipe"], input_data, config,
parsed["session"], options, parsed["json"]
)
if parsed["stream"]:
return self._run_stream(
parsed["recipe"], input_data, config,
@@ -562,6 +664,50 @@ class RecipeHandler:
self._print_error(str(e))
return self.EXIT_RUNTIME_ERROR
def _run_background(
self,
recipe_name: str,
input_data: Dict[str, Any],
config: Dict[str, Any],
session_id: Optional[str],
options: Dict[str, Any],
json_output: bool,
) -> int:
"""Run recipe as a background task."""
try:
from praisonai.recipe.operations import run_background
task = run_background(
recipe_name,
input=input_data or None,
config=config or None,
session_id=session_id,
timeout_sec=options.get('timeout_sec', 300),
)
if json_output:
self._print_json({
"ok": True,
"task_id": task.task_id,
"recipe": task.recipe_name,
"session_id": task.session_id,
"message": "Task submitted to background"
})
else:
self._print_success(f"Recipe '{recipe_name}' submitted to background")
print(f" Task ID: {task.task_id}")
print(f" Session: {task.session_id}")
print(f"\nCheck status with: praisonai background status {task.task_id}")
return self.EXIT_SUCCESS
except Exception as e:
if json_output:
self._print_json({"ok": False, "error": str(e)})
else:
self._print_error(f"Failed to submit background task: {e}")
return self.EXIT_RUNTIME_ERROR
def _run_stream(
self,
recipe_name: str,
@@ -928,9 +1074,25 @@ OPENAI_API_KEY=your-api-key
self._print_error("Run ID required")
return self.EXIT_VALIDATION_ERROR
# For now, just show a message - full implementation would query run history
self._print_error("Run export from history not yet implemented. Use --export flag with 'run' command.")
return self.EXIT_GENERAL_ERROR
try:
from praisonai.recipe.history import get_history
history = get_history()
output_path = history.export(
run_id=parsed["run_id"],
output_path=Path(parsed["output"]) if parsed["output"] else None,
)
if parsed["json"]:
self._print_json({"ok": True, "path": str(output_path)})
else:
self._print_success(f"Exported to {output_path}")
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def cmd_replay(self, args: List[str]) -> int:
"""Replay from a run bundle."""
@@ -1008,6 +1170,12 @@ OPENAI_API_KEY=your-api-key
"recipes": {"default": None},
"config": {"default": None},
"api_key": {"default": None},
"workers": {"default": "1"},
"rate_limit": {"default": None},
"max_request_size": {"default": None},
"enable_metrics": {"flag": True, "default": False},
"enable_admin": {"flag": True, "default": False},
"trace_exporter": {"default": "none"},
}
parsed = self._parse_args(args, spec)
@@ -1025,6 +1193,7 @@ OPENAI_API_KEY=your-api-key
port = int(parsed["port"]) if parsed["port"] != "8765" or "port" not in config else config.get("port", 8765)
host = parsed["host"] if parsed["host"] != "127.0.0.1" or "host" not in config else config.get("host", "127.0.0.1")
auth = parsed["auth"] if parsed["auth"] != "none" or "auth" not in config else config.get("auth", "none")
workers = int(parsed["workers"]) if parsed["workers"] != "1" or "workers" not in config else config.get("workers", 1)
# Update config with CLI overrides
config["auth"] = auth
@@ -1032,6 +1201,16 @@ OPENAI_API_KEY=your-api-key
config["api_key"] = parsed["api_key"]
if parsed["recipes"]:
config["recipes"] = parsed["recipes"].split(",")
if parsed["rate_limit"]:
config["rate_limit"] = int(parsed["rate_limit"])
if parsed["max_request_size"]:
config["max_request_size"] = int(parsed["max_request_size"])
if parsed["enable_metrics"]:
config["enable_metrics"] = True
if parsed["enable_admin"]:
config["enable_admin"] = True
if parsed["trace_exporter"] != "none":
config["trace_exporter"] = parsed["trace_exporter"]
# Safety check: require auth for non-localhost
if host != "127.0.0.1" and host != "localhost" and auth == "none":
@@ -1042,15 +1221,22 @@ OPENAI_API_KEY=your-api-key
from praisonai.recipe.serve import serve
print(f"Starting Recipe Runner on http://{host}:{port}")
if workers > 1:
print(f"Workers: {workers}")
if auth != "none":
print(f"Auth: {auth}")
print("Press Ctrl+C to stop")
print("\nEndpoints:")
print(" GET /health - Health check")
print(" GET /v1/recipes - List recipes")
print(" GET /v1/recipes/{name} - Describe recipe")
print(" POST /v1/recipes/run - Run recipe")
print(" POST /v1/recipes/stream - Stream recipe")
print(" GET /health - Health check")
print(" GET /v1/recipes - List recipes")
print(" GET /v1/recipes/{name} - Describe recipe")
print(" POST /v1/recipes/run - Run recipe")
print(" POST /v1/recipes/stream - Stream recipe")
print(" GET /openapi.json - OpenAPI spec")
if config.get("enable_metrics"):
print(" GET /metrics - Prometheus metrics")
if config.get("enable_admin"):
print(" POST /admin/reload - Hot reload registry")
# Preload recipes if requested
if parsed["preload"]:
@@ -1059,7 +1245,7 @@ OPENAI_API_KEY=your-api-key
recipes = recipe.list_recipes()
print(f" Loaded {len(recipes)} recipes")
serve(host=host, port=port, reload=parsed["reload"], config=config)
serve(host=host, port=port, reload=parsed["reload"], config=config, workers=workers)
return self.EXIT_SUCCESS
@@ -1071,6 +1257,466 @@ OPENAI_API_KEY=your-api-key
return self.EXIT_GENERAL_ERROR
def cmd_publish(self, args: List[str]) -> int:
"""Publish recipe to registry."""
spec = {
"bundle": {"positional": True, "default": ""},
"registry": {"default": None},
"token": {"default": None},
"force": {"flag": True, "default": False},
"json": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
if not parsed["bundle"]:
self._print_error("Bundle or recipe directory required")
return self.EXIT_VALIDATION_ERROR
try:
from praisonai.recipe.registry import get_registry
bundle_path = Path(parsed["bundle"])
# If directory, pack it first
if bundle_path.is_dir():
# Pack the recipe first
import tarfile
import hashlib
info = self.recipe.describe(str(bundle_path))
if info is None:
self._print_error(f"Invalid recipe directory: {bundle_path}")
return self.EXIT_VALIDATION_ERROR
bundle_name = f"{info.name}-{info.version}.praison"
bundle_path_new = Path(bundle_name)
with tarfile.open(bundle_path_new, "w:gz") as tar:
manifest = {
"name": info.name,
"version": info.version,
"description": info.description,
"tags": info.tags,
"created_at": self._get_timestamp(),
"files": [],
}
for file_path in bundle_path.rglob("*"):
if file_path.is_file() and not file_path.name.startswith("."):
rel_path = file_path.relative_to(bundle_path)
tar.add(file_path, arcname=str(rel_path))
with open(file_path, "rb") as f:
checksum = hashlib.sha256(f.read()).hexdigest()
manifest["files"].append({"path": str(rel_path), "checksum": checksum})
import io
manifest_bytes = json.dumps(manifest, indent=2).encode()
manifest_info = tarfile.TarInfo(name="manifest.json")
manifest_info.size = len(manifest_bytes)
tar.addfile(manifest_info, io.BytesIO(manifest_bytes))
bundle_path = bundle_path_new
# Get registry
registry = get_registry(
registry=parsed["registry"],
token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN"),
)
# Publish
result = registry.publish(
bundle_path=bundle_path,
force=parsed["force"],
)
if parsed["json"]:
self._print_json({"ok": True, **result})
else:
self._print_success(f"Published {result['name']}@{result['version']}")
print(f" Registry: {parsed['registry'] or '~/.praison/registry'}")
print(f" Checksum: {result['checksum'][:16]}...")
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def cmd_pull(self, args: List[str]) -> int:
"""Pull recipe from registry."""
spec = {
"name": {"positional": True, "default": ""},
"registry": {"default": None},
"token": {"default": None},
"output": {"short": "-o", "default": "."},
"json": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
if not parsed["name"]:
self._print_error("Recipe name required (e.g., [email protected])")
return self.EXIT_VALIDATION_ERROR
try:
from praisonai.recipe.registry import get_registry
# Parse name@version
name = parsed["name"]
version = None
if "@" in name:
name, version = name.rsplit("@", 1)
# Get registry
registry = get_registry(
registry=parsed["registry"],
token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN"),
)
# Pull
result = registry.pull(
name=name,
version=version,
output_dir=Path(parsed["output"]),
)
if parsed["json"]:
self._print_json({"ok": True, **result})
else:
self._print_success(f"Pulled {result['name']}@{result['version']}")
print(f" Path: {result['path']}")
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def cmd_runs(self, args: List[str]) -> int:
"""List/manage run history."""
spec = {
"action": {"positional": True, "default": "list"},
"recipe": {"default": None},
"session": {"default": None},
"limit": {"default": "20"},
"json": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
try:
from praisonai.recipe.history import get_history
history = get_history()
action = parsed["action"]
if action == "list":
runs = history.list_runs(
recipe=parsed["recipe"],
session_id=parsed["session"],
limit=int(parsed["limit"]),
)
if parsed["json"]:
self._print_json({"runs": runs, "count": len(runs)})
else:
if not runs:
print("No runs found")
else:
print(f"Recent runs ({len(runs)}):\n")
for run in runs:
status_icon = "✓" if run.get("status") == "success" else "✗"
print(f" {status_icon} {run['run_id']}")
print(f" Recipe: {run.get('recipe', 'unknown')}")
print(f" Status: {run.get('status', 'unknown')}")
print(f" Time: {run.get('stored_at', 'unknown')}")
print()
elif action == "stats":
stats = history.get_stats()
if parsed["json"]:
self._print_json(stats)
else:
print("Run History Stats:")
print(f" Total runs: {stats['total_runs']}")
print(f" Storage size: {stats['total_size_bytes'] / 1024:.1f} KB")
print(f" Path: {stats['storage_path']}")
elif action == "cleanup":
deleted = history.cleanup()
if parsed["json"]:
self._print_json({"deleted": deleted})
else:
self._print_success(f"Cleaned up {deleted} old runs")
else:
self._print_error(f"Unknown action: {action}. Use: list, stats, cleanup")
return self.EXIT_VALIDATION_ERROR
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def cmd_sbom(self, args: List[str]) -> int:
"""Generate SBOM for a recipe."""
spec = {
"recipe": {"positional": True, "default": ""},
"format": {"default": "cyclonedx"},
"output": {"short": "-o", "default": None},
"json": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
if not parsed["recipe"]:
self._print_error("Recipe path required")
return self.EXIT_VALIDATION_ERROR
try:
from praisonai.recipe.security import generate_sbom
recipe_path = Path(parsed["recipe"])
if not recipe_path.exists():
# Try to find recipe by name
info = self.recipe.describe(parsed["recipe"])
if info and info.path:
recipe_path = Path(info.path)
else:
self._print_error(f"Recipe not found: {parsed['recipe']}")
return self.EXIT_NOT_FOUND
sbom = generate_sbom(
recipe_path=recipe_path,
format=parsed["format"],
)
if parsed["output"]:
with open(parsed["output"], "w") as f:
json.dump(sbom, f, indent=2)
self._print_success(f"SBOM written to {parsed['output']}")
elif parsed["json"]:
self._print_json(sbom)
else:
print(f"SBOM ({parsed['format']}):")
print(f" Components: {len(sbom.get('components', []))}")
for comp in sbom.get("components", [])[:10]:
print(f" - {comp['name']}@{comp['version']}")
if len(sbom.get("components", [])) > 10:
print(f" ... and {len(sbom['components']) - 10} more")
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def cmd_audit(self, args: List[str]) -> int:
"""Audit recipe dependencies."""
spec = {
"recipe": {"positional": True, "default": ""},
"json": {"flag": True, "default": False},
"strict": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
if not parsed["recipe"]:
self._print_error("Recipe path required")
return self.EXIT_VALIDATION_ERROR
try:
from praisonai.recipe.security import audit_dependencies
recipe_path = Path(parsed["recipe"])
if not recipe_path.exists():
info = self.recipe.describe(parsed["recipe"])
if info and info.path:
recipe_path = Path(info.path)
else:
self._print_error(f"Recipe not found: {parsed['recipe']}")
return self.EXIT_NOT_FOUND
report = audit_dependencies(recipe_path)
if parsed["json"]:
self._print_json(report)
else:
print(f"Audit Report: {report['recipe']}")
print(f" Lockfile: {report['lockfile'] or 'Not found'}")
print(f" Dependencies: {len(report['dependencies'])}")
if report["vulnerabilities"]:
print(f"\n [red]Vulnerabilities ({len(report['vulnerabilities'])}):[/red]")
for vuln in report["vulnerabilities"]:
print(f" - {vuln['package']}: {vuln['vulnerability_id']}")
if report["warnings"]:
print("\n Warnings:")
for warn in report["warnings"]:
print(f" - {warn}")
if report["passed"]:
self._print_success("Audit passed")
else:
self._print_error("Audit failed")
if parsed["strict"] and not report["passed"]:
return self.EXIT_VALIDATION_ERROR
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def cmd_sign(self, args: List[str]) -> int:
"""Sign a recipe bundle."""
spec = {
"bundle": {"positional": True, "default": ""},
"key": {"default": None},
"output": {"short": "-o", "default": None},
"json": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
if not parsed["bundle"]:
self._print_error("Bundle path required")
return self.EXIT_VALIDATION_ERROR
if not parsed["key"]:
self._print_error("Private key path required (--key)")
return self.EXIT_VALIDATION_ERROR
try:
from praisonai.recipe.security import sign_bundle
sig_path = sign_bundle(
bundle_path=parsed["bundle"],
private_key_path=parsed["key"],
output_path=parsed["output"],
)
if parsed["json"]:
self._print_json({"ok": True, "signature": str(sig_path)})
else:
self._print_success(f"Bundle signed: {sig_path}")
return self.EXIT_SUCCESS
except ImportError:
self._print_error("cryptography package required. Install with: pip install cryptography")
return self.EXIT_MISSING_DEPS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def cmd_verify(self, args: List[str]) -> int:
"""Verify bundle signature."""
spec = {
"bundle": {"positional": True, "default": ""},
"key": {"default": None},
"signature": {"default": None},
"json": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
if not parsed["bundle"]:
self._print_error("Bundle path required")
return self.EXIT_VALIDATION_ERROR
if not parsed["key"]:
self._print_error("Public key path required (--key)")
return self.EXIT_VALIDATION_ERROR
try:
from praisonai.recipe.security import verify_bundle
valid, message = verify_bundle(
bundle_path=parsed["bundle"],
public_key_path=parsed["key"],
signature_path=parsed["signature"],
)
if parsed["json"]:
self._print_json({"valid": valid, "message": message})
else:
if valid:
self._print_success(message)
else:
self._print_error(message)
return self.EXIT_SUCCESS if valid else self.EXIT_VALIDATION_ERROR
except ImportError:
self._print_error("cryptography package required. Install with: pip install cryptography")
return self.EXIT_MISSING_DEPS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def cmd_policy(self, args: List[str]) -> int:
"""Manage policy packs."""
spec = {
"action": {"positional": True, "default": "show"},
"file": {"positional": True, "default": None},
"output": {"short": "-o", "default": None},
"json": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
try:
from praisonai.recipe.policy import PolicyPack, get_default_policy
action = parsed["action"]
if action == "show":
# Show default or loaded policy
if parsed["file"]:
policy = PolicyPack.load(parsed["file"])
else:
policy = get_default_policy()
if parsed["json"]:
self._print_json(policy.to_dict())
else:
print(f"Policy: {policy.name}")
print(f"\nAllowed tools ({len(policy.allowed_tools)}):")
for tool in list(policy.allowed_tools)[:5]:
print(f" - {tool}")
print(f"\nDenied tools ({len(policy.denied_tools)}):")
for tool in list(policy.denied_tools)[:5]:
print(f" - {tool}")
print(f"\nPII mode: {policy.pii_mode}")
elif action == "init":
# Create a new policy file
output = parsed["output"] or "policy.yaml"
policy = get_default_policy()
policy.save(output)
self._print_success(f"Policy template created: {output}")
elif action == "validate":
if not parsed["file"]:
self._print_error("Policy file required")
return self.EXIT_VALIDATION_ERROR
policy = PolicyPack.load(parsed["file"])
self._print_success(f"Policy valid: {policy.name}")
else:
self._print_error(f"Unknown action: {action}. Use: show, init, validate")
return self.EXIT_VALIDATION_ERROR
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def _get_timestamp(self) -> str:
"""Get current timestamp."""
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
def handle_recipe_command(args: List[str]) -> int:
"""Entry point for recipe command."""
handler = RecipeHandler()
@@ -0,0 +1,449 @@
"""
Recipes CLI - Commands for managing and running Agent-Recipes.
Provides commands for:
- praison recipes list - List available recipes
- praison recipes info <name> - Show recipe details
- praison recipes run <name> <input> - Run a recipe
- praison recipes doctor <name> - Check recipe dependencies
- praison recipes explain <name> - Show recipe execution plan
- praison recipes init <name> - Initialize recipe in current directory
"""
import os
import sys
import json
import yaml
import argparse
import logging
from pathlib import Path
from typing import Optional, List, Dict, Any
logger = logging.getLogger(__name__)
# Recipe discovery paths in order of precedence
RECIPE_PATHS = [
Path.home() / ".praison" / "templates",
Path.home() / ".config" / "praison" / "templates",
Path.cwd() / ".praison" / "templates",
Path("/Users/praison/Agent-Recipes/agent_recipes/templates"),
]
def find_recipe_paths() -> List[Path]:
"""Find all valid recipe directories."""
paths = []
for p in RECIPE_PATHS:
if p.exists() and p.is_dir():
paths.append(p)
return paths
def list_recipes() -> List[Dict[str, Any]]:
"""List all available recipes."""
recipes = []
seen = set()
for base_path in find_recipe_paths():
for recipe_dir in base_path.iterdir():
if not recipe_dir.is_dir():
continue
template_file = recipe_dir / "TEMPLATE.yaml"
if not template_file.exists():
continue
name = recipe_dir.name
if name in seen:
continue
seen.add(name)
try:
with open(template_file) as f:
template = yaml.safe_load(f)
recipes.append({
"name": name,
"description": template.get("description", ""),
"version": template.get("version", "1.0.0"),
"tags": template.get("tags", []),
"path": str(recipe_dir),
})
except Exception as e:
logger.warning(f"Failed to load recipe {name}: {e}")
return sorted(recipes, key=lambda x: x["name"])
def get_recipe(name: str) -> Optional[Dict[str, Any]]:
"""Get recipe by name."""
for base_path in find_recipe_paths():
recipe_dir = base_path / name
template_file = recipe_dir / "TEMPLATE.yaml"
if template_file.exists():
with open(template_file) as f:
template = yaml.safe_load(f)
template["path"] = str(recipe_dir)
return template
return None
def check_dependencies(recipe: Dict[str, Any]) -> Dict[str, bool]:
"""Check if recipe dependencies are satisfied."""
results = {}
requires = recipe.get("requires", {})
# Check environment variables
for env_var in requires.get("env", []):
results[f"env:{env_var}"] = bool(os.environ.get(env_var))
# Check Python packages
for package in requires.get("packages", []):
try:
__import__(package.replace("-", "_").split("[")[0])
results[f"package:{package}"] = True
except ImportError:
results[f"package:{package}"] = False
# Check external tools
import shutil
for tool in requires.get("external", []):
results[f"external:{tool}"] = shutil.which(tool) is not None
# Check recipe tools
for tool in requires.get("tools", []):
try:
from praisonai_tools.recipe_tools import __all__ as available_tools
# Check if tool class exists
tool_class = tool.replace("_tool", "").title() + "Tool"
results[f"tool:{tool}"] = tool_class in available_tools or tool in str(available_tools)
except ImportError:
results[f"tool:{tool}"] = False
return results
def cmd_list(args: argparse.Namespace) -> int:
"""List available recipes."""
recipes = list_recipes()
if args.json:
print(json.dumps(recipes, indent=2))
return 0
if not recipes:
print("No recipes found.")
return 0
# Group by first tag if available
if args.group:
groups = {}
for r in recipes:
tag = r["tags"][0] if r["tags"] else "other"
if tag not in groups:
groups[tag] = []
groups[tag].append(r)
for group, group_recipes in sorted(groups.items()):
print(f"\n{group.upper()}:")
for r in group_recipes:
print(f" {r['name']:<35} {r['description'][:50]}")
else:
print(f"{'Recipe':<35} {'Description':<50} {'Version'}")
print("-" * 95)
for r in recipes:
desc = r["description"][:47] + "..." if len(r["description"]) > 50 else r["description"]
print(f"{r['name']:<35} {desc:<50} {r['version']}")
print(f"\nTotal: {len(recipes)} recipes")
return 0
def cmd_info(args: argparse.Namespace) -> int:
"""Show recipe details."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
if args.json:
print(json.dumps(recipe, indent=2, default=str))
return 0
print(f"Name: {recipe.get('name', args.name)}")
print(f"Version: {recipe.get('version', '1.0.0')}")
print(f"Description: {recipe.get('description', '')}")
print(f"Author: {recipe.get('author', 'unknown')}")
print(f"License: {recipe.get('license', 'unknown')}")
print(f"Path: {recipe.get('path', '')}")
if recipe.get("tags"):
print(f"Tags: {', '.join(recipe['tags'])}")
requires = recipe.get("requires", {})
if requires:
print("\nRequirements:")
if requires.get("env"):
print(f" Environment: {', '.join(requires['env'])}")
if requires.get("packages"):
print(f" Packages: {', '.join(requires['packages'])}")
if requires.get("external"):
print(f" External: {', '.join(requires['external'])}")
if requires.get("tools"):
print(f" Tools: {', '.join(requires['tools'])}")
cli = recipe.get("cli", {})
if cli:
print("\nCLI:")
print(f" Command: {cli.get('command', '')}")
if cli.get("examples"):
print(" Examples:")
for ex in cli["examples"]:
print(f" {ex}")
safety = recipe.get("safety", {})
if safety:
print("\nSafety:")
print(f" Dry-run default: {safety.get('dry_run_default', False)}")
if safety.get("requires_consent"):
print(f" Requires consent: {safety.get('consent_message', 'Yes')}")
if safety.get("legal_disclaimer"):
print(f" Legal disclaimer: {safety.get('legal_disclaimer')}")
return 0
def cmd_doctor(args: argparse.Namespace) -> int:
"""Check recipe dependencies."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
print(f"Checking dependencies for: {args.name}")
print("-" * 50)
deps = check_dependencies(recipe)
all_ok = True
for dep, status in sorted(deps.items()):
icon = "✓" if status else "✗"
color = "\033[92m" if status else "\033[91m"
reset = "\033[0m"
print(f" {color}{icon}{reset} {dep}")
if not status:
all_ok = False
print("-" * 50)
if all_ok:
print("All dependencies satisfied!")
return 0
else:
print("\nMissing dependencies. Install with:")
requires = recipe.get("requires", {})
if requires.get("packages"):
missing_pkgs = [p for p in requires["packages"] if not deps.get(f"package:{p}", True)]
if missing_pkgs:
print(f" pip install {' '.join(missing_pkgs)}")
if requires.get("external"):
missing_ext = [e for e in requires["external"] if not deps.get(f"external:{e}", True)]
if missing_ext:
print(f" # Install external tools: {', '.join(missing_ext)}")
if requires.get("env"):
missing_env = [e for e in requires["env"] if not deps.get(f"env:{e}", True)]
if missing_env:
print(f" # Set environment variables: {', '.join(missing_env)}")
return 1
def cmd_run(args: argparse.Namespace) -> int:
"""Run a recipe."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
# Check safety requirements
safety = recipe.get("safety", {})
if safety.get("requires_consent") and not args.consent:
print(f"This recipe requires consent: {safety.get('consent_message', '')}")
print("Use --consent flag to acknowledge.")
return 1
if safety.get("legal_disclaimer"):
print(f"LEGAL DISCLAIMER: {safety['legal_disclaimer']}")
# Check dependencies
if not args.skip_checks:
deps = check_dependencies(recipe)
missing = [k for k, v in deps.items() if not v]
if missing:
print(f"Missing dependencies: {', '.join(missing)}")
print("Run 'praison recipes doctor {args.name}' for details.")
if not args.force:
return 1
# Dry run mode
if args.dry_run or (safety.get("dry_run_default") and not args.write):
print(f"DRY RUN: Would execute recipe '{args.name}'")
print(f" Input: {args.input}")
print(f" Output: {args.output or 'default'}")
print("\nUse --write to execute.")
return 0
# Execute recipe
print(f"Running recipe: {args.name}")
print(f"Input: {args.input}")
# TODO: Implement actual recipe execution via praisonaiagents
# For now, show what would be executed
print("\nRecipe execution would involve:")
for tool in recipe.get("requires", {}).get("tools", []):
print(f" - Using {tool}")
print("\nRecipe execution completed (placeholder).")
return 0
def cmd_explain(args: argparse.Namespace) -> int:
"""Explain recipe execution plan."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
print(f"Execution Plan for: {args.name}")
print("=" * 50)
print("\n1. Dependency Check:")
for tool in recipe.get("requires", {}).get("tools", []):
print(f" - Load {tool}")
print("\n2. Input Processing:")
inputs = recipe.get("inputs", {})
for name, spec in inputs.items():
print(f" - {name}: {spec.get('type', 'unknown')}")
print("\n3. Execution:")
print(f" - Run recipe workflow")
print("\n4. Output:")
outputs = recipe.get("outputs", {})
for name, spec in outputs.items():
print(f" - {name}: {spec.get('type', 'unknown')}")
return 0
def cmd_init(args: argparse.Namespace) -> int:
"""Initialize recipe in current directory."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
target_dir = Path(args.output) if args.output else Path.cwd() / args.name
if target_dir.exists() and not args.force:
print(f"Directory already exists: {target_dir}")
print("Use --force to overwrite.")
return 1
import shutil
source_dir = Path(recipe["path"])
if args.dry_run:
print(f"DRY RUN: Would copy {source_dir} to {target_dir}")
return 0
target_dir.mkdir(parents=True, exist_ok=True)
# Copy recipe files
for item in source_dir.iterdir():
if item.is_file():
shutil.copy2(item, target_dir / item.name)
print(f"Initialized recipe '{args.name}' in {target_dir}")
return 0
def setup_parser(subparsers: argparse._SubParsersAction) -> None:
"""Setup recipes subcommand parser."""
recipes_parser = subparsers.add_parser(
"recipes",
help="Manage and run Agent-Recipes",
description="Commands for managing and running Agent-Recipes templates.",
)
recipes_subparsers = recipes_parser.add_subparsers(dest="recipes_command")
# List command
list_parser = recipes_subparsers.add_parser("list", help="List available recipes")
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
list_parser.add_argument("--group", action="store_true", help="Group by category")
list_parser.set_defaults(func=cmd_list)
# Info command
info_parser = recipes_subparsers.add_parser("info", help="Show recipe details")
info_parser.add_argument("name", help="Recipe name")
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
info_parser.set_defaults(func=cmd_info)
# Doctor command
doctor_parser = recipes_subparsers.add_parser("doctor", help="Check recipe dependencies")
doctor_parser.add_argument("name", help="Recipe name")
doctor_parser.set_defaults(func=cmd_doctor)
# Run command
run_parser = recipes_subparsers.add_parser("run", help="Run a recipe")
run_parser.add_argument("name", help="Recipe name")
run_parser.add_argument("input", nargs="?", help="Input file or value")
run_parser.add_argument("--output", "-o", help="Output directory")
run_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
run_parser.add_argument("--write", action="store_true", help="Actually execute (for dry-run-default recipes)")
run_parser.add_argument("--force", action="store_true", help="Force execution despite missing deps")
run_parser.add_argument("--consent", action="store_true", help="Acknowledge consent requirements")
run_parser.add_argument("--skip-checks", action="store_true", help="Skip dependency checks")
run_parser.add_argument("--json", action="store_true", help="Output as JSON")
run_parser.set_defaults(func=cmd_run)
# Explain command
explain_parser = recipes_subparsers.add_parser("explain", help="Explain recipe execution plan")
explain_parser.add_argument("name", help="Recipe name")
explain_parser.set_defaults(func=cmd_explain)
# Init command
init_parser = recipes_subparsers.add_parser("init", help="Initialize recipe in directory")
init_parser.add_argument("name", help="Recipe name")
init_parser.add_argument("--output", "-o", help="Target directory")
init_parser.add_argument("--force", action="store_true", help="Overwrite existing")
init_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
init_parser.set_defaults(func=cmd_init)
def handle_recipes_command(args: argparse.Namespace) -> int:
"""Handle recipes subcommand."""
if not hasattr(args, "func"):
# No subcommand specified, show help
print("Usage: praison recipes <command> [options]")
print("\nCommands:")
print(" list List available recipes")
print(" info Show recipe details")
print(" doctor Check recipe dependencies")
print(" run Run a recipe")
print(" explain Explain recipe execution plan")
print(" init Initialize recipe in directory")
return 0
return args.func(args)
@@ -0,0 +1,229 @@
"""
Registry CLI Feature Handler
Provides CLI commands for recipe registry management:
- serve: Start local HTTP registry server
- status: Check registry status
All commands use the canonical `praisonai registry` prefix.
"""
import os
import sys
from pathlib import Path
from typing import Any, Dict, List
class RegistryHandler:
"""
CLI handler for registry operations.
Commands:
- serve: Start local HTTP registry server
- status: Check registry server status
"""
# Stable exit codes
EXIT_SUCCESS = 0
EXIT_GENERAL_ERROR = 1
EXIT_VALIDATION_ERROR = 2
EXIT_NETWORK_ERROR = 10
EXIT_AUTH_ERROR = 9
def __init__(self):
"""Initialize the handler."""
pass
def handle(self, args: List[str]) -> int:
"""
Handle registry subcommand.
Args:
args: Command arguments
Returns:
Exit code
"""
if not args:
self._print_help()
return self.EXIT_SUCCESS
command = args[0]
remaining = args[1:]
commands = {
"serve": self.cmd_serve,
"status": self.cmd_status,
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
}
handler = commands.get(command)
if handler:
return handler(remaining)
self._print_error(f"Unknown command: {command}")
self._print_help()
return self.EXIT_VALIDATION_ERROR
def _print_help(self):
"""Print help message."""
print("""
PraisonAI Registry Commands
Usage: praisonai registry <command> [options]
Commands:
serve Start local HTTP registry server
status Check registry server status
Examples:
praisonai registry serve
praisonai registry serve --port 7777 --token mysecret
praisonai registry serve --read-only
praisonai registry status --registry http://localhost:7777
Options for 'serve':
--host HOST Host to bind to (default: 127.0.0.1)
--port PORT Port to bind to (default: 7777)
--dir PATH Registry directory (default: ~/.praison/registry)
--token TOKEN Require token for write operations
--read-only Disable all write operations
--json Output in JSON format
Options for 'status':
--registry URL Registry URL to check (default: http://localhost:7777)
--json Output in JSON format
""")
def _print_error(self, message: str):
"""Print error message."""
print(f"Error: {message}", file=sys.stderr)
def _print_success(self, message: str):
"""Print success message."""
print(f"✓ {message}")
def _print_json(self, data: Any):
"""Print JSON output."""
import json
print(json.dumps(data, indent=2))
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Dict[str, Any]:
"""Parse command arguments based on spec."""
result = {k: v.get("default") for k, v in spec.items()}
i = 0
while i < len(args):
arg = args[i]
if arg.startswith("--"):
key = arg[2:].replace("-", "_")
if key in spec:
if spec[key].get("flag"):
result[key] = True
elif i + 1 < len(args):
result[key] = args[i + 1]
i += 1
elif arg.startswith("-") and len(arg) == 2:
# Short flag
for key, val in spec.items():
if val.get("short") == arg:
if val.get("flag"):
result[key] = True
elif i + 1 < len(args):
result[key] = args[i + 1]
i += 1
break
i += 1
return result
def cmd_serve(self, args: List[str]) -> int:
"""Start local HTTP registry server."""
spec = {
"host": {"default": "127.0.0.1"},
"port": {"default": "7777"},
"dir": {"default": None},
"token": {"default": None},
"read_only": {"flag": True, "default": False},
"json": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
try:
from praisonai.recipe.server import run_server
from praisonai.recipe.registry import DEFAULT_REGISTRY_PATH
host = parsed["host"]
port = int(parsed["port"])
registry_path = Path(parsed["dir"]) if parsed["dir"] else DEFAULT_REGISTRY_PATH
token = parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
read_only = parsed["read_only"]
if parsed["json"]:
self._print_json({
"ok": True,
"message": "Starting registry server",
"host": host,
"port": port,
"registry_path": str(registry_path),
"read_only": read_only,
"auth_required": bool(token),
})
run_server(
host=host,
port=port,
registry_path=registry_path,
token=token,
read_only=read_only,
)
return self.EXIT_SUCCESS
except KeyboardInterrupt:
print("\nServer stopped.")
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
def cmd_status(self, args: List[str]) -> int:
"""Check registry server status."""
spec = {
"registry": {"default": "http://localhost:7777"},
"json": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
try:
from praisonai.recipe.registry import HttpRegistry
registry = HttpRegistry(parsed["registry"])
health = registry.health()
if parsed["json"]:
self._print_json(health)
else:
status = "healthy" if health.get("ok") else "unhealthy"
print(f"Registry: {parsed['registry']}")
print(f"Status: {status}")
if health.get("read_only"):
print("Mode: read-only")
if health.get("auth_required"):
print("Auth: required for writes")
return self.EXIT_SUCCESS
except Exception as e:
if parsed["json"]:
self._print_json({
"ok": False,
"error": str(e),
"registry": parsed["registry"],
})
else:
self._print_error(f"Cannot connect to registry: {e}")
return self.EXIT_NETWORK_ERROR
@@ -0,0 +1,812 @@
"""
Serve CLI Feature Handler
Provides CLI commands for launching PraisonAI servers:
- praisonai serve agents - Launch agents as HTTP API
- praisonai serve recipe - Launch recipe runner
- praisonai serve mcp - Launch MCP server
- praisonai serve tools - Launch tools as MCP server
- praisonai serve a2a - Launch A2A server
- praisonai serve a2u - Launch A2U event stream server
- praisonai serve unified - Launch unified server with all providers
All servers include the unified discovery endpoint at /__praisonai__/discovery.
"""
import os
import sys
from typing import Any, Dict, List
class ServeHandler:
"""
CLI handler for serve operations.
Commands:
- agents: Launch agents as HTTP API
- recipe: Launch recipe runner
- mcp: Launch MCP server
- tools: Launch tools as MCP server
- a2a: Launch A2A server
- a2u: Launch A2U event stream server
- unified: Launch unified server
"""
EXIT_SUCCESS = 0
EXIT_GENERAL_ERROR = 1
EXIT_VALIDATION_ERROR = 2
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8765
def __init__(self):
"""Initialize the handler."""
pass
def handle(self, args: List[str]) -> int:
"""
Handle serve subcommand.
Args:
args: Command arguments
Returns:
Exit code
"""
if not args:
self._print_help()
return self.EXIT_SUCCESS
command = args[0]
remaining = args[1:]
commands = {
"agents": self.cmd_agents,
"recipe": self.cmd_recipe,
"mcp": self.cmd_mcp,
"tools": self.cmd_tools,
"a2a": self.cmd_a2a,
"a2u": self.cmd_a2u,
"unified": self.cmd_unified,
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
}
if command in commands:
return commands[command](remaining)
else:
self._print_error(f"Unknown command: {command}")
self._print_help()
return self.EXIT_GENERAL_ERROR
def _print_help(self):
"""Print help message."""
help_text = """
[bold cyan]PraisonAI Serve[/bold cyan]
Launch PraisonAI servers with unified discovery support.
[bold]Usage:[/bold]
praisonai serve <command> [options]
[bold]Commands:[/bold]
agents Launch agents as HTTP API
recipe Launch recipe runner server
mcp Launch MCP server (HTTP mode)
tools Launch tools as MCP server
a2a Launch A2A protocol server
a2u Launch A2U event stream server
unified Launch unified server with all providers
[bold]Common Options:[/bold]
--host <host> Server host (default: 127.0.0.1)
--port <port> Server port (default: 8765)
--reload Enable hot reload
--api-key <key> API key for authentication
[bold]Agents Options:[/bold]
--file <path> Agents YAML file (default: agents.yaml)
--path <path> API endpoint path (default: /agents)
[bold]Recipe Options:[/bold]
--config <path> Config file path (serve.yaml)
--preload Preload all recipes
[bold]MCP Options:[/bold]
--transport Transport type: http, sse (default: http)
[bold]Examples:[/bold]
praisonai serve agents --file agents.yaml --port 8000
praisonai serve recipe --port 8765
praisonai serve mcp --transport http --port 8080
praisonai serve tools --port 8081
praisonai serve a2a --port 8082
praisonai serve unified --port 8765
[bold]Discovery:[/bold]
All servers expose /__praisonai__/discovery for unified endpoint discovery.
Use `praisonai endpoints` to interact with any server.
"""
self._print_rich(help_text)
def _print_rich(self, text: str):
"""Print with rich formatting if available."""
try:
from rich import print as rprint
rprint(text)
except ImportError:
import re
plain = re.sub(r'\[/?[^\]]+\]', '', text)
print(plain)
def _print_error(self, message: str):
"""Print error message."""
try:
from rich import print as rprint
rprint(f"[red]Error: {message}[/red]")
except ImportError:
print(f"Error: {message}", file=sys.stderr)
def _print_success(self, message: str):
"""Print success message."""
try:
from rich import print as rprint
rprint(f"[green]✓ {message}[/green]")
except ImportError:
print(f"✓ {message}")
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Dict[str, Any]:
"""Parse command arguments based on spec."""
result = {k: v.get("default") for k, v in spec.items()}
i = 0
while i < len(args):
arg = args[i]
if arg.startswith("--"):
key = arg[2:].replace("-", "_")
if key in spec:
if spec[key].get("flag"):
result[key] = True
elif i + 1 < len(args):
value = args[i + 1]
# Type conversion
if spec[key].get("type") == "int":
value = int(value)
result[key] = value
i += 1
i += 1
else:
i += 1
return result
def cmd_agents(self, args: List[str]) -> int:
"""Launch agents as HTTP API."""
spec = {
"file": {"default": "agents.yaml"},
"host": {"default": self.DEFAULT_HOST},
"port": {"default": self.DEFAULT_PORT, "type": "int"},
"path": {"default": "/agents"},
"reload": {"flag": True, "default": False},
"api_key": {"default": None},
}
parsed = self._parse_args(args, spec)
# Check if agents file exists
if not os.path.exists(parsed["file"]):
self._print_error(f"Agents file not found: {parsed['file']}")
return self.EXIT_VALIDATION_ERROR
try:
self._print_success(f"Starting agents server on {parsed['host']}:{parsed['port']}")
print(f" Agents file: {parsed['file']}")
print(f" Endpoint: {parsed['path']}")
print(" Discovery: /__praisonai__/discovery")
# Create and run server
app = self._create_agents_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], parsed["reload"])
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
print("Install with: pip install praisonai[serve]")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_agents_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for agents."""
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from praisonai.endpoints.discovery import (
create_discovery_document,
EndpointInfo,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
# Load agents from YAML
import yaml
with open(config["file"]) as f:
_ = yaml.safe_load(f) # Validate YAML
# Create discovery document
discovery = create_discovery_document(server_name="praisonai-agents")
discovery.add_provider(ProviderInfo(
type="agents-api",
name="Agents API",
description="Agent HTTP API endpoints",
capabilities=["invoke", "health"],
))
# Create app
app = FastAPI(
title="PraisonAI Agents API",
description="HTTP API for PraisonAI Agents",
)
# Add discovery routes
add_discovery_routes(app, discovery)
# Request model
class AgentQuery(BaseModel):
query: str
# Create endpoint for agents
path = config["path"]
@app.post(path)
async def invoke_agents(request: Request, query_data: AgentQuery = None):
"""Invoke agents with a query."""
if query_data is None:
try:
body = await request.json()
_ = body.get("query", "") # Extract query
except Exception:
raise HTTPException(status_code=400, detail="Invalid request")
try:
# Lazy load and run agents
from praisonai.agents_generator import AgentsGenerator
generator = AgentsGenerator(
agent_file=config["file"],
framework="praisonai",
)
result = generator.generate_crew_and_kickoff()
return {"response": result}
except Exception as e:
return JSONResponse(
{"error": str(e)},
status_code=500,
)
# Add endpoint to discovery
discovery.add_endpoint(EndpointInfo(
name=path.lstrip("/"),
description="Invoke agents workflow",
provider_type="agents-api",
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
streaming=["none"],
))
# Root endpoint
@app.get("/")
async def root():
return {
"message": "PraisonAI Agents API",
"endpoints": [path],
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_recipe(self, args: List[str]) -> int:
"""Launch recipe runner server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": self.DEFAULT_PORT, "type": "int"},
"config": {"default": None},
"reload": {"flag": True, "default": False},
"api_key": {"default": None},
"preload": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting recipe server on {parsed['host']}:{parsed['port']}")
print(" Discovery: /__praisonai__/discovery")
from praisonai.recipe.serve import serve, load_config
# Load config
config = load_config(parsed["config"]) if parsed["config"] else {}
if parsed["api_key"]:
config["api_key"] = parsed["api_key"]
serve(
host=parsed["host"],
port=parsed["port"],
reload=parsed["reload"],
config=config,
)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
print("Install with: pip install praisonai[serve]")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def cmd_mcp(self, args: List[str]) -> int:
"""Launch MCP server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8080, "type": "int"},
"transport": {"default": "http"},
"tools_file": {"default": None},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting MCP server on {parsed['host']}:{parsed['port']}")
print(f" Transport: {parsed['transport']}")
print(" Discovery: /__praisonai__/discovery")
app = self._create_mcp_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_mcp_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for MCP server."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
# Create discovery document
discovery = create_discovery_document(server_name="praisonai-mcp")
discovery.add_provider(ProviderInfo(
type="mcp",
name="MCP Server",
description=f"MCP server ({config['transport']} transport)",
capabilities=["list-tools", "call-tool"],
))
app = FastAPI(
title="PraisonAI MCP Server",
description="MCP protocol server",
)
add_discovery_routes(app, discovery)
# MCP tools endpoint
@app.get("/mcp/tools")
async def list_tools():
"""List available MCP tools."""
# TODO: Load tools from config or registry
return {"tools": []}
@app.post("/mcp/tools/call")
async def call_tool(request_data: dict):
"""Call an MCP tool."""
tool_name = request_data.get("tool")
_ = request_data.get("arguments", {}) # Arguments for tool
# TODO: Execute tool
return {"result": None, "tool": tool_name}
@app.get("/")
async def root():
return {
"message": "PraisonAI MCP Server",
"transport": config["transport"],
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_tools(self, args: List[str]) -> int:
"""Launch tools as MCP server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8081, "type": "int"},
"tools_file": {"default": None},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting tools MCP server on {parsed['host']}:{parsed['port']}")
print(" Discovery: /__praisonai__/discovery")
app = self._create_tools_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_tools_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for tools MCP server."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
discovery = create_discovery_document(server_name="praisonai-tools-mcp")
discovery.add_provider(ProviderInfo(
type="tools-mcp",
name="Tools MCP Server",
description="Python tools exposed as MCP server",
capabilities=["list-tools", "call-tool"],
))
app = FastAPI(
title="PraisonAI Tools MCP Server",
description="Tools exposed as MCP server",
)
add_discovery_routes(app, discovery)
@app.get("/tools")
async def list_tools():
"""List available tools."""
return {"tools": []}
@app.post("/tools/call")
async def call_tool(request_data: dict):
"""Call a tool."""
return {"result": None}
@app.get("/")
async def root():
return {
"message": "PraisonAI Tools MCP Server",
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_a2a(self, args: List[str]) -> int:
"""Launch A2A protocol server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8082, "type": "int"},
"file": {"default": "agents.yaml"},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting A2A server on {parsed['host']}:{parsed['port']}")
print(" Agent Card: /.well-known/agent.json")
print(" Discovery: /__praisonai__/discovery")
app = self._create_a2a_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_a2a_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for A2A server."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
EndpointInfo,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
discovery = create_discovery_document(server_name="praisonai-a2a")
discovery.add_provider(ProviderInfo(
type="a2a",
name="A2A Protocol",
description="Agent-to-Agent communication",
capabilities=["agent-card", "message-send"],
))
app = FastAPI(
title="PraisonAI A2A Server",
description="A2A protocol server",
)
add_discovery_routes(app, discovery)
# Agent card
@app.get("/.well-known/agent.json")
async def agent_card():
"""Return agent card for A2A discovery."""
return {
"name": "PraisonAI Agent",
"description": "PraisonAI Agent via A2A protocol",
"version": "1.0.0",
"url": f"http://{config.get('host', 'localhost')}:{config.get('port', 8082)}/a2a",
"capabilities": {
"streaming": True,
"pushNotifications": False,
},
"skills": [],
}
@app.get("/status")
async def status():
"""Return server status."""
return {"status": "ok", "name": "PraisonAI A2A", "version": "1.0.0"}
@app.post("/a2a")
async def a2a_endpoint(request_data: dict):
"""Handle A2A JSON-RPC requests."""
method = request_data.get("method")
params = request_data.get("params", {})
request_id = request_data.get("id")
if method == "message/send":
# Handle message
message = params.get("message", {})
parts = message.get("parts", [])
text = ""
for part in parts:
if part.get("type") == "text":
text += part.get("text", "")
# TODO: Process with agent
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"message": {
"role": "assistant",
"parts": [{"type": "text", "text": f"Received: {text}"}],
},
},
}
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
discovery.add_endpoint(EndpointInfo(
name="a2a",
description="A2A protocol endpoint",
provider_type="a2a",
streaming=["sse"],
))
@app.get("/")
async def root():
return {
"message": "PraisonAI A2A Server",
"agent_card": "/.well-known/agent.json",
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_a2u(self, args: List[str]) -> int:
"""Launch A2U event stream server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8083, "type": "int"},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting A2U server on {parsed['host']}:{parsed['port']}")
print(" Events: /a2u/events/<stream>")
print(" Discovery: /__praisonai__/discovery")
app = self._create_a2u_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_a2u_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for A2U server."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
EndpointInfo,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
from praisonai.endpoints.a2u_server import create_a2u_routes
discovery = create_discovery_document(server_name="praisonai-a2u")
discovery.add_provider(ProviderInfo(
type="a2u",
name="A2U Event Stream",
description="Agent-to-User event streaming",
capabilities=["subscribe", "stream"],
))
app = FastAPI(
title="PraisonAI A2U Server",
description="A2U event stream server",
)
add_discovery_routes(app, discovery)
create_a2u_routes(app)
discovery.add_endpoint(EndpointInfo(
name="events",
description="Agent event stream",
provider_type="a2u",
streaming=["sse"],
))
@app.get("/")
async def root():
return {
"message": "PraisonAI A2U Server",
"events": "/a2u/events/<stream>",
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_unified(self, args: List[str]) -> int:
"""Launch unified server with all providers."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": self.DEFAULT_PORT, "type": "int"},
"file": {"default": "agents.yaml"},
"reload": {"flag": True, "default": False},
"api_key": {"default": None},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting unified server on {parsed['host']}:{parsed['port']}")
print(" Providers: agents-api, recipe, mcp, a2a, a2u")
print(" Discovery: /__praisonai__/discovery")
app = self._create_unified_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], parsed["reload"])
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
print("Install with: pip install praisonai[serve]")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_unified_app(self, config: Dict[str, Any]) -> Any:
"""Create unified FastAPI app with all providers."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
EndpointInfo,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
from praisonai.endpoints.a2u_server import create_a2u_routes
discovery = create_discovery_document(server_name="praisonai-unified")
# Add all providers
discovery.add_provider(ProviderInfo(
type="agents-api",
name="Agents API",
capabilities=["invoke", "health"],
))
discovery.add_provider(ProviderInfo(
type="recipe",
name="Recipe Runner",
capabilities=["list", "describe", "invoke", "stream"],
))
discovery.add_provider(ProviderInfo(
type="mcp",
name="MCP Server",
capabilities=["list-tools", "call-tool"],
))
discovery.add_provider(ProviderInfo(
type="a2a",
name="A2A Protocol",
capabilities=["agent-card", "message-send"],
))
discovery.add_provider(ProviderInfo(
type="a2u",
name="A2U Event Stream",
capabilities=["subscribe", "stream"],
))
app = FastAPI(
title="PraisonAI Unified Server",
description="Unified server with all PraisonAI providers",
)
add_discovery_routes(app, discovery)
create_a2u_routes(app)
# Add sample endpoints
discovery.add_endpoint(EndpointInfo(
name="agents",
description="Agents workflow endpoint",
provider_type="agents-api",
))
discovery.add_endpoint(EndpointInfo(
name="events",
description="Agent event stream",
provider_type="a2u",
streaming=["sse"],
))
@app.get("/")
async def root():
return {
"message": "PraisonAI Unified Server",
"providers": ["agents-api", "recipe", "mcp", "a2a", "a2u"],
"discovery": "/__praisonai__/discovery",
}
@app.get("/.well-known/agent.json")
async def agent_card():
return {
"name": "PraisonAI Unified",
"description": "PraisonAI Unified Server",
"version": "1.0.0",
}
return app
def _run_server(self, app: Any, host: str, port: int, reload: bool) -> None:
"""Run the server with uvicorn."""
import uvicorn
uvicorn.run(app, host=host, port=port, reload=reload)
def handle_serve_command(args: List[str]) -> int:
"""Entry point for serve command."""
handler = ServeHandler()
return handler.handle(args)
+36 -2
View File
@@ -698,7 +698,7 @@ class PraisonAI:
return default_args
# Define special commands
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor']
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall']
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -1193,11 +1193,17 @@ class PraisonAI:
sys.exit(exit_code)
elif args.command == 'endpoints':
# Endpoints command - client CLI for recipe endpoints
# Endpoints command - unified client CLI for all server types
from .features.endpoints import handle_endpoints_command
exit_code = handle_endpoints_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'serve':
# Serve command - launch PraisonAI servers
from .features.serve import handle_serve_command
exit_code = handle_serve_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'agents':
# Agents command - run multiple agents with custom definitions
if not PRAISONAI_AVAILABLE:
@@ -1377,6 +1383,34 @@ class PraisonAI:
handler = DoctorHandler()
exit_code = handler.run(unknown_args)
sys.exit(exit_code)
elif args.command == 'registry':
# Registry command - manage recipe registry server
from .features.registry import RegistryHandler
handler = RegistryHandler()
exit_code = handler.handle(unknown_args)
sys.exit(exit_code)
elif args.command == 'package':
# Package command - pip-like package management
from .features.package import PackageHandler
handler = PackageHandler()
exit_code = handler.handle(unknown_args)
sys.exit(exit_code)
elif args.command == 'install':
# Install command - shortcut for package install
from .features.package import PackageHandler
handler = PackageHandler()
exit_code = handler.cmd_install(unknown_args)
sys.exit(exit_code)
elif args.command == 'uninstall':
# Uninstall command - shortcut for package uninstall
from .features.package import PackageHandler
handler = PackageHandler()
exit_code = handler.cmd_uninstall(unknown_args)
sys.exit(exit_code)
# Only check framework availability for agent-related operations
if not args.command and (args.init or args.auto or args.framework):
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.7.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.7.6 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -0,0 +1,53 @@
"""
Unified Endpoints Module
Provides unified discovery, provider adapters, and server utilities
for all PraisonAI serve features.
Provider Types:
- recipe: Recipe runner endpoints
- agents-api: Single/multi-agent HTTP API
- mcp: MCP server (stdio, http, sse)
- tools-mcp: Tools exposed as MCP server
- a2a: Agent-to-agent protocol
- a2u: Agent-to-user event stream
Architecture:
- Discovery schema is versioned and consistent across all providers
- Provider adapters abstract protocol differences
- CLI client uses unified discovery for list/describe/invoke/health
"""
# Lazy loading for all exports
_lazy_imports = {
"DiscoveryDocument": ".discovery",
"EndpointInfo": ".discovery",
"ProviderInfo": ".discovery",
"SCHEMA_VERSION": ".discovery",
"create_discovery_document": ".discovery",
"BaseProvider": ".providers.base",
"RecipeProvider": ".providers.recipe",
"AgentsAPIProvider": ".providers.agents_api",
"MCPProvider": ".providers.mcp",
"ToolsMCPProvider": ".providers.tools_mcp",
"A2AProvider": ".providers.a2a",
"A2UProvider": ".providers.a2u",
"ProviderRegistry": ".registry",
"get_provider": ".registry",
"register_provider": ".registry",
"list_provider_types": ".registry",
"get_provider_class": ".registry",
"create_unified_app": ".server",
"add_discovery_routes": ".server",
}
def __getattr__(name: str):
if name in _lazy_imports:
module_path = _lazy_imports[name]
import importlib
module = importlib.import_module(module_path, package=__name__)
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
return list(_lazy_imports.keys())
@@ -0,0 +1,410 @@
"""
A2U (Agent-to-User) Event Stream Server
Provides SSE-based event streaming for agent-to-user communication.
"""
import asyncio
import json
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set
logger = logging.getLogger(__name__)
@dataclass
class A2UEvent:
"""An event in the A2U stream."""
event_type: str
data: Dict[str, Any]
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
event_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
def to_sse(self) -> str:
"""Convert to SSE format."""
return f"event: {self.event_type}\ndata: {json.dumps(self.data)}\nid: {self.event_id}\n\n"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"event_type": self.event_type,
"data": self.data,
"timestamp": self.timestamp,
"event_id": self.event_id,
}
@dataclass
class A2USubscription:
"""A subscription to an A2U event stream."""
subscription_id: str
stream_name: str
filters: List[str] = field(default_factory=list)
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def matches_event(self, event: A2UEvent) -> bool:
"""Check if event matches subscription filters."""
if not self.filters:
return True
return event.event_type in self.filters
class A2UEventBus:
"""
Event bus for A2U event distribution.
Manages subscriptions and broadcasts events to subscribers.
"""
def __init__(self):
"""Initialize the event bus."""
self._subscriptions: Dict[str, A2USubscription] = {}
self._queues: Dict[str, asyncio.Queue] = {}
self._streams: Dict[str, Set[str]] = {} # stream_name -> subscription_ids
def subscribe(
self,
stream_name: str = "events",
filters: Optional[List[str]] = None,
) -> A2USubscription:
"""
Subscribe to an event stream.
Args:
stream_name: Name of the stream to subscribe to
filters: Optional list of event types to filter
Returns:
A2USubscription object
"""
subscription_id = f"sub-{uuid.uuid4().hex[:12]}"
subscription = A2USubscription(
subscription_id=subscription_id,
stream_name=stream_name,
filters=filters or [],
)
self._subscriptions[subscription_id] = subscription
self._queues[subscription_id] = asyncio.Queue()
if stream_name not in self._streams:
self._streams[stream_name] = set()
self._streams[stream_name].add(subscription_id)
logger.debug(f"Created subscription {subscription_id} for stream {stream_name}")
return subscription
def unsubscribe(self, subscription_id: str) -> bool:
"""
Unsubscribe from an event stream.
Args:
subscription_id: ID of the subscription to remove
Returns:
True if unsubscribed, False if not found
"""
if subscription_id not in self._subscriptions:
return False
subscription = self._subscriptions[subscription_id]
# Remove from stream set
if subscription.stream_name in self._streams:
self._streams[subscription.stream_name].discard(subscription_id)
# Clean up
del self._subscriptions[subscription_id]
del self._queues[subscription_id]
logger.debug(f"Removed subscription {subscription_id}")
return True
async def publish(self, event: A2UEvent, stream_name: str = "events") -> int:
"""
Publish an event to a stream.
Args:
event: Event to publish
stream_name: Name of the stream
Returns:
Number of subscribers that received the event
"""
if stream_name not in self._streams:
return 0
count = 0
for sub_id in self._streams[stream_name]:
subscription = self._subscriptions.get(sub_id)
if subscription and subscription.matches_event(event):
await self._queues[sub_id].put(event)
count += 1
logger.debug(f"Published event {event.event_type} to {count} subscribers")
return count
def publish_sync(self, event: A2UEvent, stream_name: str = "events") -> int:
"""
Synchronously publish an event (creates event loop if needed).
Args:
event: Event to publish
stream_name: Name of the stream
Returns:
Number of subscribers that received the event
"""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Schedule in running loop
asyncio.ensure_future(self.publish(event, stream_name))
return len(self._streams.get(stream_name, set()))
else:
return loop.run_until_complete(self.publish(event, stream_name))
except RuntimeError:
# No event loop, create one
return asyncio.run(self.publish(event, stream_name))
async def get_events(
self,
subscription_id: str,
timeout: float = 30.0,
):
"""
Async generator for subscription events.
Args:
subscription_id: ID of the subscription
timeout: Timeout for waiting for events
Yields:
A2UEvent objects
"""
if subscription_id not in self._queues:
return
queue = self._queues[subscription_id]
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=timeout)
yield event
except asyncio.TimeoutError:
# Send keepalive
yield A2UEvent(event_type="keepalive", data={})
# Global event bus instance
_event_bus: Optional[A2UEventBus] = None
def get_event_bus() -> A2UEventBus:
"""Get or create the global event bus."""
global _event_bus
if _event_bus is None:
_event_bus = A2UEventBus()
return _event_bus
def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None:
"""
Add A2U routes to a FastAPI/Starlette application.
Args:
app: FastAPI or Starlette application
event_bus: Optional event bus (uses global if not provided)
"""
bus = event_bus or get_event_bus()
try:
from starlette.responses import JSONResponse, StreamingResponse
from starlette.routing import Route
except ImportError:
try:
from fastapi.responses import JSONResponse, StreamingResponse
except ImportError:
raise ImportError("Starlette or FastAPI required for A2U routes")
async def a2u_info(request):
"""GET /a2u/info - Get A2U server info."""
return JSONResponse({
"name": "A2U Event Stream",
"version": "1.0.0",
"streams": list(bus._streams.keys()) or ["events"],
"event_types": [
"agent.started",
"agent.thinking",
"agent.tool_call",
"agent.response",
"agent.completed",
"agent.error",
],
})
async def a2u_subscribe(request):
"""POST /a2u/subscribe - Subscribe to an event stream."""
try:
body = await request.json()
except Exception:
body = {}
stream_name = body.get("stream", "events")
filters = body.get("filters", [])
subscription = bus.subscribe(stream_name, filters)
base_url = str(request.url).rsplit("/", 1)[0]
return JSONResponse({
"subscription_id": subscription.subscription_id,
"stream_name": subscription.stream_name,
"stream_url": f"{base_url}/events/{subscription.subscription_id}",
"created_at": subscription.created_at,
})
async def a2u_unsubscribe(request):
"""POST /a2u/unsubscribe - Unsubscribe from an event stream."""
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
subscription_id = body.get("subscription_id")
if not subscription_id:
return JSONResponse({"error": "subscription_id required"}, status_code=400)
if bus.unsubscribe(subscription_id):
return JSONResponse({"status": "unsubscribed"})
else:
return JSONResponse({"error": "Subscription not found"}, status_code=404)
async def a2u_events_stream(request):
"""GET /a2u/events/{stream_name} - Stream events via SSE."""
stream_name = request.path_params.get("stream_name", "events")
# Create subscription for this stream
subscription = bus.subscribe(stream_name)
async def event_generator():
try:
async for event in bus.get_events(subscription.subscription_id):
yield event.to_sse()
finally:
bus.unsubscribe(subscription.subscription_id)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def a2u_events_subscription(request):
"""GET /a2u/events/sub/{subscription_id} - Stream events for subscription."""
subscription_id = request.path_params.get("subscription_id")
if subscription_id not in bus._subscriptions:
return JSONResponse({"error": "Subscription not found"}, status_code=404)
async def event_generator():
async for event in bus.get_events(subscription_id):
yield event.to_sse()
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def a2u_health(request):
"""GET /a2u/health - A2U health check."""
return JSONResponse({
"status": "healthy",
"active_subscriptions": len(bus._subscriptions),
"active_streams": len(bus._streams),
})
# Add routes based on app type
if hasattr(app, 'add_api_route'):
# FastAPI
app.add_api_route("/a2u/info", a2u_info, methods=["GET"])
app.add_api_route("/a2u/subscribe", a2u_subscribe, methods=["POST"])
app.add_api_route("/a2u/unsubscribe", a2u_unsubscribe, methods=["POST"])
app.add_api_route("/a2u/events/{stream_name}", a2u_events_stream, methods=["GET"])
app.add_api_route("/a2u/events/sub/{subscription_id}", a2u_events_subscription, methods=["GET"])
app.add_api_route("/a2u/health", a2u_health, methods=["GET"])
elif hasattr(app, 'routes'):
# Starlette
app.routes.extend([
Route("/a2u/info", a2u_info, methods=["GET"]),
Route("/a2u/subscribe", a2u_subscribe, methods=["POST"]),
Route("/a2u/unsubscribe", a2u_unsubscribe, methods=["POST"]),
Route("/a2u/events/{stream_name}", a2u_events_stream, methods=["GET"]),
Route("/a2u/events/sub/{subscription_id}", a2u_events_subscription, methods=["GET"]),
Route("/a2u/health", a2u_health, methods=["GET"]),
])
# Helper functions for publishing events
def emit_agent_event(
event_type: str,
data: Dict[str, Any],
agent_id: Optional[str] = None,
stream_name: str = "events",
) -> None:
"""
Emit an agent event to the A2U stream.
Args:
event_type: Type of event (e.g., "agent.started", "agent.response")
data: Event data
agent_id: Optional agent ID to include
stream_name: Stream to publish to
"""
if agent_id:
data["agent_id"] = agent_id
event = A2UEvent(event_type=event_type, data=data)
get_event_bus().publish_sync(event, stream_name)
def emit_agent_started(agent_id: str, agent_name: str, **kwargs) -> None:
"""Emit agent.started event."""
emit_agent_event("agent.started", {"agent_name": agent_name, **kwargs}, agent_id)
def emit_agent_thinking(agent_id: str, message: str = "", **kwargs) -> None:
"""Emit agent.thinking event."""
emit_agent_event("agent.thinking", {"message": message, **kwargs}, agent_id)
def emit_agent_tool_call(agent_id: str, tool_name: str, arguments: Dict = None, **kwargs) -> None:
"""Emit agent.tool_call event."""
emit_agent_event("agent.tool_call", {"tool_name": tool_name, "arguments": arguments or {}, **kwargs}, agent_id)
def emit_agent_response(agent_id: str, response: str, **kwargs) -> None:
"""Emit agent.response event."""
emit_agent_event("agent.response", {"response": response, **kwargs}, agent_id)
def emit_agent_completed(agent_id: str, result: Any = None, **kwargs) -> None:
"""Emit agent.completed event."""
emit_agent_event("agent.completed", {"result": result, **kwargs}, agent_id)
def emit_agent_error(agent_id: str, error: str, **kwargs) -> None:
"""Emit agent.error event."""
emit_agent_event("agent.error", {"error": error, **kwargs}, agent_id)
@@ -0,0 +1,165 @@
"""
Unified Discovery Schema
Provides versioned discovery document format for all PraisonAI serve features.
This schema is consistent across all provider types.
"""
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional
from enum import Enum
# Schema version - increment on breaking changes
SCHEMA_VERSION = "1.0.0"
class ProviderType(str, Enum):
"""Supported provider types."""
RECIPE = "recipe"
AGENTS_API = "agents-api"
MCP = "mcp"
TOOLS_MCP = "tools-mcp"
A2A = "a2a"
A2U = "a2u"
class StreamingMode(str, Enum):
"""Supported streaming modes."""
NONE = "none"
SSE = "sse"
WEBSOCKET = "websocket"
MCP_STREAM = "mcp-stream"
class AuthMode(str, Enum):
"""Supported authentication modes."""
NONE = "none"
API_KEY = "api-key"
BEARER = "bearer"
JWT = "jwt"
@dataclass
class EndpointInfo:
"""Information about a single endpoint."""
name: str
description: str = ""
provider_type: str = "recipe"
tags: List[str] = field(default_factory=list)
version: str = "1.0.0"
# Schema information
input_schema: Optional[Dict[str, Any]] = None
output_schema: Optional[Dict[str, Any]] = None
# Capabilities
streaming: List[str] = field(default_factory=lambda: ["none"])
auth_modes: List[str] = field(default_factory=lambda: ["none"])
# Metadata
examples: List[Dict[str, Any]] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
@dataclass
class ProviderInfo:
"""Information about a provider."""
type: str
name: str
version: str = "1.0.0"
description: str = ""
capabilities: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
@dataclass
class DiscoveryDocument:
"""
Unified discovery document for PraisonAI servers.
This document is served at /__praisonai__/discovery and provides
a consistent interface for discovering endpoints across all provider types.
"""
schema_version: str = SCHEMA_VERSION
server_name: str = "praisonai"
server_version: str = "1.0.0"
# Provider information
providers: List[ProviderInfo] = field(default_factory=list)
# Endpoint information
endpoints: List[EndpointInfo] = field(default_factory=list)
# Server capabilities
auth_modes: List[str] = field(default_factory=lambda: ["none"])
streaming_modes: List[str] = field(default_factory=lambda: ["none", "sse"])
# Metadata
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"schema_version": self.schema_version,
"server_name": self.server_name,
"server_version": self.server_version,
"providers": [p.to_dict() for p in self.providers],
"endpoints": [e.to_dict() for e in self.endpoints],
"auth_modes": self.auth_modes,
"streaming_modes": self.streaming_modes,
"metadata": self.metadata,
}
def add_provider(self, provider: ProviderInfo) -> None:
"""Add a provider to the discovery document."""
self.providers.append(provider)
def add_endpoint(self, endpoint: EndpointInfo) -> None:
"""Add an endpoint to the discovery document."""
self.endpoints.append(endpoint)
def get_endpoints_by_type(self, provider_type: str) -> List[EndpointInfo]:
"""Get endpoints filtered by provider type."""
return [e for e in self.endpoints if e.provider_type == provider_type]
def get_endpoint_by_name(self, name: str) -> Optional[EndpointInfo]:
"""Get an endpoint by name."""
for e in self.endpoints:
if e.name == name:
return e
return None
def create_discovery_document(
server_name: str = "praisonai",
server_version: Optional[str] = None,
) -> DiscoveryDocument:
"""
Create a new discovery document.
Args:
server_name: Name of the server
server_version: Version of the server (auto-detected if not provided)
Returns:
DiscoveryDocument instance
"""
if server_version is None:
try:
from praisonai.version import __version__
server_version = __version__
except ImportError:
server_version = "unknown"
return DiscoveryDocument(
server_name=server_name,
server_version=server_version,
)
@@ -0,0 +1,28 @@
"""
Provider Adapters
Each provider adapter implements the BaseProvider interface
and handles protocol-specific details for discovery and invocation.
"""
# Lazy loading for all exports
_lazy_imports = {
"BaseProvider": ".base",
"RecipeProvider": ".recipe",
"AgentsAPIProvider": ".agents_api",
"MCPProvider": ".mcp",
"ToolsMCPProvider": ".tools_mcp",
"A2AProvider": ".a2a",
"A2UProvider": ".a2u",
}
def __getattr__(name: str):
if name in _lazy_imports:
module_path = _lazy_imports[name]
import importlib
module = importlib.import_module(module_path, package=__name__)
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
return list(_lazy_imports.keys())
@@ -0,0 +1,253 @@
"""
A2A Provider
Provider adapter for Agent-to-Agent protocol endpoints.
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class A2AProvider(BaseProvider):
"""
Provider adapter for A2A (Agent-to-Agent) protocol endpoints.
A2A enables agent-to-agent communication following the A2A protocol spec.
Supports discovery via /.well-known/agent.json and message passing.
"""
provider_type = "a2a"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="A2A Protocol",
description="Agent-to-Agent communication protocol",
capabilities=["agent-card", "message-send", "task-management"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available A2A agents."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
streaming=ep.get("streaming", ["sse"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: try agent card
result = self._make_request("GET", "/.well-known/agent.json")
if result.get("error"):
return []
agent_card = result.get("data", {})
return [EndpointInfo(
name=agent_card.get("name", "a2a-agent"),
description=agent_card.get("description", "A2A Agent"),
provider_type=self.provider_type,
version=agent_card.get("version", "1.0.0"),
streaming=["sse"] if agent_card.get("capabilities", {}).get("streaming") else ["none"],
auth_modes=["none"],
metadata={"agent_card": agent_card},
)]
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about an A2A agent."""
# Get agent card
result = self._make_request("GET", "/.well-known/agent.json")
if result.get("error"):
return None
agent_card = result.get("data", {})
return EndpointInfo(
name=agent_card.get("name", name),
description=agent_card.get("description", ""),
provider_type=self.provider_type,
version=agent_card.get("version", "1.0.0"),
streaming=["sse"] if agent_card.get("capabilities", {}).get("streaming") else ["none"],
auth_modes=["none"],
metadata={
"agent_card": agent_card,
"skills": agent_card.get("skills", []),
"capabilities": agent_card.get("capabilities", {}),
},
)
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Send a message to an A2A agent."""
import uuid
# Build A2A message
message = input_data.get("message", "") if input_data else ""
if not message and config:
message = config.get("message", config.get("query", ""))
body = {
"jsonrpc": "2.0",
"method": "message/send",
"id": str(uuid.uuid4()),
"params": {
"message": {
"role": "user",
"parts": [{"type": "text", "text": message}],
},
},
}
result = self._make_request("POST", "/a2a", json_data=body)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
# Handle JSON-RPC response
if "result" in data:
return InvokeResult(
ok=True,
status="success",
data=data["result"],
)
elif "error" in data:
return InvokeResult(
ok=False,
status="error",
error=data["error"].get("message", str(data["error"])),
)
return InvokeResult(
ok=True,
status="success",
data=data,
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Send a streaming message to an A2A agent."""
import json
import urllib.request
import uuid
message = input_data.get("message", "") if input_data else ""
if not message and config:
message = config.get("message", config.get("query", ""))
body = {
"jsonrpc": "2.0",
"method": "message/stream",
"id": str(uuid.uuid4()),
"params": {
"message": {
"role": "user",
"parts": [{"type": "text", "text": message}],
},
},
}
full_url = f"{self.base_url}/a2a"
headers = {
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
if self.api_key:
headers["X-API-Key"] = self.api_key
data = json.dumps(body).encode("utf-8")
try:
req = urllib.request.Request(full_url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=300) as response:
buffer = ""
for line in response:
line = line.decode("utf-8")
buffer += line
if buffer.endswith("\n\n"):
event_type = "message"
event_data = ""
for part in buffer.strip().split("\n"):
if part.startswith("event:"):
event_type = part[6:].strip()
elif part.startswith("data:"):
event_data = part[5:].strip()
if event_data:
try:
yield {"event": event_type, "data": json.loads(event_data)}
except json.JSONDecodeError:
yield {"event": event_type, "data": event_data}
buffer = ""
except Exception as e:
yield {"event": "error", "data": {"error": str(e)}}
def health(self) -> HealthResult:
"""Check A2A server health."""
# Try status endpoint first
result = self._make_request("GET", "/status")
if not result.get("error"):
data = result.get("data", {})
return HealthResult(
healthy=data.get("status") == "ok",
status=data.get("status", "unknown"),
server_name=data.get("name", "A2A Agent"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
# Fallback: try agent card
result = self._make_request("GET", "/.well-known/agent.json")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
agent_card = result.get("data", {})
return HealthResult(
healthy=True,
status="healthy",
server_name=agent_card.get("name", "A2A Agent"),
server_version=agent_card.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,208 @@
"""
A2U Provider
Provider adapter for Agent-to-User event stream endpoints.
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class A2UProvider(BaseProvider):
"""
Provider adapter for A2U (Agent-to-User) event stream endpoints.
A2U provides a protocol surface for streaming agent events to users,
typically via SSE or WebSocket. This enables real-time UI updates.
"""
provider_type = "a2u"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="A2U Event Stream",
description="Agent-to-User event streaming protocol",
capabilities=["event-stream", "subscribe", "unsubscribe"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available A2U event streams."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
streaming=ep.get("streaming", ["sse"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: check for events endpoint
result = self._make_request("GET", "/a2u/info")
if result.get("error"):
# Return default event stream endpoint
return [EndpointInfo(
name="events",
description="Agent event stream",
provider_type=self.provider_type,
streaming=["sse"],
auth_modes=["none"],
)]
data = result.get("data", {})
streams = data.get("streams", [{"name": "events", "description": "Agent event stream"}])
endpoints = []
for stream in streams:
endpoints.append(EndpointInfo(
name=stream.get("name", "events"),
description=stream.get("description", "Event stream"),
provider_type=self.provider_type,
streaming=["sse", "websocket"],
auth_modes=["none"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about an A2U event stream."""
endpoints = self.list_endpoints()
for ep in endpoints:
if ep.name == name:
return ep
# Return default info
return EndpointInfo(
name=name,
description=f"Event stream: {name}",
provider_type=self.provider_type,
streaming=["sse"],
auth_modes=["none"],
metadata={
"event_types": [
"agent.started",
"agent.thinking",
"agent.tool_call",
"agent.response",
"agent.completed",
"agent.error",
],
},
)
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Subscribe to an A2U event stream (returns subscription info)."""
body = {
"stream": name,
"filters": input_data.get("filters", []) if input_data else [],
}
result = self._make_request("POST", "/a2u/subscribe", json_data=body)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=True,
status="subscribed",
data={
"subscription_id": data.get("subscription_id"),
"stream_url": data.get("stream_url", f"{self.base_url}/a2u/events/{name}"),
},
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Stream events from an A2U endpoint."""
import json
import urllib.request
stream_path = f"/a2u/events/{name}"
full_url = f"{self.base_url}{stream_path}"
headers = {"Accept": "text/event-stream"}
if self.api_key:
headers["X-API-Key"] = self.api_key
try:
req = urllib.request.Request(full_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=300) as response:
buffer = ""
for line in response:
line = line.decode("utf-8")
buffer += line
if buffer.endswith("\n\n"):
event_type = "event"
event_data = ""
for part in buffer.strip().split("\n"):
if part.startswith("event:"):
event_type = part[6:].strip()
elif part.startswith("data:"):
event_data = part[5:].strip()
if event_data:
try:
yield {"event": event_type, "data": json.loads(event_data)}
except json.JSONDecodeError:
yield {"event": event_type, "data": event_data}
buffer = ""
except Exception as e:
yield {"event": "error", "data": {"error": str(e)}}
def health(self) -> HealthResult:
"""Check A2U server health."""
result = self._make_request("GET", "/health")
if result.get("error"):
# Try a2u-specific health
result = self._make_request("GET", "/a2u/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=True,
status="healthy",
server_name=data.get("server_name", "A2U Server"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,171 @@
"""
Agents API Provider
Provider adapter for agents-as-API endpoints (single agent or multi-agent router).
"""
from typing import Any, Dict, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class AgentsAPIProvider(BaseProvider):
"""
Provider adapter for agents-as-API endpoints.
Connects to FastAPI-based agent servers launched via Agent.launch() or Agents.launch()
and provides unified discovery and invocation interface.
"""
provider_type = "agents-api"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="Agents API",
description="Agent HTTP API endpoints",
capabilities=["list", "describe", "invoke", "health"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available agent endpoints."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
version=ep.get("version", "1.0.0"),
streaming=ep.get("streaming", ["none"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: try root endpoint for endpoint list
result = self._make_request("GET", "/")
if result.get("error"):
return []
data = result.get("data", {})
endpoint_paths = data.get("endpoints", [])
endpoints = []
for path in endpoint_paths:
endpoints.append(EndpointInfo(
name=path.lstrip("/"),
description=f"Agent endpoint at {path}",
provider_type=self.provider_type,
streaming=["none"],
auth_modes=["none"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about an agent endpoint."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
for ep in data.get("endpoints", []):
if ep.get("name") == name:
return EndpointInfo(
name=ep.get("name", name),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
version=ep.get("version", "1.0.0"),
input_schema=ep.get("input_schema"),
output_schema=ep.get("output_schema"),
streaming=ep.get("streaming", ["none"]),
auth_modes=ep.get("auth_modes", ["none"]),
metadata=ep.get("metadata", {}),
)
# Fallback: return basic info
return EndpointInfo(
name=name,
description=f"Agent endpoint: {name}",
provider_type=self.provider_type,
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
streaming=["none"],
auth_modes=["none"],
)
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Invoke an agent endpoint."""
# Normalize endpoint path
path = name if name.startswith("/") else f"/{name}"
# Build request body
body = input_data or {}
if "query" not in body and config and "query" in config:
body["query"] = config["query"]
result = self._make_request("POST", path, json_data=body)
if result.get("status") == 401:
return InvokeResult(
ok=False,
status="auth_error",
error="Authentication required",
)
if result.get("status") == 404:
return InvokeResult(
ok=False,
status="not_found",
error=f"Endpoint not found: {name}",
)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=True,
status="success",
data=data.get("response", data),
)
def health(self) -> HealthResult:
"""Check agent server health."""
result = self._make_request("GET", "/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=data.get("status") == "ok",
status=data.get("status", "unknown"),
server_name="PraisonAI Agents API",
provider_type=self.provider_type,
metadata={"endpoints": data.get("endpoints", [])},
)
@@ -0,0 +1,231 @@
"""
Base Provider Interface
Abstract base class for all provider adapters.
Each provider implements discovery, invocation, and health check methods.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, Iterator, List, Optional
from ..discovery import EndpointInfo, ProviderInfo
@dataclass
class InvokeResult:
"""Result from invoking an endpoint."""
ok: bool
status: str = "success"
data: Optional[Any] = None
error: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"ok": self.ok,
"status": self.status,
"data": self.data,
"error": self.error,
"metadata": self.metadata,
}
@dataclass
class HealthResult:
"""Result from health check."""
healthy: bool
status: str = "healthy"
server_name: Optional[str] = None
server_version: Optional[str] = None
provider_type: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"healthy": self.healthy,
"status": self.status,
"server_name": self.server_name,
"server_version": self.server_version,
"provider_type": self.provider_type,
"metadata": self.metadata,
}
class BaseProvider(ABC):
"""
Abstract base class for provider adapters.
Each provider type (recipe, agents-api, mcp, etc.) implements this interface
to provide consistent discovery and invocation behavior.
"""
# Provider type identifier
provider_type: str = "base"
def __init__(
self,
base_url: str = "http://localhost:8765",
api_key: Optional[str] = None,
timeout: float = 30.0,
):
"""
Initialize the provider.
Args:
base_url: Base URL of the server
api_key: Optional API key for authentication
timeout: Request timeout in seconds
"""
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
@abstractmethod
def get_provider_info(self) -> ProviderInfo:
"""
Get provider information.
Returns:
ProviderInfo with provider details
"""
pass
@abstractmethod
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""
List available endpoints.
Args:
tags: Optional list of tags to filter by
Returns:
List of EndpointInfo objects
"""
pass
@abstractmethod
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""
Get detailed information about an endpoint.
Args:
name: Endpoint name
Returns:
EndpointInfo or None if not found
"""
pass
@abstractmethod
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""
Invoke an endpoint.
Args:
name: Endpoint name
input_data: Input data for the endpoint
config: Optional configuration overrides
stream: Whether to stream the response
Returns:
InvokeResult with response data
"""
pass
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""
Invoke an endpoint with streaming.
Args:
name: Endpoint name
input_data: Input data for the endpoint
config: Optional configuration overrides
Yields:
Stream events as dictionaries
"""
# Default implementation - subclasses can override
result = self.invoke(name, input_data, config, stream=True)
if result.ok:
yield {"event": "complete", "data": result.data}
else:
yield {"event": "error", "data": {"error": result.error}}
@abstractmethod
def health(self) -> HealthResult:
"""
Check server health.
Returns:
HealthResult with health status
"""
pass
def _make_request(
self,
method: str,
path: str,
json_data: Optional[Dict] = None,
) -> Dict[str, Any]:
"""
Make HTTP request to server.
Args:
method: HTTP method
path: URL path
json_data: Optional JSON body
Returns:
Response data dictionary
"""
import json
import urllib.request
import urllib.error
full_url = f"{self.base_url}{path}"
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["X-API-Key"] = self.api_key
data = None
if json_data:
data = json.dumps(json_data).encode("utf-8")
try:
req = urllib.request.Request(
full_url,
data=data,
headers=headers,
method=method,
)
with urllib.request.urlopen(req, timeout=self.timeout) as response:
body = response.read().decode("utf-8")
return {"status": response.status, "data": json.loads(body) if body else {}}
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8") if e.fp else ""
try:
error_data = json.loads(body) if body else {}
except json.JSONDecodeError:
error_data = {"message": body}
return {"status": e.code, "error": error_data}
except urllib.error.URLError as e:
return {"status": 0, "error": {"message": f"Connection error: {e.reason}"}}
except Exception as e:
return {"status": 0, "error": {"message": str(e)}}
@@ -0,0 +1,263 @@
"""
MCP Provider
Provider adapter for MCP server endpoints (stdio, http, sse).
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class MCPProvider(BaseProvider):
"""
Provider adapter for MCP server endpoints.
Supports multiple MCP transport types:
- stdio: Standard input/output (requires subprocess)
- http: HTTP-based MCP server
- sse: Server-Sent Events MCP gateway
"""
provider_type = "mcp"
def __init__(
self,
base_url: str = "http://localhost:8765",
api_key: Optional[str] = None,
timeout: float = 30.0,
transport: str = "http",
command: Optional[str] = None,
args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None,
):
"""
Initialize MCP provider.
Args:
base_url: Base URL for HTTP/SSE transport
api_key: Optional API key
timeout: Request timeout
transport: Transport type (stdio, http, sse)
command: Command for stdio transport
args: Command arguments for stdio transport
env: Environment variables for stdio transport
"""
super().__init__(base_url, api_key, timeout)
self.transport = transport
self.command = command
self.args = args or []
self.env = env or {}
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="MCP Server",
description=f"MCP server ({self.transport} transport)",
capabilities=["list-tools", "call-tool", "list-resources"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available MCP tools as endpoints."""
if self.transport == "stdio":
return self._list_stdio_tools(tags)
else:
return self._list_http_tools(tags)
def _list_stdio_tools(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List tools from stdio MCP server."""
try:
from praisonaiagents.mcp import MCP
mcp = MCP(command=self.command, args=self.args, env=self.env)
tools = mcp.get_tools()
endpoints = []
for tool in tools:
tool_name = getattr(tool, 'name', str(tool))
tool_desc = getattr(tool, 'description', '')
endpoints.append(EndpointInfo(
name=tool_name,
description=tool_desc,
provider_type=self.provider_type,
streaming=["mcp-stream"],
auth_modes=["none"],
metadata={"transport": "stdio"},
))
return endpoints
except Exception:
return []
def _list_http_tools(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List tools from HTTP MCP server."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
streaming=ep.get("streaming", ["mcp-stream"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: try MCP tools endpoint
result = self._make_request("GET", "/mcp/tools")
if result.get("error"):
return []
tools = result.get("data", {}).get("tools", [])
endpoints = []
for tool in tools:
endpoints.append(EndpointInfo(
name=tool.get("name", ""),
description=tool.get("description", ""),
provider_type=self.provider_type,
input_schema=tool.get("inputSchema"),
streaming=["mcp-stream"],
auth_modes=["none"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about an MCP tool."""
endpoints = self.list_endpoints()
for ep in endpoints:
if ep.name == name:
return ep
return None
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Invoke an MCP tool."""
if self.transport == "stdio":
return self._invoke_stdio(name, input_data)
else:
return self._invoke_http(name, input_data)
def _invoke_stdio(self, name: str, input_data: Optional[Dict[str, Any]] = None) -> InvokeResult:
"""Invoke tool via stdio MCP."""
try:
from praisonaiagents.mcp import MCP
mcp = MCP(command=self.command, args=self.args, env=self.env)
tools = mcp.get_tools()
for tool in tools:
tool_name = getattr(tool, 'name', str(tool))
if tool_name == name:
if callable(tool):
result = tool(**(input_data or {}))
return InvokeResult(
ok=True,
status="success",
data=result,
)
return InvokeResult(
ok=False,
status="not_found",
error=f"Tool not found: {name}",
)
except Exception as e:
return InvokeResult(
ok=False,
status="error",
error=str(e),
)
def _invoke_http(self, name: str, input_data: Optional[Dict[str, Any]] = None) -> InvokeResult:
"""Invoke tool via HTTP MCP."""
body = {
"tool": name,
"arguments": input_data or {},
}
result = self._make_request("POST", "/mcp/tools/call", json_data=body)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=True,
status="success",
data=data.get("result", data),
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Invoke MCP tool with streaming."""
# MCP streaming is tool-specific; for now, wrap sync call
result = self.invoke(name, input_data, config)
if result.ok:
yield {"event": "result", "data": result.data}
else:
yield {"event": "error", "data": {"error": result.error}}
def health(self) -> HealthResult:
"""Check MCP server health."""
if self.transport == "stdio":
# For stdio, check if command exists
import shutil
if self.command and shutil.which(self.command):
return HealthResult(
healthy=True,
status="available",
server_name=self.command,
provider_type=self.provider_type,
metadata={"transport": "stdio"},
)
return HealthResult(
healthy=False,
status="unavailable",
provider_type=self.provider_type,
metadata={"error": f"Command not found: {self.command}"},
)
# HTTP health check
result = self._make_request("GET", "/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=True,
status="healthy",
server_name=data.get("server_name", "MCP Server"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,206 @@
"""
Recipe Provider
Provider adapter for recipe runner endpoints.
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class RecipeProvider(BaseProvider):
"""
Provider adapter for recipe runner endpoints.
Connects to recipe runner server (Starlette-based) and provides
unified discovery and invocation interface.
"""
provider_type = "recipe"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="Recipe Runner",
description="Recipe execution endpoints",
capabilities=["list", "describe", "invoke", "stream", "validate"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available recipe endpoints."""
path = "/v1/recipes"
if tags:
path += f"?tags={','.join(tags)}"
result = self._make_request("GET", path)
if result.get("error"):
return []
recipes = result.get("data", {}).get("recipes", [])
endpoints = []
for r in recipes:
endpoints.append(EndpointInfo(
name=r.get("name", ""),
description=r.get("description", ""),
provider_type=self.provider_type,
tags=r.get("tags", []),
version=r.get("version", "1.0.0"),
streaming=["none", "sse"],
auth_modes=["none", "api-key"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about a recipe endpoint."""
result = self._make_request("GET", f"/v1/recipes/{name}")
if result.get("status") == 404 or result.get("error"):
return None
data = result.get("data", {})
# Get schema
schema_result = self._make_request("GET", f"/v1/recipes/{name}/schema")
schema_data = schema_result.get("data", {}) if not schema_result.get("error") else {}
return EndpointInfo(
name=data.get("name", name),
description=data.get("description", ""),
provider_type=self.provider_type,
tags=data.get("tags", []),
version=data.get("version", "1.0.0"),
input_schema=schema_data.get("input_schema"),
output_schema=schema_data.get("output_schema"),
streaming=["none", "sse"],
auth_modes=["none", "api-key"],
metadata=data.get("metadata", {}),
)
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Invoke a recipe endpoint."""
body = {
"recipe": name,
"input": input_data or {},
"config": config or {},
}
result = self._make_request("POST", "/v1/recipes/run", json_data=body)
if result.get("status") == 401:
return InvokeResult(
ok=False,
status="auth_error",
error="Authentication required",
)
if result.get("status") == 404:
return InvokeResult(
ok=False,
status="not_found",
error=f"Recipe not found: {name}",
)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=data.get("ok", True),
status=data.get("status", "success"),
data=data.get("output"),
metadata={"run_id": data.get("run_id")},
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Invoke a recipe endpoint with streaming."""
import json
import urllib.request
body = {
"recipe": name,
"input": input_data or {},
"config": config or {},
}
full_url = f"{self.base_url}/v1/recipes/stream"
headers = {
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
if self.api_key:
headers["X-API-Key"] = self.api_key
data = json.dumps(body).encode("utf-8")
try:
req = urllib.request.Request(full_url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=300) as response:
buffer = ""
for line in response:
line = line.decode("utf-8")
buffer += line
if buffer.endswith("\n\n"):
event_type = "message"
event_data = ""
for part in buffer.strip().split("\n"):
if part.startswith("event:"):
event_type = part[6:].strip()
elif part.startswith("data:"):
event_data = part[5:].strip()
if event_data:
try:
yield {"event": event_type, "data": json.loads(event_data)}
except json.JSONDecodeError:
yield {"event": event_type, "data": event_data}
buffer = ""
except Exception as e:
yield {"event": "error", "data": {"error": str(e)}}
def health(self) -> HealthResult:
"""Check recipe server health."""
result = self._make_request("GET", "/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=data.get("status") == "healthy",
status=data.get("status", "unknown"),
server_name=data.get("service"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,150 @@
"""
Tools MCP Provider
Provider adapter for tools exposed as MCP server.
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class ToolsMCPProvider(BaseProvider):
"""
Provider adapter for tools exposed as MCP server.
This provider connects to a ToolsMCPServer that exposes Python functions
as MCP tools.
"""
provider_type = "tools-mcp"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="Tools MCP Server",
description="Python tools exposed as MCP server",
capabilities=["list-tools", "call-tool"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available tools as endpoints."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
input_schema=ep.get("input_schema"),
streaming=ep.get("streaming", ["none"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: try tools endpoint
result = self._make_request("GET", "/tools")
if result.get("error"):
return []
tools = result.get("data", {}).get("tools", [])
endpoints = []
for tool in tools:
endpoints.append(EndpointInfo(
name=tool.get("name", ""),
description=tool.get("description", ""),
provider_type=self.provider_type,
input_schema=tool.get("input_schema"),
streaming=["none"],
auth_modes=["none"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about a tool."""
endpoints = self.list_endpoints()
for ep in endpoints:
if ep.name == name:
return ep
return None
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Invoke a tool."""
body = {
"tool": name,
"arguments": input_data or {},
}
result = self._make_request("POST", "/tools/call", json_data=body)
if result.get("status") == 404:
return InvokeResult(
ok=False,
status="not_found",
error=f"Tool not found: {name}",
)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=True,
status="success",
data=data.get("result", data),
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Invoke tool with streaming (wraps sync call)."""
result = self.invoke(name, input_data, config)
if result.ok:
yield {"event": "result", "data": result.data}
else:
yield {"event": "error", "data": {"error": result.error}}
def health(self) -> HealthResult:
"""Check tools server health."""
result = self._make_request("GET", "/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=True,
status="healthy",
server_name=data.get("server_name", "Tools MCP Server"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,131 @@
"""
Provider Registry
Central registry for provider adapters.
"""
from typing import Dict, List, Optional, Type
from .providers.base import BaseProvider
# Global provider registry
_providers: Dict[str, Type[BaseProvider]] = {}
def register_provider(provider_type: str, provider_class: Type[BaseProvider]) -> None:
"""
Register a provider class.
Args:
provider_type: Provider type identifier
provider_class: Provider class to register
"""
_providers[provider_type] = provider_class
def get_provider(
provider_type: str,
base_url: str = "http://localhost:8765",
api_key: Optional[str] = None,
**kwargs,
) -> Optional[BaseProvider]:
"""
Get a provider instance by type.
Args:
provider_type: Provider type identifier
base_url: Base URL for the provider
api_key: Optional API key
**kwargs: Additional provider-specific arguments
Returns:
Provider instance or None if not found
"""
# Lazy register built-in providers
_ensure_providers_registered()
if provider_type not in _providers:
return None
return _providers[provider_type](base_url=base_url, api_key=api_key, **kwargs)
def list_provider_types() -> List[str]:
"""
List all registered provider types.
Returns:
List of provider type identifiers
"""
_ensure_providers_registered()
return list(_providers.keys())
def get_provider_class(provider_type: str) -> Optional[Type[BaseProvider]]:
"""
Get a provider class by type.
Args:
provider_type: Provider type identifier
Returns:
Provider class or None if not found
"""
_ensure_providers_registered()
return _providers.get(provider_type)
def _ensure_providers_registered() -> None:
"""Ensure built-in providers are registered."""
if _providers:
return
# Lazy import and register built-in providers
from .providers.recipe import RecipeProvider
from .providers.agents_api import AgentsAPIProvider
from .providers.mcp import MCPProvider
from .providers.tools_mcp import ToolsMCPProvider
from .providers.a2a import A2AProvider
from .providers.a2u import A2UProvider
register_provider("recipe", RecipeProvider)
register_provider("agents-api", AgentsAPIProvider)
register_provider("mcp", MCPProvider)
register_provider("tools-mcp", ToolsMCPProvider)
register_provider("a2a", A2AProvider)
register_provider("a2u", A2UProvider)
class ProviderRegistry:
"""
Provider registry class for managing provider instances.
This class provides a more object-oriented interface to the provider registry.
"""
def __init__(self):
"""Initialize the registry."""
_ensure_providers_registered()
def get(
self,
provider_type: str,
base_url: str = "http://localhost:8765",
api_key: Optional[str] = None,
**kwargs,
) -> Optional[BaseProvider]:
"""Get a provider instance."""
return get_provider(provider_type, base_url, api_key, **kwargs)
def register(self, provider_type: str, provider_class: Type[BaseProvider]) -> None:
"""Register a provider class."""
register_provider(provider_type, provider_class)
def list_types(self) -> List[str]:
"""List all provider types."""
return list_provider_types()
def get_class(self, provider_type: str) -> Optional[Type[BaseProvider]]:
"""Get a provider class."""
return get_provider_class(provider_type)
+161
View File
@@ -0,0 +1,161 @@
"""
Unified Server Utilities
Provides utilities for adding discovery endpoints to any server.
"""
from typing import Any, List, Optional
from .discovery import (
DiscoveryDocument,
EndpointInfo,
ProviderInfo,
SCHEMA_VERSION,
create_discovery_document,
)
def add_discovery_routes(
app: Any,
discovery: DiscoveryDocument,
path: str = "/__praisonai__/discovery",
) -> None:
"""
Add unified discovery routes to a Starlette/FastAPI application.
Args:
app: Starlette or FastAPI application
discovery: DiscoveryDocument to serve
path: Path for discovery endpoint
"""
try:
from starlette.responses import JSONResponse
from starlette.routing import Route
except ImportError:
# Try FastAPI
try:
from fastapi.responses import JSONResponse
except ImportError:
raise ImportError("Starlette or FastAPI required for discovery routes")
async def discovery_handler(request):
"""Return discovery document."""
return JSONResponse(discovery.to_dict())
async def health_handler(request):
"""Return health status with discovery info."""
return JSONResponse({
"status": "healthy",
"schema_version": SCHEMA_VERSION,
"server_name": discovery.server_name,
"server_version": discovery.server_version,
"providers": [p.type for p in discovery.providers],
"endpoint_count": len(discovery.endpoints),
})
# Add routes based on app type
if hasattr(app, 'add_api_route'):
# FastAPI
app.add_api_route(path, discovery_handler, methods=["GET"])
if not _has_route(app, "/health"):
app.add_api_route("/health", health_handler, methods=["GET"])
elif hasattr(app, 'routes'):
# Starlette
app.routes.append(Route(path, discovery_handler, methods=["GET"]))
if not _has_route(app, "/health"):
app.routes.append(Route("/health", health_handler, methods=["GET"]))
def _has_route(app: Any, path: str) -> bool:
"""Check if app already has a route at path."""
if hasattr(app, 'routes'):
for route in app.routes:
if hasattr(route, 'path') and route.path == path:
return True
return False
def create_unified_app(
providers: Optional[List[str]] = None,
server_name: str = "praisonai",
host: str = "127.0.0.1",
port: int = 8765,
cors_origins: Optional[List[str]] = None,
api_key: Optional[str] = None,
) -> Any:
"""
Create a unified server application with discovery support.
Args:
providers: List of provider types to enable
server_name: Server name for discovery
host: Server host
port: Server port
cors_origins: CORS allowed origins
api_key: Optional API key for authentication
Returns:
FastAPI application
"""
try:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
except ImportError:
raise ImportError("FastAPI required. Install with: pip install fastapi")
# Create discovery document
discovery = create_discovery_document(server_name=server_name)
# Create FastAPI app
app = FastAPI(
title=f"{server_name} Unified API",
description="Unified PraisonAI endpoints server",
version=discovery.server_version,
)
# Add CORS if configured
if cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_methods=["*"],
allow_headers=["*"],
)
# Add discovery routes
add_discovery_routes(app, discovery)
# Store discovery document for later modification
app.state.discovery = discovery
return app
def register_endpoint_to_discovery(
app: Any,
endpoint: EndpointInfo,
) -> None:
"""
Register an endpoint to the app's discovery document.
Args:
app: FastAPI/Starlette app with discovery
endpoint: EndpointInfo to register
"""
if hasattr(app, 'state') and hasattr(app.state, 'discovery'):
app.state.discovery.add_endpoint(endpoint)
def register_provider_to_discovery(
app: Any,
provider: ProviderInfo,
) -> None:
"""
Register a provider to the app's discovery document.
Args:
app: FastAPI/Starlette app with discovery
provider: ProviderInfo to register
"""
if hasattr(app, 'state') and hasattr(app.state, 'discovery'):
app.state.discovery.add_provider(provider)
+49
View File
@@ -203,7 +203,12 @@ class JobExecutor:
Run the agent for a job.
This is the core execution logic that runs the PraisonAI agent.
Supports both direct agent execution and recipe-based execution.
"""
# Check if this is a recipe job
if job.recipe_name:
return await self._run_recipe(job)
# Imports are done lazily in _run_praisonai_agents and _run_legacy_praisonai
# Determine agent configuration
@@ -241,6 +246,50 @@ class JobExecutor:
except Exception:
pass
async def _run_recipe(self, job: Job) -> Any:
"""Run a recipe-based job."""
try:
from praisonai.recipe.bridge import resolve, execute_resolved_recipe
except ImportError:
raise RuntimeError("Recipe execution requires praisonai.recipe module")
# Update progress
job.update_progress(percentage=10.0, step=f"Resolving recipe: {job.recipe_name}")
await self.store.save(job)
await self._notify_progress(job)
# Resolve the recipe
resolved = resolve(
job.recipe_name,
input_data=job.prompt,
config=job.recipe_config or {},
session_id=job.session_id,
options={'timeout_sec': job.timeout},
)
# Update job with recipe info
job.agent_id = f"recipe:{resolved.name}"
job.run_id = resolved.run_id
# Update progress
job.update_progress(percentage=20.0, step=f"Executing recipe: {resolved.name}")
await self.store.save(job)
await self._notify_progress(job)
# Execute the recipe
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: execute_resolved_recipe(resolved)
)
# Update progress
job.update_progress(percentage=90.0, step="Finalizing")
await self.store.save(job)
await self._notify_progress(job)
return result
async def _run_praisonai_agents(self, job: Job, agent_file: str) -> Any:
"""Run using praisonaiagents framework."""
try:
+6
View File
@@ -25,12 +25,15 @@ class JobSubmitRequest(BaseModel):
prompt: str = Field(..., description="The prompt or task for the agent")
agent_file: Optional[str] = Field(None, description="Path to agents.yaml file")
agent_yaml: Optional[str] = Field(None, description="Inline agent YAML configuration")
recipe_name: Optional[str] = Field(None, description="Recipe name to execute (mutually exclusive with agent_file)")
recipe_config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Recipe configuration overrides")
framework: Optional[str] = Field("praisonai", description="Framework to use (praisonai, crewai, autogen)")
config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional configuration")
webhook_url: Optional[str] = Field(None, description="URL to POST results when complete")
timeout: Optional[int] = Field(3600, description="Timeout in seconds (default: 1 hour)")
session_id: Optional[str] = Field(None, description="Session ID for conversation continuity")
idempotency_key: Optional[str] = Field(None, description="Idempotency key to prevent duplicate submissions")
idempotency_scope: Optional[str] = Field("none", description="Idempotency scope: none, session, global")
class JobSubmitResponse(BaseModel):
@@ -96,12 +99,15 @@ class Job(BaseModel):
prompt: str = Field(...)
agent_file: Optional[str] = Field(None)
agent_yaml: Optional[str] = Field(None)
recipe_name: Optional[str] = Field(None)
recipe_config: Dict[str, Any] = Field(default_factory=dict)
framework: str = Field("praisonai")
config: Dict[str, Any] = Field(default_factory=dict)
webhook_url: Optional[str] = Field(None)
timeout: int = Field(3600)
session_id: Optional[str] = Field(None)
idempotency_key: Optional[str] = Field(None)
idempotency_scope: str = Field("none")
# Progress tracking
progress_percentage: float = Field(0.0)
@@ -20,10 +20,17 @@ __all__ = [
"validate",
"list_recipes",
"describe",
# Runtime Bridge API
"resolve",
"run_background",
"submit_job",
"schedule",
# Data classes
"RecipeResult",
"RecipeEvent",
"RecipeConfig",
"ResolvedRecipe",
"RuntimeConfig",
# Exceptions
"RecipeError",
"RecipeNotFoundError",
@@ -99,5 +106,29 @@ def __getattr__(name):
from .models import ExitCode
_module_cache[name] = ExitCode
return ExitCode
elif name == "resolve":
from .bridge import resolve
_module_cache[name] = resolve
return resolve
elif name == "run_background":
from .operations import run_background
_module_cache[name] = run_background
return run_background
elif name == "submit_job":
from .operations import submit_job
_module_cache[name] = submit_job
return submit_job
elif name == "schedule":
from .operations import schedule
_module_cache[name] = schedule
return schedule
elif name == "ResolvedRecipe":
from .bridge import ResolvedRecipe
_module_cache[name] = ResolvedRecipe
return ResolvedRecipe
elif name == "RuntimeConfig":
from .runtime import RuntimeConfig
_module_cache[name] = RuntimeConfig
return RuntimeConfig
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+278
View File
@@ -0,0 +1,278 @@
"""
Recipe Runtime Bridge.
Provides unified recipe resolution and execution for operational runtimes:
- Background tasks
- Async jobs
- Scheduled execution
This module bridges the gap between recipes and operational features,
keeping all glue code in the wrapper (praisonai) without modifying core SDK.
"""
import uuid
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class ResolvedRecipe:
"""
A fully resolved recipe ready for execution.
Contains all information needed to execute a recipe in any runtime mode.
"""
name: str
version: str
description: str
path: Path
# Workflow/agents configuration
workflow_config: Dict[str, Any] = field(default_factory=dict)
agents_config: Dict[str, Any] = field(default_factory=dict)
# User-provided input and config
input_data: Any = None
config: Dict[str, Any] = field(default_factory=dict)
# Runtime configuration
runtime_config: Optional[Any] = None # RuntimeConfig
# Execution context
session_id: Optional[str] = None
run_id: str = field(default_factory=lambda: f"run_{uuid.uuid4().hex[:12]}")
# Options
options: Dict[str, Any] = field(default_factory=dict)
def get_timeout_sec(self, default: int = 300) -> int:
"""Get timeout from runtime config or options."""
if self.options.get('timeout_sec'):
return self.options['timeout_sec']
if self.runtime_config and hasattr(self.runtime_config, 'schedule'):
return self.runtime_config.schedule.timeout_sec
return default
def get_max_cost_usd(self, default: float = 1.00) -> float:
"""Get max cost from runtime config or options."""
if self.options.get('max_cost_usd'):
return self.options['max_cost_usd']
if self.runtime_config and hasattr(self.runtime_config, 'schedule'):
return self.runtime_config.schedule.max_cost_usd
return default
def resolve(
name: str,
input_data: Any = None,
config: Optional[Dict[str, Any]] = None,
session_id: Optional[str] = None,
options: Optional[Dict[str, Any]] = None,
) -> ResolvedRecipe:
"""
Resolve a recipe name into a fully executable ResolvedRecipe.
Args:
name: Recipe name or path
input_data: User input for the recipe
config: Configuration overrides
session_id: Session ID for conversation continuity
options: Execution options (timeout_sec, verbose, etc.)
Returns:
ResolvedRecipe ready for execution
Raises:
RecipeNotFoundError: If recipe cannot be found
RecipeValidationError: If recipe is invalid
"""
from praisonai.templates import TemplateLoader
from praisonai.recipe.exceptions import RecipeNotFoundError
options = options or {}
config = config or {}
loader = TemplateLoader(offline=options.get('offline', False))
try:
# Load template configuration
template_config = loader.load(name, config=config, offline=options.get('offline', False))
except Exception as e:
# Try to find similar recipes for helpful error message
try:
from praisonai.recipe.core import list_recipes
available = list_recipes()
suggestions = _find_similar_recipes(name, [r.name for r in available])
if suggestions:
raise RecipeNotFoundError(
f"Recipe '{name}' not found. Did you mean: {', '.join(suggestions[:3])}?"
)
except Exception:
pass
raise RecipeNotFoundError(f"Recipe '{name}' not found: {e}")
# Load workflow and agents configs
try:
workflow_config = loader.load_workflow_config(template_config)
except Exception:
workflow_config = {}
try:
agents_config = loader.load_agents_config(template_config)
except Exception:
agents_config = {}
# Generate session_id if not provided
if not session_id:
session_id = f"session_{uuid.uuid4().hex[:8]}"
return ResolvedRecipe(
name=template_config.name,
version=template_config.version,
description=template_config.description,
path=template_config.path,
workflow_config=workflow_config,
agents_config=agents_config,
input_data=input_data,
config={**template_config.defaults, **config},
runtime_config=template_config.runtime,
session_id=session_id,
options=options,
)
def execute_resolved_recipe(
resolved: ResolvedRecipe,
progress_callback: Optional[callable] = None,
) -> Any:
"""
Execute a resolved recipe and return the result.
This is the canonical execution function used by all runtime modes.
Args:
resolved: Resolved recipe to execute
progress_callback: Optional callback for progress updates
Returns:
Recipe execution result
"""
from praisonai.recipe.core import run
# Build options dict
options = {
'timeout_sec': resolved.get_timeout_sec(),
'verbose': resolved.options.get('verbose', False),
'dry_run': resolved.options.get('dry_run', False),
**resolved.options,
}
# Execute using core recipe.run
result = run(
resolved.name,
input=resolved.input_data,
config=resolved.config,
session_id=resolved.session_id,
options=options,
)
return result
def execute_resolved_recipe_stream(
resolved: ResolvedRecipe,
progress_callback: Optional[callable] = None,
) -> Iterator[Any]:
"""
Execute a resolved recipe with streaming output.
Args:
resolved: Resolved recipe to execute
progress_callback: Optional callback for progress updates
Yields:
RecipeEvent objects during execution
"""
from praisonai.recipe.core import run_stream
# Build options dict
options = {
'timeout_sec': resolved.get_timeout_sec(),
'verbose': resolved.options.get('verbose', False),
**resolved.options,
}
# Execute using core recipe.run_stream
for event in run_stream(
resolved.name,
input=resolved.input_data,
config=resolved.config,
session_id=resolved.session_id,
options=options,
):
if progress_callback:
try:
progress_callback(event)
except Exception as e:
logger.warning(f"Progress callback error: {e}")
yield event
def _find_similar_recipes(name: str, available: List[str], max_results: int = 5) -> List[str]:
"""Find recipes with similar names using simple string matching."""
name_lower = name.lower()
# Exact prefix match
prefix_matches = [r for r in available if r.lower().startswith(name_lower)]
# Contains match
contains_matches = [r for r in available if name_lower in r.lower() and r not in prefix_matches]
# Levenshtein-like similarity (simplified)
def similarity(a: str, b: str) -> float:
a, b = a.lower(), b.lower()
if a == b:
return 1.0
if a in b or b in a:
return 0.8
# Count common characters
common = sum(1 for c in a if c in b)
return common / max(len(a), len(b))
other_matches = sorted(
[r for r in available if r not in prefix_matches and r not in contains_matches],
key=lambda r: similarity(name, r),
reverse=True
)
results = prefix_matches + contains_matches + other_matches
return results[:max_results]
# Convenience functions for different runtime modes
def get_recipe_task_description(resolved: ResolvedRecipe) -> str:
"""Get a task description suitable for scheduler/background."""
if resolved.input_data:
if isinstance(resolved.input_data, str):
return resolved.input_data
elif isinstance(resolved.input_data, dict):
return str(resolved.input_data.get('prompt', resolved.input_data.get('task', str(resolved.input_data))))
return f"Execute recipe: {resolved.name}"
def get_recipe_metadata(resolved: ResolvedRecipe) -> Dict[str, Any]:
"""Get metadata dict for job/scheduler state persistence."""
return {
'recipe_name': resolved.name,
'recipe_version': resolved.version,
'recipe_path': str(resolved.path) if resolved.path else None,
'session_id': resolved.session_id,
'run_id': resolved.run_id,
'config': resolved.config,
'options': resolved.options,
}
+80
View File
@@ -59,6 +59,86 @@ def _get_timestamp() -> str:
return datetime.now(timezone.utc).isoformat()
def get_template_search_paths() -> List[Path]:
"""
Get list of paths to search for recipe templates.
Precedence order:
1. PRAISONAI_RECIPE_PATH environment variable (colon-separated)
2. Current working directory ./recipes
3. User home ~/.praison/recipes
4. Agent-Recipes package templates (if installed)
5. Built-in templates
Returns:
List of Path objects to search for templates
"""
paths = []
# 1. Environment variable
env_path = os.environ.get("PRAISONAI_RECIPE_PATH")
if env_path:
for p in env_path.split(os.pathsep):
path = Path(p).expanduser()
if path.exists():
paths.append(path)
# 2. Current working directory
cwd_recipes = Path.cwd() / "recipes"
if cwd_recipes.exists():
paths.append(cwd_recipes)
# 3. User home
home_recipes = Path.home() / ".praison" / "recipes"
if home_recipes.exists():
paths.append(home_recipes)
# 4. Agent-Recipes package (lazy import)
try:
import agent_recipes
if hasattr(agent_recipes, 'get_template_path'):
agent_path = Path(agent_recipes.get_template_path(""))
if agent_path.parent.exists():
paths.append(agent_path.parent)
elif hasattr(agent_recipes, '__file__'):
agent_templates = Path(agent_recipes.__file__).parent / "templates"
if agent_templates.exists():
paths.append(agent_templates)
except ImportError:
pass
# 5. Built-in templates (relative to this package)
builtin = Path(__file__).parent.parent / "templates"
if builtin.exists():
paths.append(builtin)
return paths
def reload_registry():
"""
Reload the recipe registry, clearing any cached templates.
This is useful for hot-reloading recipes during development
or when using the /admin/reload endpoint.
"""
global _recipe_cache
if '_recipe_cache' in globals():
_recipe_cache.clear()
# Also clear any module-level caches
import importlib
try:
from praisonai import recipe as recipe_module
importlib.reload(recipe_module)
except Exception:
pass
# Recipe cache for performance
_recipe_cache: Dict[str, Any] = {}
def run(
name: str,
input: Union[str, Dict[str, Any]] = None,
+402
View File
@@ -0,0 +1,402 @@
"""
Run History Storage Module
Provides storage and retrieval of recipe run history for:
- Export/replay functionality
- Audit trails
- Debugging and analysis
"""
import json
import shutil
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
from .models import RecipeResult
# Default storage path
DEFAULT_RUNS_PATH = Path.home() / ".praison" / "runs"
class RunHistoryError(Exception):
"""Base exception for run history operations."""
pass
class RunNotFoundError(RunHistoryError):
"""Run not found in history."""
pass
def _get_timestamp() -> str:
"""Get current timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat()
class RunHistory:
"""
Local run history storage.
Storage structure:
~/.praison/runs/
├── index.json # Run index for fast lookup
└── <run_id>/
├── run.json # Run metadata and result
├── input.json # Input data
├── output.json # Output data
└── events.jsonl # Event stream (if streaming)
"""
def __init__(self, path: Optional[Path] = None):
"""Initialize run history storage."""
self.path = Path(path) if path else DEFAULT_RUNS_PATH
self.index_path = self.path / "index.json"
self._ensure_structure()
def _ensure_structure(self):
"""Ensure storage directory structure exists."""
self.path.mkdir(parents=True, exist_ok=True)
if not self.index_path.exists():
self._save_index({"runs": {}, "updated": _get_timestamp()})
def _load_index(self) -> Dict[str, Any]:
"""Load run index."""
if self.index_path.exists():
with open(self.index_path) as f:
return json.load(f)
return {"runs": {}, "updated": _get_timestamp()}
def _save_index(self, index: Dict[str, Any]):
"""Save run index."""
index["updated"] = _get_timestamp()
with open(self.index_path, "w") as f:
json.dump(index, f, indent=2)
def store(
self,
result: RecipeResult,
input_data: Optional[Dict[str, Any]] = None,
events: Optional[List[Dict[str, Any]]] = None,
data_policy: Optional[Dict[str, Any]] = None,
) -> str:
"""
Store a run result in history.
Args:
result: RecipeResult to store
input_data: Original input data
events: List of streaming events (if any)
data_policy: Data policy for retention/export
Returns:
run_id
"""
run_id = result.run_id
run_dir = self.path / run_id
run_dir.mkdir(parents=True, exist_ok=True)
# Check data policy
export_allowed = True
retention_days = None
if data_policy:
export_allowed = data_policy.get("export_allowed", True)
retention_days = data_policy.get("retention_days")
# Store run metadata
run_data = {
"run_id": run_id,
"recipe": result.recipe,
"version": result.version,
"status": result.status,
"ok": result.ok,
"error": result.error,
"metrics": result.metrics,
"trace": result.trace,
"stored_at": _get_timestamp(),
"export_allowed": export_allowed,
"retention_days": retention_days,
}
with open(run_dir / "run.json", "w") as f:
json.dump(run_data, f, indent=2)
# Store input (if allowed)
if input_data and export_allowed:
with open(run_dir / "input.json", "w") as f:
json.dump(input_data, f, indent=2)
# Store output (if allowed)
if result.output and export_allowed:
with open(run_dir / "output.json", "w") as f:
json.dump(result.output, f, indent=2)
# Store events (if any)
if events:
with open(run_dir / "events.jsonl", "w") as f:
for event in events:
f.write(json.dumps(event) + "\n")
# Update index
index = self._load_index()
index["runs"][run_id] = {
"recipe": result.recipe,
"version": result.version,
"status": result.status,
"stored_at": run_data["stored_at"],
"session_id": result.trace.get("session_id"),
}
self._save_index(index)
return run_id
def get(self, run_id: str) -> Dict[str, Any]:
"""
Get a run from history.
Args:
run_id: Run ID to retrieve
Returns:
Dict with run data, input, output, events
Raises:
RunNotFoundError: If run not found
"""
run_dir = self.path / run_id
if not run_dir.exists():
raise RunNotFoundError(f"Run not found: {run_id}")
# Load run metadata
run_path = run_dir / "run.json"
if not run_path.exists():
raise RunNotFoundError(f"Run metadata missing: {run_id}")
with open(run_path) as f:
run_data = json.load(f)
# Load input
input_path = run_dir / "input.json"
if input_path.exists():
with open(input_path) as f:
run_data["input"] = json.load(f)
# Load output
output_path = run_dir / "output.json"
if output_path.exists():
with open(output_path) as f:
run_data["output"] = json.load(f)
# Load events
events_path = run_dir / "events.jsonl"
if events_path.exists():
events = []
with open(events_path) as f:
for line in f:
if line.strip():
events.append(json.loads(line))
run_data["events"] = events
return run_data
def list_runs(
self,
recipe: Optional[str] = None,
session_id: Optional[str] = None,
status: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> List[Dict[str, Any]]:
"""
List runs from history.
Args:
recipe: Filter by recipe name
session_id: Filter by session ID
status: Filter by status
limit: Maximum number of results
offset: Offset for pagination
Returns:
List of run summaries
"""
index = self._load_index()
runs = []
for run_id, info in index["runs"].items():
# Apply filters
if recipe and info.get("recipe") != recipe:
continue
if session_id and info.get("session_id") != session_id:
continue
if status and info.get("status") != status:
continue
runs.append({
"run_id": run_id,
**info,
})
# Sort by stored_at descending
runs.sort(key=lambda x: x.get("stored_at", ""), reverse=True)
# Apply pagination
return runs[offset:offset + limit]
def export(
self,
run_id: str,
output_path: Optional[Path] = None,
) -> Path:
"""
Export a run as a JSON bundle.
Args:
run_id: Run ID to export
output_path: Output file path (default: <run_id>.export.json)
Returns:
Path to exported file
Raises:
RunNotFoundError: If run not found
RunHistoryError: If export not allowed
"""
run_data = self.get(run_id)
# Check if export is allowed
if not run_data.get("export_allowed", True):
raise RunHistoryError(f"Export not allowed for run: {run_id}")
# Prepare export bundle
export_bundle = {
"format": "praison-run-export",
"version": "1.0",
"exported_at": _get_timestamp(),
"run": run_data,
}
# Write to file
output_path = Path(output_path) if output_path else Path(f"{run_id}.export.json")
with open(output_path, "w") as f:
json.dump(export_bundle, f, indent=2)
return output_path
def delete(self, run_id: str) -> bool:
"""
Delete a run from history.
Args:
run_id: Run ID to delete
Returns:
True if deleted
"""
run_dir = self.path / run_id
if run_dir.exists():
shutil.rmtree(run_dir)
index = self._load_index()
if run_id in index["runs"]:
del index["runs"][run_id]
self._save_index(index)
return True
def cleanup(self, retention_days: Optional[int] = None) -> int:
"""
Clean up old runs based on retention policy.
Args:
retention_days: Override retention days (default: use per-run policy)
Returns:
Number of runs deleted
"""
index = self._load_index()
deleted = 0
now = datetime.now(timezone.utc)
for run_id in list(index["runs"].keys()):
run_dir = self.path / run_id
run_path = run_dir / "run.json"
if not run_path.exists():
# Clean up orphaned index entry
del index["runs"][run_id]
deleted += 1
continue
with open(run_path) as f:
run_data = json.load(f)
# Check retention
run_retention = retention_days or run_data.get("retention_days")
if run_retention:
stored_at = datetime.fromisoformat(run_data["stored_at"].replace("Z", "+00:00"))
if now - stored_at > timedelta(days=run_retention):
self.delete(run_id)
deleted += 1
return deleted
def get_stats(self) -> Dict[str, Any]:
"""Get storage statistics."""
index = self._load_index()
total_runs = len(index["runs"])
total_size = 0
for run_id in index["runs"]:
run_dir = self.path / run_id
if run_dir.exists():
for f in run_dir.iterdir():
if f.is_file():
total_size += f.stat().st_size
return {
"total_runs": total_runs,
"total_size_bytes": total_size,
"storage_path": str(self.path),
}
# Global instance for convenience
_default_history: Optional[RunHistory] = None
def get_history(path: Optional[Path] = None) -> RunHistory:
"""Get or create default run history instance."""
global _default_history
if path:
return RunHistory(path)
if _default_history is None:
_default_history = RunHistory()
return _default_history
def store_run(
result: RecipeResult,
input_data: Optional[Dict[str, Any]] = None,
events: Optional[List[Dict[str, Any]]] = None,
data_policy: Optional[Dict[str, Any]] = None,
) -> str:
"""Convenience function to store a run."""
return get_history().store(result, input_data, events, data_policy)
def get_run(run_id: str) -> Dict[str, Any]:
"""Convenience function to get a run."""
return get_history().get(run_id)
def list_runs(**kwargs) -> List[Dict[str, Any]]:
"""Convenience function to list runs."""
return get_history().list_runs(**kwargs)
def export_run(run_id: str, output_path: Optional[Path] = None) -> Path:
"""Convenience function to export a run."""
return get_history().export(run_id, output_path)
@@ -0,0 +1,440 @@
"""
Recipe Operations Module.
Provides high-level APIs for running recipes in different operational modes:
- run_background(): Execute recipe as a background task
- submit_job(): Submit recipe to async jobs server
- schedule(): Create a scheduled recipe executor
All functions are lazy-loaded and have zero performance impact when not used.
"""
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Safe defaults
DEFAULT_TIMEOUT_SEC = 300
DEFAULT_MAX_COST_USD = 1.00
DEFAULT_MAX_RETRIES = 3
@dataclass
class BackgroundTaskHandle:
"""Handle for a background recipe task."""
task_id: str
recipe_name: str
session_id: Optional[str] = None
_runner: Any = field(default=None, repr=False)
_task: Any = field(default=None, repr=False)
async def status(self) -> str:
"""Get task status."""
if self._runner and self._task:
return self._task.status.value
return "unknown"
async def wait(self, timeout: Optional[float] = None) -> Any:
"""Wait for task completion and return result."""
if self._runner and self._task:
return await self._runner.wait_for_task(self._task.id, timeout=timeout)
return None
async def cancel(self) -> bool:
"""Cancel the task."""
if self._runner:
return await self._runner.cancel(self.task_id)
return False
@dataclass
class JobHandle:
"""Handle for an async job."""
job_id: str
recipe_name: str
status: str = "queued"
poll_url: Optional[str] = None
stream_url: Optional[str] = None
api_url: str = "http://127.0.0.1:8005"
def get_status(self) -> Dict[str, Any]:
"""Get job status from API."""
try:
import httpx
with httpx.Client(timeout=30.0) as client:
response = client.get(f"{self.api_url}/api/v1/runs/{self.job_id}")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get job status: {e}")
return {"job_id": self.job_id, "status": "unknown", "error": str(e)}
def get_result(self) -> Any:
"""Get job result from API."""
try:
import httpx
with httpx.Client(timeout=30.0) as client:
response = client.get(f"{self.api_url}/api/v1/runs/{self.job_id}/result")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get job result: {e}")
return {"job_id": self.job_id, "error": str(e)}
def cancel(self) -> bool:
"""Cancel the job."""
try:
import httpx
with httpx.Client(timeout=30.0) as client:
response = client.post(f"{self.api_url}/api/v1/runs/{self.job_id}/cancel")
return response.status_code < 400
except Exception as e:
logger.error(f"Failed to cancel job: {e}")
return False
def wait(self, poll_interval: int = 5, timeout: Optional[int] = None) -> Dict[str, Any]:
"""Wait for job completion by polling."""
import time
start_time = time.time()
while True:
status = self.get_status()
if status.get("status") in ("succeeded", "failed", "cancelled"):
return self.get_result()
if timeout and (time.time() - start_time) > timeout:
return {"job_id": self.job_id, "status": "timeout", "error": "Wait timeout exceeded"}
time.sleep(poll_interval)
@dataclass
class RecipeScheduler:
"""Wrapper around AgentScheduler for recipe-based scheduling."""
recipe_name: str
interval: str = "hourly"
max_retries: int = DEFAULT_MAX_RETRIES
timeout_sec: int = DEFAULT_TIMEOUT_SEC
max_cost_usd: float = DEFAULT_MAX_COST_USD
run_immediately: bool = False
_scheduler: Any = field(default=None, repr=False)
_resolved: Any = field(default=None, repr=False)
def start(self) -> bool:
"""Start the scheduler."""
if self._scheduler:
return self._scheduler.start(
schedule_expr=self.interval,
max_retries=self.max_retries,
run_immediately=self.run_immediately
)
return False
def stop(self) -> bool:
"""Stop the scheduler."""
if self._scheduler:
return self._scheduler.stop()
return False
def get_stats(self) -> Dict[str, Any]:
"""Get scheduler statistics."""
if self._scheduler:
return self._scheduler.get_stats()
return {}
@property
def is_running(self) -> bool:
"""Check if scheduler is running."""
if self._scheduler:
return self._scheduler.is_running
return False
def run_background(
name: str,
*,
input: Any = None,
config: Optional[Dict[str, Any]] = None,
session_id: Optional[str] = None,
timeout_sec: Optional[int] = None,
max_concurrent: int = 5,
on_complete: Optional[callable] = None,
) -> BackgroundTaskHandle:
"""
Run a recipe as a background task.
Args:
name: Recipe name
input: Input data for the recipe
config: Configuration overrides
session_id: Session ID for conversation continuity
timeout_sec: Timeout in seconds (default: 300)
max_concurrent: Max concurrent tasks (default: 5)
on_complete: Callback when task completes
Returns:
BackgroundTaskHandle for tracking the task
Example:
task = recipe.run_background("my-recipe", input={"query": "test"})
print(f"Task ID: {task.task_id}")
result = await task.wait()
"""
import asyncio
from .bridge import resolve, execute_resolved_recipe
# Resolve the recipe
resolved = resolve(
name,
input_data=input,
config=config,
session_id=session_id,
options={'timeout_sec': timeout_sec or DEFAULT_TIMEOUT_SEC},
)
# Import BackgroundRunner lazily
try:
from praisonaiagents.background import BackgroundRunner
except ImportError:
raise RuntimeError(
"Background tasks require praisonaiagents. "
"Install with: pip install praisonaiagents"
)
# Create or get runner
runner = BackgroundRunner(max_concurrent_tasks=max_concurrent)
# Define the task function
def recipe_task():
return execute_resolved_recipe(resolved)
# Submit the task
loop = asyncio.get_event_loop()
if loop.is_running():
# We're in an async context, use create_task
import concurrent.futures
future = concurrent.futures.Future()
async def submit_and_return():
task = await runner.submit(
recipe_task,
name=f"recipe:{resolved.name}",
timeout=timeout_sec,
on_complete=on_complete,
)
return task
asyncio.ensure_future(submit_and_return()).add_done_callback(
lambda f: future.set_result(f.result())
)
task = future.result(timeout=10)
else:
# Sync context
task = loop.run_until_complete(runner.submit(
recipe_task,
name=f"recipe:{resolved.name}",
timeout=timeout_sec,
on_complete=on_complete,
))
return BackgroundTaskHandle(
task_id=task.id,
recipe_name=resolved.name,
session_id=resolved.session_id,
_runner=runner,
_task=task,
)
def submit_job(
name: str,
*,
input: Any = None,
config: Optional[Dict[str, Any]] = None,
session_id: Optional[str] = None,
timeout_sec: Optional[int] = None,
idempotency_key: Optional[str] = None,
webhook_url: Optional[str] = None,
api_url: str = "http://127.0.0.1:8005",
wait: bool = False,
poll_interval: int = 5,
) -> JobHandle:
"""
Submit a recipe to the async jobs server.
Args:
name: Recipe name
input: Input data for the recipe
config: Configuration overrides
session_id: Session ID for conversation continuity
timeout_sec: Timeout in seconds (default: 3600)
idempotency_key: Key for deduplication
webhook_url: URL for completion webhook
api_url: Jobs API URL (default: http://127.0.0.1:8005)
wait: If True, wait for completion before returning
poll_interval: Polling interval when waiting (seconds)
Returns:
JobHandle for tracking the job
Example:
job = recipe.submit_job("my-recipe", input={"query": "test"}, wait=True)
print(f"Result: {job.get_result()}")
"""
try:
import httpx
except ImportError:
raise RuntimeError(
"Job submission requires httpx. "
"Install with: pip install httpx"
)
# Build request payload
payload = {
"prompt": str(input) if input else f"Execute recipe: {name}",
"recipe_name": name,
"timeout": timeout_sec or 3600,
}
if config:
payload["config"] = config
if session_id:
payload["session_id"] = session_id
if idempotency_key:
payload["idempotency_key"] = idempotency_key
if webhook_url:
payload["webhook_url"] = webhook_url
# Submit to API
with httpx.Client(timeout=30.0) as client:
response = client.post(
f"{api_url}/api/v1/runs",
json=payload,
)
response.raise_for_status()
data = response.json()
handle = JobHandle(
job_id=data["job_id"],
recipe_name=name,
status=data.get("status", "queued"),
poll_url=data.get("poll_url"),
stream_url=data.get("stream_url"),
api_url=api_url,
)
if wait:
handle.wait(poll_interval=poll_interval, timeout=timeout_sec)
return handle
def schedule(
name: str,
*,
input: Any = None,
config: Optional[Dict[str, Any]] = None,
interval: Optional[str] = None,
max_retries: Optional[int] = None,
run_immediately: Optional[bool] = None,
timeout_sec: Optional[int] = None,
max_cost_usd: Optional[float] = None,
on_success: Optional[callable] = None,
on_failure: Optional[callable] = None,
) -> RecipeScheduler:
"""
Create a scheduler for periodic recipe execution.
Args:
name: Recipe name
input: Input data for the recipe
config: Configuration overrides
interval: Schedule interval (hourly, daily, */30m, etc.)
max_retries: Max retry attempts (default: 3)
run_immediately: Run once immediately (default: False)
timeout_sec: Timeout per execution (default: 300)
max_cost_usd: Budget limit (default: $1.00)
on_success: Callback on successful execution
on_failure: Callback on failed execution
Returns:
RecipeScheduler instance (call .start() to begin)
Example:
scheduler = recipe.schedule("news-monitor", interval="hourly")
scheduler.start()
# ... later ...
scheduler.stop()
"""
from .bridge import resolve, execute_resolved_recipe, get_recipe_task_description
# Resolve the recipe
resolved = resolve(
name,
input_data=input,
config=config,
options={'timeout_sec': timeout_sec or DEFAULT_TIMEOUT_SEC},
)
# Get runtime config defaults
runtime = resolved.runtime_config
if runtime and hasattr(runtime, 'schedule'):
sched_config = runtime.schedule
interval = interval or sched_config.interval
max_retries = max_retries if max_retries is not None else sched_config.max_retries
run_immediately = run_immediately if run_immediately is not None else sched_config.run_immediately
timeout_sec = timeout_sec or sched_config.timeout_sec
max_cost_usd = max_cost_usd if max_cost_usd is not None else sched_config.max_cost_usd
# Apply defaults
interval = interval or "hourly"
max_retries = max_retries if max_retries is not None else DEFAULT_MAX_RETRIES
run_immediately = run_immediately if run_immediately is not None else False
timeout_sec = timeout_sec or DEFAULT_TIMEOUT_SEC
max_cost_usd = max_cost_usd if max_cost_usd is not None else DEFAULT_MAX_COST_USD
# Import AgentScheduler lazily
try:
from praisonai.scheduler import AgentScheduler
except ImportError:
raise RuntimeError(
"Scheduling requires praisonai scheduler module."
)
# Create a recipe executor agent wrapper
class RecipeExecutorAgent:
"""Wrapper that makes a recipe look like an agent for the scheduler."""
def __init__(self, resolved_recipe):
self.resolved = resolved_recipe
self.name = f"RecipeAgent:{resolved_recipe.name}"
def start(self, task: str) -> Any:
return execute_resolved_recipe(self.resolved)
# Create the agent wrapper
agent = RecipeExecutorAgent(resolved)
task = get_recipe_task_description(resolved)
# Create the scheduler
scheduler = AgentScheduler(
agent=agent,
task=task,
timeout=timeout_sec,
max_cost=max_cost_usd,
on_success=on_success,
on_failure=on_failure,
)
return RecipeScheduler(
recipe_name=resolved.name,
interval=interval,
max_retries=max_retries,
timeout_sec=timeout_sec,
max_cost_usd=max_cost_usd,
run_immediately=run_immediately,
_scheduler=scheduler,
_resolved=resolved,
)
+422
View File
@@ -0,0 +1,422 @@
"""
Recipe Policy Module
Provides policy management for recipes:
- Policy packs (reusable org-wide policies)
- Tool allow/deny enforcement
- Network/file restrictions
- PII defaults
- Mode enforcement (dev/prod)
"""
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Union
import yaml
class PolicyError(Exception):
"""Base exception for policy operations."""
pass
class PolicyDeniedError(PolicyError):
"""Action denied by policy."""
pass
# Default denied tools (dangerous by default)
DEFAULT_DENIED_TOOLS: Set[str] = {
"shell.exec",
"shell.run",
"shell_tool",
"execute_command",
"file.write",
"file.delete",
"fs.write",
"fs.delete",
"network.unrestricted",
"db.write",
"db.delete",
"db.drop",
}
# Default allowed tools (safe by default)
DEFAULT_ALLOWED_TOOLS: Set[str] = {
"web.search",
"web_search",
"tavily_search",
"file.read",
"fs.read",
"db.query",
"db.read",
}
class PolicyPack:
"""
Reusable policy pack for org-wide policy management.
Policy pack YAML format:
```yaml
name: my-org-policy
version: "1.0"
description: Organization-wide security policy
tools:
allow:
- web.search
- db.query
deny:
- shell.exec
- file.write
network:
allow_domains:
- api.openai.com
- api.anthropic.com
deny_domains:
- localhost
- 127.0.0.1
files:
allow_paths:
- /tmp
- ./outputs
deny_paths:
- /etc
- /var
pii:
mode: redact # allow, deny, redact
fields:
- email
- phone
- ssn
data:
retention_days: 30
export_allowed: true
modes:
dev:
allow_interactive_prompts: true
strict_tool_enforcement: false
prod:
allow_interactive_prompts: false
strict_tool_enforcement: true
require_auth: true
```
"""
def __init__(
self,
name: str = "default",
config: Optional[Dict[str, Any]] = None,
):
"""Initialize policy pack."""
self.name = name
self.config = config or {}
# Tool policies
self.allowed_tools: Set[str] = set(self.config.get("tools", {}).get("allow", []))
self.denied_tools: Set[str] = set(self.config.get("tools", {}).get("deny", []))
# Add defaults
self.denied_tools.update(DEFAULT_DENIED_TOOLS)
# Network policies
self.allowed_domains: Set[str] = set(self.config.get("network", {}).get("allow_domains", []))
self.denied_domains: Set[str] = set(self.config.get("network", {}).get("deny_domains", []))
# File policies
self.allowed_paths: List[str] = self.config.get("files", {}).get("allow_paths", [])
self.denied_paths: List[str] = self.config.get("files", {}).get("deny_paths", [])
# PII policy
self.pii_mode: str = self.config.get("pii", {}).get("mode", "allow")
self.pii_fields: List[str] = self.config.get("pii", {}).get("fields", [])
# Data policy
self.retention_days: Optional[int] = self.config.get("data", {}).get("retention_days")
self.export_allowed: bool = self.config.get("data", {}).get("export_allowed", True)
# Mode-specific settings
self.modes: Dict[str, Dict[str, Any]] = self.config.get("modes", {})
@classmethod
def load(cls, path: Union[str, Path]) -> "PolicyPack":
"""Load policy pack from file."""
path = Path(path)
if not path.exists():
raise PolicyError(f"Policy file not found: {path}")
with open(path) as f:
if path.suffix in (".yaml", ".yml"):
config = yaml.safe_load(f)
else:
config = json.load(f)
name = config.get("name", path.stem)
return cls(name=name, config=config)
def save(self, path: Union[str, Path]):
"""Save policy pack to file."""
path = Path(path)
data = {
"name": self.name,
"version": self.config.get("version", "1.0"),
"description": self.config.get("description", ""),
"tools": {
"allow": list(self.allowed_tools),
"deny": list(self.denied_tools),
},
"network": {
"allow_domains": list(self.allowed_domains),
"deny_domains": list(self.denied_domains),
},
"files": {
"allow_paths": self.allowed_paths,
"deny_paths": self.denied_paths,
},
"pii": {
"mode": self.pii_mode,
"fields": self.pii_fields,
},
"data": {
"retention_days": self.retention_days,
"export_allowed": self.export_allowed,
},
"modes": self.modes,
}
with open(path, "w") as f:
if path.suffix in (".yaml", ".yml"):
yaml.dump(data, f, default_flow_style=False)
else:
json.dump(data, f, indent=2)
def check_tool(self, tool_id: str, mode: str = "dev") -> bool:
"""
Check if a tool is allowed.
Args:
tool_id: Tool identifier
mode: Execution mode (dev/prod)
Returns:
True if allowed
Raises:
PolicyDeniedError: If tool is denied
"""
# Explicit deny always wins
if tool_id in self.denied_tools:
raise PolicyDeniedError(f"Tool denied by policy: {tool_id}")
# Check mode-specific settings
mode_config = self.modes.get(mode, {})
strict = mode_config.get("strict_tool_enforcement", mode == "prod")
if strict:
# In strict mode, tool must be explicitly allowed
if tool_id not in self.allowed_tools and tool_id not in DEFAULT_ALLOWED_TOOLS:
raise PolicyDeniedError(f"Tool not in allowlist (strict mode): {tool_id}")
return True
def check_domain(self, domain: str) -> bool:
"""Check if a network domain is allowed."""
if domain in self.denied_domains:
raise PolicyDeniedError(f"Domain denied by policy: {domain}")
if self.allowed_domains and domain not in self.allowed_domains:
raise PolicyDeniedError(f"Domain not in allowlist: {domain}")
return True
def check_path(self, path: str) -> bool:
"""Check if a file path is allowed."""
path_obj = Path(path).resolve()
for denied in self.denied_paths:
if str(path_obj).startswith(str(Path(denied).resolve())):
raise PolicyDeniedError(f"Path denied by policy: {path}")
if self.allowed_paths:
allowed = False
for allow in self.allowed_paths:
if str(path_obj).startswith(str(Path(allow).resolve())):
allowed = True
break
if not allowed:
raise PolicyDeniedError(f"Path not in allowlist: {path}")
return True
def get_data_policy(self) -> Dict[str, Any]:
"""Get data policy configuration."""
return {
"pii": {
"mode": self.pii_mode,
"fields": self.pii_fields,
},
"retention_days": self.retention_days,
"export_allowed": self.export_allowed,
}
def get_mode_config(self, mode: str) -> Dict[str, Any]:
"""Get mode-specific configuration."""
return self.modes.get(mode, {})
def merge(self, other: "PolicyPack") -> "PolicyPack":
"""
Merge another policy pack (other takes precedence).
Args:
other: Policy pack to merge
Returns:
New merged policy pack
"""
merged_config = {
"name": f"{self.name}+{other.name}",
"tools": {
"allow": list(self.allowed_tools | other.allowed_tools),
"deny": list(self.denied_tools | other.denied_tools),
},
"network": {
"allow_domains": list(self.allowed_domains | other.allowed_domains),
"deny_domains": list(self.denied_domains | other.denied_domains),
},
"files": {
"allow_paths": self.allowed_paths + other.allowed_paths,
"deny_paths": self.denied_paths + other.denied_paths,
},
"pii": {
"mode": other.pii_mode or self.pii_mode,
"fields": list(set(self.pii_fields + other.pii_fields)),
},
"data": {
"retention_days": other.retention_days or self.retention_days,
"export_allowed": other.export_allowed and self.export_allowed,
},
"modes": {**self.modes, **other.modes},
}
return PolicyPack(name=merged_config["name"], config=merged_config)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"tools": {
"allow": list(self.allowed_tools),
"deny": list(self.denied_tools),
},
"network": {
"allow_domains": list(self.allowed_domains),
"deny_domains": list(self.denied_domains),
},
"files": {
"allow_paths": self.allowed_paths,
"deny_paths": self.denied_paths,
},
"pii": {
"mode": self.pii_mode,
"fields": self.pii_fields,
},
"data": {
"retention_days": self.retention_days,
"export_allowed": self.export_allowed,
},
"modes": self.modes,
}
# Default policy packs
DEFAULT_DEV_POLICY = PolicyPack(
name="default-dev",
config={
"tools": {
"allow": list(DEFAULT_ALLOWED_TOOLS),
"deny": list(DEFAULT_DENIED_TOOLS),
},
"pii": {"mode": "allow"},
"modes": {
"dev": {
"allow_interactive_prompts": True,
"strict_tool_enforcement": False,
},
},
},
)
DEFAULT_PROD_POLICY = PolicyPack(
name="default-prod",
config={
"tools": {
"allow": list(DEFAULT_ALLOWED_TOOLS),
"deny": list(DEFAULT_DENIED_TOOLS),
},
"pii": {"mode": "redact", "fields": ["email", "phone", "ssn"]},
"modes": {
"prod": {
"allow_interactive_prompts": False,
"strict_tool_enforcement": True,
"require_auth": True,
},
},
},
)
def get_default_policy(mode: str = "dev") -> PolicyPack:
"""Get default policy for a mode."""
if mode == "prod":
return DEFAULT_PROD_POLICY
return DEFAULT_DEV_POLICY
def load_policy(
policy_path: Optional[Union[str, Path]] = None,
mode: str = "dev",
) -> PolicyPack:
"""
Load policy from file or return default.
Args:
policy_path: Path to policy file (optional)
mode: Execution mode
Returns:
PolicyPack instance
"""
if policy_path:
return PolicyPack.load(policy_path)
return get_default_policy(mode)
def check_tool_policy(
tool_id: str,
policy: Optional[PolicyPack] = None,
mode: str = "dev",
) -> bool:
"""
Check if a tool is allowed by policy.
Args:
tool_id: Tool identifier
policy: Policy pack (optional, uses default)
mode: Execution mode
Returns:
True if allowed
Raises:
PolicyDeniedError: If tool is denied
"""
policy = policy or get_default_policy(mode)
return policy.check_tool(tool_id, mode)
+849
View File
@@ -0,0 +1,849 @@
"""
Recipe Registry Module
Provides local and remote registry support for recipe distribution.
Supports:
- Local filesystem registry (~/.praison/registry)
- Local HTTP registry (http://localhost:7777)
- Remote HTTP registry (https://registry.example.com)
- Publish, pull, search, list operations
- Atomic writes and file locking for concurrency safety
"""
import hashlib
import json
import os
import shutil
import tarfile
import tempfile
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Union, Protocol
from urllib.parse import urlparse
# Default registry paths
DEFAULT_REGISTRY_PATH = Path.home() / ".praison" / "registry"
DEFAULT_RUNS_PATH = Path.home() / ".praison" / "runs"
DEFAULT_REGISTRY_PORT = 7777
class RegistryError(Exception):
"""Base exception for registry operations."""
pass
class RecipeNotFoundError(RegistryError):
"""Recipe not found in registry."""
pass
class RecipeExistsError(RegistryError):
"""Recipe version already exists in registry."""
pass
class RegistryAuthError(RegistryError):
"""Authentication failed for registry."""
pass
class RegistryNetworkError(RegistryError):
"""Network error connecting to registry."""
pass
class RegistryConflictError(RegistryError):
"""Conflict error (e.g., already exists without force)."""
pass
class RegistryProtocol(Protocol):
"""Protocol defining registry interface for type checking."""
def publish(
self,
bundle_path: Union[str, Path],
force: bool = False,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ...
def pull(
self,
name: str,
version: Optional[str] = None,
output_dir: Optional[Path] = None,
verify_checksum: bool = True,
) -> Dict[str, Any]: ...
def list_recipes(
self,
tags: Optional[List[str]] = None,
) -> List[Dict[str, Any]]: ...
def search(
self,
query: str,
tags: Optional[List[str]] = None,
) -> List[Dict[str, Any]]: ...
def get_versions(self, name: str) -> List[str]: ...
def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]: ...
def delete(self, name: str, version: Optional[str] = None) -> bool: ...
def _get_timestamp() -> str:
"""Get current timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat()
def _calculate_checksum(file_path: Path) -> str:
"""Calculate SHA256 checksum of a file."""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
def _normalize_name(name: str) -> str:
"""Normalize recipe name per PEP 503 rules."""
return re.sub(r"[-_.]+", "-", name).lower()
def _validate_name(name: str) -> bool:
"""Validate recipe name format."""
if not name or len(name) > 128:
return False
return bool(re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$', name))
def _validate_version(version: str) -> bool:
"""Validate version string (semver-like)."""
if not version:
return False
return bool(re.match(r'^\d+\.\d+\.\d+([a-zA-Z0-9._-]*)?$', version))
def _atomic_write(file_path: Path, data: bytes) -> None:
"""Write data atomically using temp file + rename."""
file_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix='.tmp')
try:
os.write(fd, data)
os.fsync(fd)
os.close(fd)
os.rename(tmp_path, file_path)
except Exception:
os.close(fd)
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
def _atomic_write_json(file_path: Path, data: Dict[str, Any]) -> None:
"""Write JSON data atomically."""
_atomic_write(file_path, json.dumps(data, indent=2).encode('utf-8'))
class LocalRegistry:
"""
Local filesystem-based recipe registry.
Storage structure:
~/.praison/registry/
├── index.json # Registry index
└── recipes/
└── <name>/
└── <version>/
├── manifest.json
└── <name>-<version>.praison
"""
def __init__(self, path: Optional[Path] = None):
"""Initialize local registry."""
self.path = Path(path) if path else DEFAULT_REGISTRY_PATH
self.recipes_path = self.path / "recipes"
self.index_path = self.path / "index.json"
self._ensure_structure()
def _ensure_structure(self):
"""Ensure registry directory structure exists."""
self.path.mkdir(parents=True, exist_ok=True)
self.recipes_path.mkdir(parents=True, exist_ok=True)
if not self.index_path.exists():
self._save_index({"recipes": {}, "updated": _get_timestamp()})
def _load_index(self) -> Dict[str, Any]:
"""Load registry index."""
if self.index_path.exists():
with open(self.index_path) as f:
return json.load(f)
return {"recipes": {}, "updated": _get_timestamp()}
def _save_index(self, index: Dict[str, Any]):
"""Save registry index."""
index["updated"] = _get_timestamp()
with open(self.index_path, "w") as f:
json.dump(index, f, indent=2)
def publish(
self,
bundle_path: Union[str, Path],
force: bool = False,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Publish a recipe bundle to the registry.
Args:
bundle_path: Path to .praison bundle file
force: Overwrite existing version if True
metadata: Additional metadata to store
Returns:
Dict with name, version, path, checksum
Raises:
RecipeExistsError: If version exists and force=False
RegistryError: If bundle is invalid
"""
bundle_path = Path(bundle_path)
if not bundle_path.exists():
raise RegistryError(f"Bundle not found: {bundle_path}")
# Extract manifest from bundle
try:
with tarfile.open(bundle_path, "r:gz") as tar:
manifest_file = tar.extractfile("manifest.json")
if not manifest_file:
raise RegistryError("Bundle missing manifest.json")
manifest = json.load(manifest_file)
except tarfile.TarError as e:
raise RegistryError(f"Invalid bundle format: {e}")
name = manifest.get("name")
version = manifest.get("version")
if not name or not version:
raise RegistryError("Bundle manifest missing name or version")
# Check if version exists
recipe_dir = self.recipes_path / name / version
if recipe_dir.exists() and not force:
raise RecipeExistsError(f"Recipe {name}@{version} already exists. Use --force to overwrite.")
# Create recipe directory
recipe_dir.mkdir(parents=True, exist_ok=True)
# Copy bundle
bundle_name = f"{name}-{version}.praison"
dest_path = recipe_dir / bundle_name
shutil.copy2(bundle_path, dest_path)
# Calculate checksum
checksum = _calculate_checksum(dest_path)
# Create registry manifest
registry_manifest = {
"name": name,
"version": version,
"description": manifest.get("description", ""),
"tags": manifest.get("tags", []),
"author": manifest.get("author", ""),
"checksum": checksum,
"published_at": _get_timestamp(),
"bundle_path": str(dest_path),
"files": manifest.get("files", []),
**(metadata or {}),
}
# Save manifest
manifest_path = recipe_dir / "manifest.json"
with open(manifest_path, "w") as f:
json.dump(registry_manifest, f, indent=2)
# Update index
index = self._load_index()
if name not in index["recipes"]:
index["recipes"][name] = {"versions": {}, "latest": version}
index["recipes"][name]["versions"][version] = {
"checksum": checksum,
"published_at": registry_manifest["published_at"],
}
index["recipes"][name]["latest"] = version
self._save_index(index)
return {
"name": name,
"version": version,
"path": str(dest_path),
"checksum": checksum,
"published_at": registry_manifest["published_at"],
}
def pull(
self,
name: str,
version: Optional[str] = None,
output_dir: Optional[Path] = None,
verify_checksum: bool = True,
) -> Dict[str, Any]:
"""
Pull a recipe from the registry.
Args:
name: Recipe name
version: Version to pull (default: latest)
output_dir: Directory to extract to
verify_checksum: Verify bundle checksum
Returns:
Dict with name, version, path
Raises:
RecipeNotFoundError: If recipe/version not found
"""
index = self._load_index()
if name not in index["recipes"]:
raise RecipeNotFoundError(f"Recipe not found: {name}")
recipe_info = index["recipes"][name]
version = version or recipe_info.get("latest")
if version not in recipe_info["versions"]:
raise RecipeNotFoundError(f"Version not found: {name}@{version}")
# Get bundle path
bundle_name = f"{name}-{version}.praison"
bundle_path = self.recipes_path / name / version / bundle_name
if not bundle_path.exists():
raise RecipeNotFoundError(f"Bundle file missing: {bundle_path}")
# Verify checksum
if verify_checksum:
expected = recipe_info["versions"][version]["checksum"]
actual = _calculate_checksum(bundle_path)
if expected != actual:
raise RegistryError(f"Checksum mismatch for {name}@{version}")
# Extract to output directory
output_dir = Path(output_dir) if output_dir else Path.cwd()
output_dir.mkdir(parents=True, exist_ok=True)
recipe_dir = output_dir / name
recipe_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(bundle_path, "r:gz") as tar:
tar.extractall(recipe_dir)
return {
"name": name,
"version": version,
"path": str(recipe_dir),
"bundle_path": str(bundle_path),
}
def list_recipes(
self,
tags: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""
List all recipes in the registry.
Args:
tags: Filter by tags (optional)
Returns:
List of recipe info dicts
"""
index = self._load_index()
recipes = []
for name, info in index["recipes"].items():
# Load full manifest for latest version
latest = info.get("latest")
if latest:
manifest_path = self.recipes_path / name / latest / "manifest.json"
if manifest_path.exists():
with open(manifest_path) as f:
manifest = json.load(f)
# Filter by tags if specified
if tags:
recipe_tags = manifest.get("tags", [])
if not any(t in recipe_tags for t in tags):
continue
recipes.append({
"name": name,
"version": latest,
"description": manifest.get("description", ""),
"tags": manifest.get("tags", []),
"versions": list(info["versions"].keys()),
})
return recipes
def search(
self,
query: str,
tags: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""
Search recipes by name, description, or tags.
Args:
query: Search query
tags: Filter by tags (optional)
Returns:
List of matching recipe info dicts
"""
all_recipes = self.list_recipes(tags=tags)
query_lower = query.lower()
results = []
for recipe in all_recipes:
# Search in name, description, tags
if (
query_lower in recipe["name"].lower()
or query_lower in recipe.get("description", "").lower()
or any(query_lower in t.lower() for t in recipe.get("tags", []))
):
results.append(recipe)
return results
def get_versions(self, name: str) -> List[str]:
"""Get all versions of a recipe."""
index = self._load_index()
if name not in index["recipes"]:
raise RecipeNotFoundError(f"Recipe not found: {name}")
return list(index["recipes"][name]["versions"].keys())
def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
"""Get detailed info about a recipe version."""
index = self._load_index()
if name not in index["recipes"]:
raise RecipeNotFoundError(f"Recipe not found: {name}")
recipe_info = index["recipes"][name]
version = version or recipe_info.get("latest")
manifest_path = self.recipes_path / name / version / "manifest.json"
if not manifest_path.exists():
raise RecipeNotFoundError(f"Version not found: {name}@{version}")
with open(manifest_path) as f:
return json.load(f)
def delete(self, name: str, version: Optional[str] = None) -> bool:
"""
Delete a recipe or specific version.
Args:
name: Recipe name
version: Version to delete (None = all versions)
Returns:
True if deleted
"""
index = self._load_index()
if name not in index["recipes"]:
raise RecipeNotFoundError(f"Recipe not found: {name}")
if version:
# Delete specific version
version_dir = self.recipes_path / name / version
if version_dir.exists():
shutil.rmtree(version_dir)
if version in index["recipes"][name]["versions"]:
del index["recipes"][name]["versions"][version]
# Update latest if needed
versions = list(index["recipes"][name]["versions"].keys())
if versions:
index["recipes"][name]["latest"] = sorted(versions)[-1]
else:
del index["recipes"][name]
else:
# Delete all versions
recipe_dir = self.recipes_path / name
if recipe_dir.exists():
shutil.rmtree(recipe_dir)
del index["recipes"][name]
self._save_index(index)
return True
class HttpRegistry:
"""
HTTP-based recipe registry client.
Works with both local HTTP registry (http://localhost:7777) and
remote HTTP registries (https://registry.example.com).
Supports:
- Token-based authentication (Bearer token)
- Multipart file upload for publish
- ETag/If-None-Match for efficient downloads
- Proper error handling with specific exceptions
"""
def __init__(
self,
url: str,
token: Optional[str] = None,
timeout: int = 30,
):
"""
Initialize HTTP registry client.
Args:
url: Registry base URL (http://localhost:7777 or https://...)
token: Authentication token (or set PRAISONAI_REGISTRY_TOKEN env var)
timeout: Request timeout in seconds
"""
self.url = url.rstrip("/")
self.token = token or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
self.timeout = timeout
self._etag_cache: Dict[str, str] = {}
def _get_headers(self, content_type: str = "application/json") -> Dict[str, str]:
"""Get request headers with optional auth."""
headers = {"Content-Type": content_type}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
def _request(
self,
method: str,
path: str,
data: Optional[bytes] = None,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""Make HTTP request to registry."""
import urllib.request
import urllib.error
url = f"{self.url}{path}"
req_headers = headers or self._get_headers()
try:
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
with urllib.request.urlopen(req, timeout=self.timeout) as response:
# Cache ETag for future requests
etag = response.headers.get("ETag")
if etag:
self._etag_cache[path] = etag
return json.loads(response.read().decode())
except urllib.error.HTTPError as e:
if e.code == 401:
raise RegistryAuthError("Authentication failed. Check your token.")
elif e.code == 403:
raise RegistryAuthError("Access denied. Token may lack required permissions.")
elif e.code == 404:
raise RecipeNotFoundError(f"Not found: {path}")
elif e.code == 409:
raise RegistryConflictError("Recipe version already exists. Use --force to overwrite.")
else:
body = e.read().decode() if e.fp else ""
raise RegistryError(f"Registry error ({e.code}): {body}")
except urllib.error.URLError as e:
raise RegistryNetworkError(f"Connection error: {e.reason}")
def _download_file(self, path: str, dest_path: Path) -> Dict[str, Any]:
"""Download file from registry with ETag support."""
import urllib.request
import urllib.error
url = f"{self.url}{path}"
headers = self._get_headers()
# Add If-None-Match if we have cached ETag
if path in self._etag_cache:
headers["If-None-Match"] = self._etag_cache[path]
try:
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=self.timeout) as response:
etag = response.headers.get("ETag")
if etag:
self._etag_cache[path] = etag
dest_path.parent.mkdir(parents=True, exist_ok=True)
with open(dest_path, "wb") as f:
shutil.copyfileobj(response, f)
return {"downloaded": True, "path": str(dest_path)}
except urllib.error.HTTPError as e:
if e.code == 304:
return {"downloaded": False, "cached": True}
elif e.code == 404:
raise RecipeNotFoundError(f"Not found: {path}")
else:
raise RegistryError(f"Download error ({e.code})")
except urllib.error.URLError as e:
raise RegistryNetworkError(f"Connection error: {e.reason}")
def _upload_file(self, path: str, file_path: Path, force: bool = False) -> Dict[str, Any]:
"""Upload file to registry using multipart form."""
import urllib.request
import urllib.error
import uuid
boundary = f"----PraisonAI{uuid.uuid4().hex}"
# Build multipart body
body_parts = []
# Add force field
body_parts.append(f"--{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="force"')
body_parts.append(b"")
body_parts.append(b"true" if force else b"false")
# Add file
body_parts.append(f"--{boundary}".encode())
body_parts.append(f'Content-Disposition: form-data; name="bundle"; filename="{file_path.name}"'.encode())
body_parts.append(b"Content-Type: application/gzip")
body_parts.append(b"")
with open(file_path, "rb") as f:
body_parts.append(f.read())
body_parts.append(f"--{boundary}--".encode())
body_parts.append(b"")
body = b"\r\n".join(body_parts)
headers = self._get_headers(f"multipart/form-data; boundary={boundary}")
url = f"{self.url}{path}"
try:
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=self.timeout) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as e:
if e.code == 401:
raise RegistryAuthError("Authentication required for publish")
elif e.code == 409:
raise RegistryConflictError("Recipe version already exists. Use --force to overwrite.")
else:
body_text = e.read().decode() if e.fp else ""
raise RegistryError(f"Upload error ({e.code}): {body_text}")
except urllib.error.URLError as e:
raise RegistryNetworkError(f"Connection error: {e.reason}")
def health(self) -> Dict[str, Any]:
"""Check registry health."""
return self._request("GET", "/healthz")
def publish(
self,
bundle_path: Union[str, Path],
force: bool = False,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Publish bundle to HTTP registry.
Args:
bundle_path: Path to .praison bundle file
force: Overwrite existing version if True
metadata: Additional metadata (ignored for HTTP, included in bundle)
Returns:
Dict with name, version, checksum
"""
bundle_path = Path(bundle_path)
if not bundle_path.exists():
raise RegistryError(f"Bundle not found: {bundle_path}")
# Extract name/version from bundle to construct path
try:
with tarfile.open(bundle_path, "r:gz") as tar:
manifest_file = tar.extractfile("manifest.json")
if not manifest_file:
raise RegistryError("Bundle missing manifest.json")
manifest = json.load(manifest_file)
except tarfile.TarError as e:
raise RegistryError(f"Invalid bundle format: {e}")
name = manifest.get("name")
version = manifest.get("version")
if not name or not version:
raise RegistryError("Bundle manifest missing name or version")
# Upload to /v1/recipes/{name}/{version}
return self._upload_file(f"/v1/recipes/{name}/{version}", bundle_path, force=force)
def pull(
self,
name: str,
version: Optional[str] = None,
output_dir: Optional[Path] = None,
verify_checksum: bool = True,
) -> Dict[str, Any]:
"""
Pull recipe from HTTP registry.
Args:
name: Recipe name
version: Version to pull (default: latest)
output_dir: Directory to extract to
verify_checksum: Verify bundle checksum
Returns:
Dict with name, version, path
"""
# Get recipe info to determine version
if not version:
info = self._request("GET", f"/v1/recipes/{name}")
version = info.get("latest")
if not version:
raise RecipeNotFoundError(f"No versions found for: {name}")
output_dir = Path(output_dir) if output_dir else Path.cwd()
bundle_path = output_dir / f"{name}-{version}.praison"
# Download bundle
download_path = f"/v1/recipes/{name}/{version}/download"
self._download_file(download_path, bundle_path)
# Verify checksum if requested
if verify_checksum:
info = self._request("GET", f"/v1/recipes/{name}/{version}")
expected = info.get("checksum")
if expected:
actual = _calculate_checksum(bundle_path)
if expected != actual:
bundle_path.unlink()
raise RegistryError(f"Checksum mismatch for {name}@{version}")
# Extract
recipe_dir = output_dir / name
recipe_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(bundle_path, "r:gz") as tar:
tar.extractall(recipe_dir)
return {
"name": name,
"version": version,
"path": str(recipe_dir),
"bundle_path": str(bundle_path),
}
def list_recipes(
self,
tags: Optional[List[str]] = None,
page: int = 1,
per_page: int = 50,
) -> List[Dict[str, Any]]:
"""
List recipes from HTTP registry.
Args:
tags: Filter by tags
page: Page number (1-indexed)
per_page: Results per page
Returns:
List of recipe info dicts
"""
params = f"?page={page}&per_page={per_page}"
if tags:
params += f"&tags={','.join(tags)}"
result = self._request("GET", f"/v1/recipes{params}")
return result.get("recipes", [])
def search(
self,
query: str,
tags: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""
Search recipes in HTTP registry.
Args:
query: Search query
tags: Filter by tags
Returns:
List of matching recipe info dicts
"""
from urllib.parse import quote as url_quote
params = f"?q={url_quote(query)}"
if tags:
params += f"&tags={','.join(tags)}"
result = self._request("GET", f"/v1/search{params}")
return result.get("results", [])
def get_versions(self, name: str) -> List[str]:
"""Get all versions of a recipe."""
result = self._request("GET", f"/v1/recipes/{name}")
return result.get("versions", [])
def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
"""Get detailed info about a recipe version."""
if version:
return self._request("GET", f"/v1/recipes/{name}/{version}")
return self._request("GET", f"/v1/recipes/{name}")
def delete(self, name: str, version: Optional[str] = None) -> bool:
"""
Delete a recipe or specific version.
Args:
name: Recipe name
version: Version to delete (None = all versions)
Returns:
True if deleted
"""
if version:
self._request("DELETE", f"/v1/recipes/{name}/{version}")
else:
self._request("DELETE", f"/v1/recipes/{name}")
return True
# Alias for backwards compatibility
RemoteRegistry = HttpRegistry
def get_registry(
registry: Optional[str] = None,
token: Optional[str] = None,
) -> Union[LocalRegistry, RemoteRegistry]:
"""
Get appropriate registry instance.
Args:
registry: Registry path or URL (default: local)
token: Auth token for remote registry
Returns:
LocalRegistry or RemoteRegistry instance
"""
if registry is None:
return LocalRegistry()
# Check if it's a URL
parsed = urlparse(registry)
if parsed.scheme in ("http", "https"):
return RemoteRegistry(registry, token=token)
# Local path
return LocalRegistry(Path(registry))
+214
View File
@@ -0,0 +1,214 @@
"""
Recipe Runtime Configuration Models.
Defines the runtime configuration schema for recipes, enabling:
- Background task execution
- Async job submission
- Scheduled execution (24/7 daemon)
All fields are optional and backward compatible with existing recipes.
"""
import os
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
# Safe defaults for operational features
DEFAULT_TIMEOUT_SEC = 300 # 5 minutes
DEFAULT_MAX_COST_USD = 1.00 # $1.00 budget limit
DEFAULT_MAX_RETRIES = 3
DEFAULT_MAX_CONCURRENT = 5
DEFAULT_CLEANUP_DELAY_SEC = 3600 # 1 hour
@dataclass
class BackgroundRuntimeConfig:
"""Configuration for background task execution."""
enabled: bool = False
max_concurrent: int = DEFAULT_MAX_CONCURRENT
cleanup_delay_sec: int = DEFAULT_CLEANUP_DELAY_SEC
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'BackgroundRuntimeConfig':
"""Create from dictionary with defaults."""
if not data:
return cls()
return cls(
enabled=data.get('enabled', False),
max_concurrent=data.get('max_concurrent', DEFAULT_MAX_CONCURRENT),
cleanup_delay_sec=data.get('cleanup_delay_sec', DEFAULT_CLEANUP_DELAY_SEC),
)
@dataclass
class JobRuntimeConfig:
"""Configuration for async job execution."""
enabled: bool = False
timeout_sec: int = DEFAULT_TIMEOUT_SEC
webhook_url: Optional[str] = None # Supports ${ENV_VAR} interpolation
idempotency_scope: str = "none" # none|session|global
events: List[str] = field(default_factory=lambda: ["completed", "failed"])
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'JobRuntimeConfig':
"""Create from dictionary with defaults."""
if not data:
return cls()
return cls(
enabled=data.get('enabled', False),
timeout_sec=data.get('timeout_sec', DEFAULT_TIMEOUT_SEC),
webhook_url=data.get('webhook_url'),
idempotency_scope=data.get('idempotency_scope', 'none'),
events=data.get('events', ['completed', 'failed']),
)
@dataclass
class ScheduleRuntimeConfig:
"""Configuration for scheduled execution."""
enabled: bool = False
interval: str = "hourly" # hourly|daily|*/30m|seconds|cron-like
max_retries: int = DEFAULT_MAX_RETRIES
run_immediately: bool = False
timeout_sec: int = DEFAULT_TIMEOUT_SEC
max_cost_usd: float = DEFAULT_MAX_COST_USD
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'ScheduleRuntimeConfig':
"""Create from dictionary with defaults."""
if not data:
return cls()
return cls(
enabled=data.get('enabled', False),
interval=data.get('interval', 'hourly'),
max_retries=data.get('max_retries', DEFAULT_MAX_RETRIES),
run_immediately=data.get('run_immediately', False),
timeout_sec=data.get('timeout_sec', DEFAULT_TIMEOUT_SEC),
max_cost_usd=data.get('max_cost_usd', DEFAULT_MAX_COST_USD),
)
@dataclass
class RuntimeConfig:
"""
Complete runtime configuration for a recipe.
Parsed from the optional `runtime` block in TEMPLATE.yaml:
```yaml
runtime:
background:
enabled: false
max_concurrent: 5
cleanup_delay_sec: 3600
job:
enabled: false
timeout_sec: 3600
webhook_url: "${WEBHOOK_URL}"
idempotency_scope: "session"
events: ["completed", "failed"]
schedule:
enabled: false
interval: "hourly"
max_retries: 3
run_immediately: true
timeout_sec: 300
max_cost_usd: 1.00
```
"""
background: BackgroundRuntimeConfig = field(default_factory=BackgroundRuntimeConfig)
job: JobRuntimeConfig = field(default_factory=JobRuntimeConfig)
schedule: ScheduleRuntimeConfig = field(default_factory=ScheduleRuntimeConfig)
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'RuntimeConfig':
"""Create from dictionary with defaults."""
if not data:
return cls()
return cls(
background=BackgroundRuntimeConfig.from_dict(data.get('background')),
job=JobRuntimeConfig.from_dict(data.get('job')),
schedule=ScheduleRuntimeConfig.from_dict(data.get('schedule')),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
'background': {
'enabled': self.background.enabled,
'max_concurrent': self.background.max_concurrent,
'cleanup_delay_sec': self.background.cleanup_delay_sec,
},
'job': {
'enabled': self.job.enabled,
'timeout_sec': self.job.timeout_sec,
'webhook_url': self.job.webhook_url,
'idempotency_scope': self.job.idempotency_scope,
'events': self.job.events,
},
'schedule': {
'enabled': self.schedule.enabled,
'interval': self.schedule.interval,
'max_retries': self.schedule.max_retries,
'run_immediately': self.schedule.run_immediately,
'timeout_sec': self.schedule.timeout_sec,
'max_cost_usd': self.schedule.max_cost_usd,
},
}
def expand_env_vars(value: Any) -> Any:
"""
Safely expand environment variables in a value.
Only supports ${VAR} syntax for safety (no eval, no shell expansion).
Args:
value: Value to expand (string, dict, or list)
Returns:
Value with environment variables expanded
"""
if isinstance(value, str):
# Match ${VAR} pattern only
pattern = r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'
def replace_env(match):
var_name = match.group(1)
return os.environ.get(var_name, match.group(0)) # Keep original if not found
return re.sub(pattern, replace_env, value)
elif isinstance(value, dict):
return {k: expand_env_vars(v) for k, v in value.items()}
elif isinstance(value, list):
return [expand_env_vars(item) for item in value]
return value
def parse_runtime_config(
raw_runtime: Optional[Dict[str, Any]],
expand_env: bool = True
) -> RuntimeConfig:
"""
Parse runtime configuration from raw YAML data.
Args:
raw_runtime: Raw runtime dict from TEMPLATE.yaml
expand_env: Whether to expand environment variables
Returns:
Parsed RuntimeConfig with defaults applied
"""
if not raw_runtime:
return RuntimeConfig()
# Expand environment variables if requested
if expand_env:
raw_runtime = expand_env_vars(raw_runtime)
return RuntimeConfig.from_dict(raw_runtime)
+711
View File
@@ -0,0 +1,711 @@
"""
Recipe Security Module
Provides security features for recipes:
- SBOM (Software Bill of Materials) generation
- Bundle signing and verification
- Dependency auditing
- PII redaction
- Lockfile validation
"""
import hashlib
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
class SecurityError(Exception):
"""Base exception for security operations."""
pass
class SignatureError(SecurityError):
"""Signature verification failed."""
pass
class LockfileError(SecurityError):
"""Lockfile validation failed."""
pass
def _get_timestamp() -> str:
"""Get current timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat()
# ============================================================================
# SBOM Generation
# ============================================================================
def generate_sbom(
recipe_path: Union[str, Path],
format: str = "cyclonedx",
include_python_deps: bool = True,
include_tools: bool = True,
) -> Dict[str, Any]:
"""
Generate Software Bill of Materials for a recipe.
Args:
recipe_path: Path to recipe directory or bundle
format: Output format (cyclonedx or spdx)
include_python_deps: Include Python dependencies
include_tools: Include tool dependencies
Returns:
SBOM as dictionary
"""
recipe_path = Path(recipe_path)
# Load recipe manifest
template_path = recipe_path / "TEMPLATE.yaml"
manifest = {}
if template_path.exists():
import yaml
with open(template_path) as f:
manifest = yaml.safe_load(f) or {}
# Get recipe info
recipe_name = manifest.get("name", recipe_path.name)
recipe_version = manifest.get("version", "0.0.0")
# Collect components
components = []
# Add praisonai as component
try:
import praisonai
praisonai_version = getattr(praisonai, "__version__", "unknown")
except ImportError:
praisonai_version = "unknown"
components.append({
"type": "library",
"name": "praisonai",
"version": praisonai_version,
"purl": f"pkg:pypi/praisonai@{praisonai_version}",
})
# Add Python dependencies from lockfile
if include_python_deps:
deps = _get_python_deps(recipe_path)
for dep in deps:
components.append({
"type": "library",
"name": dep["name"],
"version": dep["version"],
"purl": f"pkg:pypi/{dep['name']}@{dep['version']}",
})
# Add tool dependencies
if include_tools:
requires = manifest.get("requires", {})
tools = requires.get("tools", [])
for tool in tools:
if isinstance(tool, str):
components.append({
"type": "application",
"name": tool,
"version": "unknown",
})
elif isinstance(tool, dict):
components.append({
"type": "application",
"name": tool.get("name", "unknown"),
"version": tool.get("version", "unknown"),
})
# External dependencies
external = requires.get("external", [])
for ext in external:
if isinstance(ext, str):
components.append({
"type": "application",
"name": ext,
"version": "unknown",
})
if format == "cyclonedx":
return _generate_cyclonedx(recipe_name, recipe_version, components)
elif format == "spdx":
return _generate_spdx(recipe_name, recipe_version, components)
else:
raise SecurityError(f"Unknown SBOM format: {format}")
def _generate_cyclonedx(
name: str,
version: str,
components: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Generate CycloneDX SBOM."""
return {
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": f"urn:uuid:{hashlib.md5(f'{name}{version}{_get_timestamp()}'.encode()).hexdigest()}",
"version": 1,
"metadata": {
"timestamp": _get_timestamp(),
"tools": [{"name": "praisonai-sbom", "version": "1.0.0"}],
"component": {
"type": "application",
"name": name,
"version": version,
},
},
"components": [
{
"type": comp["type"],
"name": comp["name"],
"version": comp["version"],
"purl": comp.get("purl"),
}
for comp in components
],
}
def _generate_spdx(
name: str,
version: str,
components: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Generate SPDX SBOM."""
return {
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": f"{name}-{version}",
"documentNamespace": f"https://praison.ai/sbom/{name}/{version}",
"creationInfo": {
"created": _get_timestamp(),
"creators": ["Tool: praisonai-sbom-1.0.0"],
},
"packages": [
{
"SPDXID": f"SPDXRef-Package-{i}",
"name": comp["name"],
"versionInfo": comp["version"],
"downloadLocation": "NOASSERTION",
}
for i, comp in enumerate(components)
],
}
def _get_python_deps(recipe_path: Path) -> List[Dict[str, str]]:
"""Get Python dependencies from lockfile."""
deps = []
lock_dir = recipe_path / "lock"
# Try uv.lock
uv_lock = lock_dir / "uv.lock"
if uv_lock.exists():
deps.extend(_parse_uv_lock(uv_lock))
return deps
# Try requirements.lock
req_lock = lock_dir / "requirements.lock"
if req_lock.exists():
deps.extend(_parse_requirements_lock(req_lock))
return deps
# Try poetry.lock
poetry_lock = lock_dir / "poetry.lock"
if poetry_lock.exists():
deps.extend(_parse_poetry_lock(poetry_lock))
return deps
# Fallback: try requirements.txt in recipe root
req_txt = recipe_path / "requirements.txt"
if req_txt.exists():
deps.extend(_parse_requirements_lock(req_txt))
return deps
def _parse_uv_lock(path: Path) -> List[Dict[str, str]]:
"""Parse uv.lock file."""
deps = []
try:
import tomllib
except ImportError:
import tomli as tomllib
try:
with open(path, "rb") as f:
data = tomllib.load(f)
for pkg in data.get("package", []):
deps.append({
"name": pkg.get("name", ""),
"version": pkg.get("version", ""),
})
except Exception:
pass
return deps
def _parse_requirements_lock(path: Path) -> List[Dict[str, str]]:
"""Parse requirements.lock or requirements.txt."""
deps = []
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or line.startswith("-"):
continue
# Parse package==version
match = re.match(r"^([a-zA-Z0-9_-]+)==([^\s;]+)", line)
if match:
deps.append({
"name": match.group(1),
"version": match.group(2),
})
return deps
def _parse_poetry_lock(path: Path) -> List[Dict[str, str]]:
"""Parse poetry.lock file."""
deps = []
try:
import tomllib
except ImportError:
import tomli as tomllib
try:
with open(path, "rb") as f:
data = tomllib.load(f)
for pkg in data.get("package", []):
deps.append({
"name": pkg.get("name", ""),
"version": pkg.get("version", ""),
})
except Exception:
pass
return deps
# ============================================================================
# Bundle Signing
# ============================================================================
def sign_bundle(
bundle_path: Union[str, Path],
private_key_path: Union[str, Path],
output_path: Optional[Union[str, Path]] = None,
) -> Path:
"""
Sign a recipe bundle.
Args:
bundle_path: Path to .praison bundle
private_key_path: Path to private key (PEM format)
output_path: Output path for signature (default: bundle.sig)
Returns:
Path to signature file
"""
bundle_path = Path(bundle_path)
private_key_path = Path(private_key_path)
if not bundle_path.exists():
raise SecurityError(f"Bundle not found: {bundle_path}")
if not private_key_path.exists():
raise SecurityError(f"Private key not found: {private_key_path}")
# Calculate bundle hash
bundle_hash = _calculate_file_hash(bundle_path)
# Sign using cryptography library (lazy import)
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
except ImportError:
raise SecurityError("cryptography package required for signing. Install with: pip install cryptography")
# Load private key
with open(private_key_path, "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
# Sign the hash
signature = private_key.sign(
bundle_hash.encode(),
padding.PKCS1v15(),
hashes.SHA256(),
)
# Write signature
output_path = Path(output_path) if output_path else bundle_path.with_suffix(".praison.sig")
sig_data = {
"bundle": bundle_path.name,
"hash": bundle_hash,
"algorithm": "RSA-PKCS1v15-SHA256",
"signature": signature.hex(),
"signed_at": _get_timestamp(),
}
with open(output_path, "w") as f:
json.dump(sig_data, f, indent=2)
return output_path
def verify_bundle(
bundle_path: Union[str, Path],
public_key_path: Union[str, Path],
signature_path: Optional[Union[str, Path]] = None,
) -> Tuple[bool, str]:
"""
Verify a signed recipe bundle.
Args:
bundle_path: Path to .praison bundle
public_key_path: Path to public key (PEM format)
signature_path: Path to signature file (default: bundle.sig)
Returns:
Tuple of (valid: bool, message: str)
"""
bundle_path = Path(bundle_path)
public_key_path = Path(public_key_path)
signature_path = Path(signature_path) if signature_path else bundle_path.with_suffix(".praison.sig")
if not bundle_path.exists():
return False, f"Bundle not found: {bundle_path}"
if not public_key_path.exists():
return False, f"Public key not found: {public_key_path}"
if not signature_path.exists():
return False, f"Signature not found: {signature_path}"
# Load signature
with open(signature_path) as f:
sig_data = json.load(f)
# Calculate current bundle hash
current_hash = _calculate_file_hash(bundle_path)
# Check hash matches
if current_hash != sig_data["hash"]:
return False, "Bundle hash mismatch - file may have been modified"
# Verify signature
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
except ImportError:
return False, "cryptography package required for verification"
# Load public key
with open(public_key_path, "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
# Verify
try:
signature = bytes.fromhex(sig_data["signature"])
public_key.verify(
signature,
sig_data["hash"].encode(),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True, "Signature valid"
except Exception as e:
return False, f"Signature verification failed: {e}"
def _calculate_file_hash(path: Path) -> str:
"""Calculate SHA256 hash of a file."""
sha256 = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
# ============================================================================
# Dependency Auditing
# ============================================================================
def audit_dependencies(
recipe_path: Union[str, Path],
check_vulnerabilities: bool = True,
) -> Dict[str, Any]:
"""
Audit recipe dependencies for security issues.
Args:
recipe_path: Path to recipe directory
check_vulnerabilities: Check for known vulnerabilities
Returns:
Audit report dictionary
"""
recipe_path = Path(recipe_path)
report = {
"recipe": recipe_path.name,
"audited_at": _get_timestamp(),
"lockfile": None,
"dependencies": [],
"vulnerabilities": [],
"warnings": [],
"passed": True,
}
# Check for lockfile
lockfile = _find_lockfile(recipe_path)
if lockfile:
report["lockfile"] = str(lockfile)
else:
report["warnings"].append("No lockfile found - dependencies may not be reproducible")
# Get dependencies
deps = _get_python_deps(recipe_path)
report["dependencies"] = deps
# Check for vulnerabilities using pip-audit if available
if check_vulnerabilities and deps:
vulns = _check_vulnerabilities(deps)
report["vulnerabilities"] = vulns
if vulns:
report["passed"] = False
# Check for outdated dependencies
outdated = _check_outdated(deps)
if outdated:
report["warnings"].extend([
f"Outdated: {d['name']} ({d['current']} -> {d['latest']})"
for d in outdated
])
return report
def _find_lockfile(recipe_path: Path) -> Optional[Path]:
"""Find lockfile in recipe directory."""
lock_dir = recipe_path / "lock"
candidates = [
lock_dir / "uv.lock",
lock_dir / "requirements.lock",
lock_dir / "poetry.lock",
recipe_path / "uv.lock",
recipe_path / "requirements.lock",
recipe_path / "poetry.lock",
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _check_vulnerabilities(deps: List[Dict[str, str]]) -> List[Dict[str, Any]]:
"""Check dependencies for known vulnerabilities."""
vulns = []
# Try using pip-audit
try:
result = subprocess.run(
[sys.executable, "-m", "pip_audit", "--format", "json"],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0:
audit_data = json.loads(result.stdout)
for vuln in audit_data.get("vulnerabilities", []):
vulns.append({
"package": vuln.get("name"),
"version": vuln.get("version"),
"vulnerability_id": vuln.get("id"),
"description": vuln.get("description"),
"fix_versions": vuln.get("fix_versions", []),
})
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
pass
return vulns
def _check_outdated(deps: List[Dict[str, str]]) -> List[Dict[str, str]]:
"""Check for outdated dependencies."""
# Simplified - would need PyPI API for full implementation
return []
# ============================================================================
# Lockfile Validation
# ============================================================================
def validate_lockfile(
recipe_path: Union[str, Path],
strict: bool = False,
) -> Dict[str, Any]:
"""
Validate recipe lockfile.
Args:
recipe_path: Path to recipe directory
strict: Fail if lockfile missing
Returns:
Validation result dictionary
"""
recipe_path = Path(recipe_path)
result = {
"valid": True,
"lockfile": None,
"lockfile_type": None,
"errors": [],
"warnings": [],
}
lockfile = _find_lockfile(recipe_path)
if not lockfile:
if strict:
result["valid"] = False
result["errors"].append("No lockfile found")
else:
result["warnings"].append("No lockfile found - dependencies may not be reproducible")
return result
result["lockfile"] = str(lockfile)
# Determine lockfile type
if "uv.lock" in lockfile.name:
result["lockfile_type"] = "uv"
elif "poetry.lock" in lockfile.name:
result["lockfile_type"] = "poetry"
else:
result["lockfile_type"] = "pip"
# Validate lockfile format
try:
if result["lockfile_type"] == "uv":
_parse_uv_lock(lockfile)
elif result["lockfile_type"] == "poetry":
_parse_poetry_lock(lockfile)
else:
_parse_requirements_lock(lockfile)
except Exception as e:
result["valid"] = False
result["errors"].append(f"Invalid lockfile format: {e}")
return result
# ============================================================================
# PII Redaction
# ============================================================================
# Default PII patterns
PII_PATTERNS = {
"email": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
"phone": r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b",
"ssn": r"\b\d{3}-\d{2}-\d{4}\b",
"credit_card": r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b",
"ip_address": r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
}
def redact_pii(
data: Any,
policy: Optional[Dict[str, Any]] = None,
fields: Optional[List[str]] = None,
) -> Any:
"""
Redact PII from data based on policy.
Args:
data: Data to redact (dict, list, or string)
policy: Data policy configuration
fields: Specific fields to redact (overrides policy)
Returns:
Redacted data
"""
policy = policy or {}
mode = policy.get("pii", {}).get("mode", "allow")
if mode == "allow":
return data
fields = fields or policy.get("pii", {}).get("fields", list(PII_PATTERNS.keys()))
if isinstance(data, dict):
return {k: redact_pii(v, policy, fields) for k, v in data.items()}
elif isinstance(data, list):
return [redact_pii(item, policy, fields) for item in data]
elif isinstance(data, str):
return _redact_string(data, fields, mode)
else:
return data
def _redact_string(text: str, fields: List[str], mode: str) -> str:
"""Redact PII patterns from a string."""
for field in fields:
if field in PII_PATTERNS:
pattern = PII_PATTERNS[field]
if mode == "redact":
text = re.sub(pattern, f"[REDACTED:{field}]", text)
elif mode == "deny":
if re.search(pattern, text):
raise SecurityError(f"PII detected ({field}) and policy is 'deny'")
return text
def detect_pii(
data: Any,
fields: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""
Detect PII in data without redacting.
Args:
data: Data to scan
fields: Specific fields to check
Returns:
List of detected PII instances
"""
fields = fields or list(PII_PATTERNS.keys())
detections = []
def scan(value, path=""):
if isinstance(value, dict):
for k, v in value.items():
scan(v, f"{path}.{k}" if path else k)
elif isinstance(value, list):
for i, item in enumerate(value):
scan(item, f"{path}[{i}]")
elif isinstance(value, str):
for field in fields:
if field in PII_PATTERNS:
matches = re.findall(PII_PATTERNS[field], value)
for match in matches:
detections.append({
"type": field,
"path": path,
"sample": match[:4] + "..." if len(match) > 4 else match,
})
scan(data)
return detections
+506 -13
View File
@@ -5,17 +5,21 @@ Provides HTTP endpoints for recipe execution.
Optional dependency - requires: pip install praisonai[serve]
Endpoints:
- GET /health - Health check
- GET /v1/recipes - List recipes
- GET /v1/recipes/{name} - Describe recipe
- GET /health - Health check
- GET /v1/recipes - List recipes
- GET /v1/recipes/{name} - Describe recipe
- GET /v1/recipes/{name}/schema - Get recipe schema
- POST /v1/recipes/run - Run recipe (sync)
- POST /v1/recipes/stream - Run recipe (SSE)
- POST /v1/recipes/run - Run recipe (sync)
- POST /v1/recipes/stream - Run recipe (SSE)
- POST /v1/recipes/validate - Validate recipe
- GET /metrics - Prometheus metrics (optional)
- GET /openapi.json - OpenAPI specification
- POST /admin/reload - Hot reload registry (auth required)
Auth modes:
- none: No authentication (localhost only)
- api-key: X-API-Key header required
- jwt: Bearer token required (future)
- jwt: Bearer token required
Config file (serve.yaml):
```yaml
@@ -28,12 +32,19 @@ recipes:
- another-recipe
preload: true
cors_origins: "*"
rate_limit: 100 # requests per minute (0 = disabled)
max_request_size: 10485760 # 10MB default
enable_metrics: false # Enable /metrics endpoint
enable_admin: false # Enable /admin/* endpoints
trace_exporter: none # none, otlp, jaeger, zipkin
```
"""
import json
import os
from typing import Any, Dict, Optional
import time
from collections import defaultdict
from typing import Any, Dict, List, Optional, Tuple
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
@@ -65,7 +76,239 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
return config
def create_auth_middleware(auth_type: str, api_key: Optional[str] = None):
# Default constants
DEFAULT_MAX_REQUEST_SIZE = 10 * 1024 * 1024 # 10MB
DEFAULT_RATE_LIMIT = 100 # requests per minute
DEFAULT_RATE_LIMIT_EXEMPT_PATHS = ["/health", "/metrics"]
class RateLimiter:
"""Simple in-memory rate limiter using sliding window."""
def __init__(self, requests_per_minute: int = DEFAULT_RATE_LIMIT):
self.requests_per_minute = requests_per_minute
self.window_seconds = 60
self._requests: Dict[str, List[float]] = defaultdict(list)
def check(self, client_id: str) -> Tuple[bool, int]:
"""
Check if request is allowed.
Returns:
Tuple of (allowed, retry_after_seconds)
"""
if self.requests_per_minute <= 0:
return True, 0
now = time.time()
window_start = now - self.window_seconds
# Clean old requests
self._requests[client_id] = [
t for t in self._requests[client_id] if t > window_start
]
if len(self._requests[client_id]) >= self.requests_per_minute:
# Calculate retry-after
oldest = min(self._requests[client_id])
retry_after = int(oldest + self.window_seconds - now) + 1
return False, max(1, retry_after)
self._requests[client_id].append(now)
return True, 0
def create_rate_limiter(requests_per_minute: int = DEFAULT_RATE_LIMIT) -> RateLimiter:
"""Create a rate limiter instance."""
return RateLimiter(requests_per_minute=requests_per_minute)
class MetricsCollector:
"""Simple in-memory metrics collector for Prometheus format."""
def __init__(self):
self._requests_total: Dict[str, int] = defaultdict(int)
self._request_durations: Dict[str, List[float]] = defaultdict(list)
self._errors_total: Dict[str, int] = defaultdict(int)
def record_request(self, path: str, method: str, status: int, duration: float):
"""Record a request."""
# Normalize path to avoid label explosion
normalized_path = self._normalize_path(path)
key = f'{normalized_path}|{method}|{status}'
self._requests_total[key] += 1
self._request_durations[f'{normalized_path}|{method}'].append(duration)
if status >= 400:
error_type = "client_error" if status < 500 else "server_error"
error_key = f'{normalized_path}|{method}|{error_type}'
self._errors_total[error_key] += 1
def _normalize_path(self, path: str) -> str:
"""Normalize path to avoid label explosion."""
# Replace dynamic segments
parts = path.split("/")
normalized = []
for i, part in enumerate(parts):
if i > 0 and parts[i-1] == "recipes" and part not in ["run", "stream", "validate"]:
normalized.append("{name}")
else:
normalized.append(part)
return "/".join(normalized)
def get_prometheus_metrics(self) -> str:
"""Get metrics in Prometheus exposition format."""
lines = []
# Requests total
lines.append("# HELP praisonai_http_requests_total Total HTTP requests")
lines.append("# TYPE praisonai_http_requests_total counter")
for key, count in self._requests_total.items():
path, method, status = key.split("|")
lines.append(f'praisonai_http_requests_total{{path="{path}",method="{method}",status="{status}"}} {count}')
# Request duration histogram (simplified - just sum and count)
lines.append("# HELP praisonai_http_request_duration_seconds HTTP request duration")
lines.append("# TYPE praisonai_http_request_duration_seconds histogram")
for key, durations in self._request_durations.items():
path, method = key.split("|")
if durations:
total = sum(durations)
count = len(durations)
lines.append(f'praisonai_http_request_duration_seconds_sum{{path="{path}",method="{method}"}} {total:.6f}')
lines.append(f'praisonai_http_request_duration_seconds_count{{path="{path}",method="{method}"}} {count}')
# Add buckets
buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
for bucket in buckets:
bucket_count = sum(1 for d in durations if d <= bucket)
lines.append(f'praisonai_http_request_duration_seconds_bucket{{path="{path}",method="{method}",le="{bucket}"}} {bucket_count}')
lines.append(f'praisonai_http_request_duration_seconds_bucket{{path="{path}",method="{method}",le="+Inf"}} {count}')
# Errors total
lines.append("# HELP praisonai_http_errors_total Total HTTP errors")
lines.append("# TYPE praisonai_http_errors_total counter")
for key, count in self._errors_total.items():
path, method, error_type = key.split("|")
lines.append(f'praisonai_http_errors_total{{path="{path}",method="{method}",error_type="{error_type}"}} {count}')
return "\n".join(lines)
# Global metrics collector (created per app instance)
_metrics_collector: Optional[MetricsCollector] = None
def get_openapi_spec(config: Dict[str, Any]) -> Dict[str, Any]:
"""Generate OpenAPI specification."""
spec = {
"openapi": "3.0.3",
"info": {
"title": "PraisonAI Recipe Runner API",
"description": "HTTP API for running PraisonAI recipes",
"version": _get_version(),
},
"servers": [
{"url": f"http://{config.get('host', '127.0.0.1')}:{config.get('port', 8765)}"}
],
"paths": {
"/health": {
"get": {
"summary": "Health check",
"responses": {"200": {"description": "Server is healthy"}}
}
},
"/v1/recipes": {
"get": {
"summary": "List available recipes",
"parameters": [
{"name": "source", "in": "query", "schema": {"type": "string"}},
{"name": "tags", "in": "query", "schema": {"type": "string"}}
],
"responses": {"200": {"description": "List of recipes"}}
}
},
"/v1/recipes/{name}": {
"get": {
"summary": "Describe a recipe",
"parameters": [{"name": "name", "in": "path", "required": True, "schema": {"type": "string"}}],
"responses": {"200": {"description": "Recipe details"}, "404": {"description": "Recipe not found"}}
}
},
"/v1/recipes/{name}/schema": {
"get": {
"summary": "Get recipe JSON schema",
"parameters": [{"name": "name", "in": "path", "required": True, "schema": {"type": "string"}}],
"responses": {"200": {"description": "Recipe schema"}}
}
},
"/v1/recipes/run": {
"post": {
"summary": "Run a recipe",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["recipe"],
"properties": {
"recipe": {"type": "string"},
"input": {"type": "object"},
"config": {"type": "object"},
"session_id": {"type": "string"},
"options": {"type": "object"}
}
}
}
}
},
"responses": {"200": {"description": "Recipe result"}}
}
},
"/v1/recipes/stream": {
"post": {
"summary": "Stream recipe execution (SSE)",
"responses": {"200": {"description": "SSE event stream"}}
}
},
"/v1/recipes/validate": {
"post": {
"summary": "Validate a recipe",
"responses": {"200": {"description": "Validation result"}}
}
},
}
}
# Add optional endpoints
if config.get("enable_metrics"):
spec["paths"]["/metrics"] = {
"get": {
"summary": "Prometheus metrics",
"responses": {"200": {"description": "Metrics in Prometheus format"}}
}
}
if config.get("enable_admin"):
spec["paths"]["/admin/reload"] = {
"post": {
"summary": "Hot reload recipe registry",
"security": [{"apiKey": []}],
"responses": {"200": {"description": "Reload successful"}, "401": {"description": "Unauthorized"}}
}
}
spec["paths"]["/openapi.json"] = {
"get": {
"summary": "OpenAPI specification",
"responses": {"200": {"description": "OpenAPI JSON"}}
}
}
return spec
def create_auth_middleware(auth_type: str, api_key: Optional[str] = None, jwt_secret: Optional[str] = None):
"""Create authentication middleware."""
try:
from starlette.middleware.base import BaseHTTPMiddleware
@@ -97,8 +340,57 @@ def create_auth_middleware(auth_type: str, api_key: Optional[str] = None):
return await call_next(request)
class JWTAuthMiddleware(BaseHTTPMiddleware):
"""JWT authentication middleware."""
async def dispatch(self, request, call_next):
# Skip auth for health endpoint
if request.url.path == "/health":
return await call_next(request)
# Get JWT secret
secret = jwt_secret or os.environ.get("PRAISONAI_JWT_SECRET")
if not secret:
return await call_next(request)
# Check Authorization header
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
{"error": {"code": "unauthorized", "message": "Missing or invalid Authorization header"}},
status_code=401
)
token = auth_header[7:] # Remove "Bearer " prefix
try:
# Lazy import jwt
import jwt as pyjwt
payload = pyjwt.decode(token, secret, algorithms=["HS256"])
# Store user info in request state
request.state.user = payload
except ImportError:
return JSONResponse(
{"error": {"code": "server_error", "message": "JWT support not installed. Run: pip install PyJWT"}},
status_code=500
)
except pyjwt.ExpiredSignatureError:
return JSONResponse(
{"error": {"code": "unauthorized", "message": "Token expired"}},
status_code=401
)
except pyjwt.InvalidTokenError as e:
return JSONResponse(
{"error": {"code": "unauthorized", "message": f"Invalid token: {e}"}},
status_code=401
)
return await call_next(request)
if auth_type == "api-key":
return APIKeyAuthMiddleware
elif auth_type == "jwt":
return JWTAuthMiddleware
return None
@@ -297,6 +589,43 @@ def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
status_code = 200 if result.valid else 400
return JSONResponse(result.to_dict(), status_code=status_code)
async def get_metrics(request: Request) -> Response:
"""GET /metrics - Prometheus metrics."""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = MetricsCollector()
content = _metrics_collector.get_prometheus_metrics()
return Response(
content=content,
media_type="text/plain; version=0.0.4; charset=utf-8"
)
async def admin_reload(request: Request) -> JSONResponse:
"""POST /admin/reload - Hot reload recipe registry."""
try:
from praisonai import recipe
# Clear any cached recipes and reload
if hasattr(recipe, '_recipe_cache'):
recipe._recipe_cache.clear()
if hasattr(recipe, 'reload_registry'):
recipe.reload_registry()
return JSONResponse({
"status": "reloaded",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
})
except Exception as e:
return JSONResponse(
{"error": {"code": "reload_failed", "message": str(e)}},
status_code=500
)
async def get_openapi(request: Request) -> JSONResponse:
"""GET /openapi.json - OpenAPI specification."""
spec = get_openapi_spec(config)
return JSONResponse(spec)
# Build routes
routes = [
Route("/health", health, methods=["GET"]),
@@ -306,28 +635,134 @@ def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
Route("/v1/recipes/validate", validate_recipe, methods=["POST"]),
Route("/v1/recipes/{name}", describe_recipe, methods=["GET"]),
Route("/v1/recipes/{name}/schema", get_schema, methods=["GET"]),
Route("/openapi.json", get_openapi, methods=["GET"]),
]
# Add optional endpoints
if config.get("enable_metrics"):
routes.append(Route("/metrics", get_metrics, methods=["GET"]))
if config.get("enable_admin"):
routes.append(Route("/admin/reload", admin_reload, methods=["POST"]))
# Initialize metrics collector if enabled
global _metrics_collector
if config.get("enable_metrics"):
_metrics_collector = MetricsCollector()
# Create rate limiter if configured
rate_limit = config.get("rate_limit", 0)
rate_limiter = None
if rate_limit > 0:
rate_limiter = create_rate_limiter(rate_limit)
# Get max request size
max_request_size = config.get("max_request_size", DEFAULT_MAX_REQUEST_SIZE)
# Exempt paths for rate limiting
rate_limit_exempt = config.get("rate_limit_exempt_paths", DEFAULT_RATE_LIMIT_EXEMPT_PATHS)
# Add CORS middleware if configured
middleware = []
cors_origins = config.get("cors_origins")
if cors_origins:
# Parse CORS configuration
if isinstance(cors_origins, str):
origins = [o.strip() for o in cors_origins.split(",")]
else:
origins = cors_origins
middleware.append(
Middleware(
CORSMiddleware,
allow_origins=cors_origins.split(",") if isinstance(cors_origins, str) else cors_origins,
allow_methods=["*"],
allow_headers=["*"],
allow_origins=origins,
allow_methods=config.get("cors_methods", ["*"]),
allow_headers=config.get("cors_headers", ["*"]),
allow_credentials=config.get("cors_credentials", False),
max_age=config.get("cors_max_age", 600),
)
)
# Add auth middleware if configured
auth_type = config.get("auth")
if auth_type and auth_type != "none":
auth_middleware = create_auth_middleware(auth_type, config.get("api_key"))
auth_middleware = create_auth_middleware(
auth_type,
api_key=config.get("api_key"),
jwt_secret=config.get("jwt_secret"),
)
if auth_middleware:
middleware.append(Middleware(auth_middleware))
# Create rate limit and size limit middleware
from starlette.middleware.base import BaseHTTPMiddleware
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Rate limiting middleware."""
async def dispatch(self, request, call_next):
if rate_limiter is None:
return await call_next(request)
# Skip exempt paths
if request.url.path in rate_limit_exempt:
return await call_next(request)
# Get client identifier (IP or API key)
client_id = request.headers.get("X-API-Key") or request.client.host if request.client else "unknown"
allowed, retry_after = rate_limiter.check(client_id)
if not allowed:
return JSONResponse(
{"error": {"code": "rate_limited", "message": "Too many requests"}},
status_code=429,
headers={"Retry-After": str(retry_after)}
)
return await call_next(request)
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
"""Request size limit middleware."""
async def dispatch(self, request, call_next):
content_length = request.headers.get("content-length")
if content_length and int(content_length) > max_request_size:
return JSONResponse(
{"error": {"code": "request_too_large", "message": f"Request body too large. Max: {max_request_size} bytes"}},
status_code=413
)
return await call_next(request)
class MetricsMiddleware(BaseHTTPMiddleware):
"""Metrics collection middleware."""
async def dispatch(self, request, call_next):
if _metrics_collector is None:
return await call_next(request)
start_time = time.time()
response = await call_next(request)
duration = time.time() - start_time
_metrics_collector.record_request(
path=request.url.path,
method=request.method,
status=response.status_code,
duration=duration
)
return response
# Add custom middleware (order matters - first added = outermost)
if config.get("enable_metrics"):
middleware.append(Middleware(MetricsMiddleware))
if max_request_size > 0:
middleware.append(Middleware(RequestSizeLimitMiddleware))
if rate_limiter is not None:
middleware.append(Middleware(RateLimitMiddleware))
return Starlette(routes=routes, middleware=middleware)
@@ -336,6 +771,7 @@ def serve(
port: int = 8765,
reload: bool = False,
config: Optional[Dict[str, Any]] = None,
workers: int = 1,
):
"""
Start the recipe runner server.
@@ -345,6 +781,7 @@ def serve(
port: Server port (default: 8765)
reload: Enable hot reload (default: False)
config: Optional configuration dict
workers: Number of worker processes (default: 1)
"""
try:
import uvicorn
@@ -353,8 +790,64 @@ def serve(
"Serve dependencies not installed. Run: pip install praisonai[serve]"
)
# Initialize OpenTelemetry tracing if configured
trace_exporter = (config or {}).get("trace_exporter", "none")
if trace_exporter and trace_exporter != "none":
_init_tracing(trace_exporter, config or {})
app = create_app(config)
uvicorn.run(app, host=host, port=port, reload=reload)
# Workers > 1 requires reload=False
if workers > 1 and reload:
import warnings
warnings.warn("Cannot use reload with multiple workers. Disabling reload.")
reload = False
uvicorn.run(app, host=host, port=port, reload=reload, workers=workers if workers > 1 else None)
def _init_tracing(exporter: str, config: Dict[str, Any]):
"""Initialize OpenTelemetry tracing (lazy import)."""
try:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
resource = Resource.create({
"service.name": config.get("service_name", "praisonai-recipe"),
"service.version": _get_version(),
})
provider = TracerProvider(resource=resource)
if exporter == "otlp":
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
endpoint = config.get("otlp_endpoint", "http://localhost:4317")
span_exporter = OTLPSpanExporter(endpoint=endpoint)
elif exporter == "jaeger":
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
span_exporter = JaegerExporter(
agent_host_name=config.get("jaeger_host", "localhost"),
agent_port=config.get("jaeger_port", 6831),
)
elif exporter == "zipkin":
from opentelemetry.exporter.zipkin.json import ZipkinExporter
span_exporter = ZipkinExporter(
endpoint=config.get("zipkin_endpoint", "http://localhost:9411/api/v2/spans")
)
else:
return # Unknown exporter
provider.add_span_processor(BatchSpanProcessor(span_exporter))
trace.set_tracer_provider(provider)
except ImportError:
import warnings
warnings.warn(
f"OpenTelemetry exporter '{exporter}' requested but dependencies not installed. "
"Run: pip install opentelemetry-sdk opentelemetry-exporter-otlp"
)
def _get_version() -> str:
+613
View File
@@ -0,0 +1,613 @@
"""
Recipe Registry HTTP Server
Provides a local HTTP server for recipe registry operations.
Supports:
- GET /healthz - Health check
- GET /v1/recipes - List recipes (pagination)
- GET /v1/recipes/{name} - Get recipe info (all versions)
- GET /v1/recipes/{name}/{version} - Get specific version info
- GET /v1/recipes/{name}/{version}/download - Download bundle
- POST /v1/recipes/{name}/{version} - Publish bundle (multipart)
- DELETE /v1/recipes/{name}/{version} - Delete version
- GET /v1/search?q=... - Search recipes
Usage:
from praisonai.recipe.server import create_app, run_server
# Run server
run_server(host="127.0.0.1", port=7777)
# Or get ASGI app for custom deployment
app = create_app(registry_path="~/.praison/registry")
"""
import json
import hashlib
import os
import re
import tempfile
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, unquote
from .registry import (
LocalRegistry,
RegistryError,
RecipeNotFoundError,
RecipeExistsError,
RegistryAuthError,
DEFAULT_REGISTRY_PATH,
DEFAULT_REGISTRY_PORT,
_calculate_checksum,
_get_timestamp,
)
class RegistryServer:
"""
Simple HTTP server for recipe registry.
Uses only stdlib for minimal dependencies.
For production, consider using with uvicorn/gunicorn.
"""
def __init__(
self,
registry_path: Optional[Path] = None,
token: Optional[str] = None,
read_only: bool = False,
):
"""
Initialize registry server.
Args:
registry_path: Path to registry directory
token: Required token for write operations (optional)
read_only: If True, disable all write operations
"""
self.registry = LocalRegistry(registry_path)
self.token = token or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
self.read_only = read_only
self._routes: List[Tuple[str, str, Callable]] = []
self._setup_routes()
def _setup_routes(self):
"""Setup URL routes."""
self._routes = [
("GET", r"^/healthz$", self._handle_health),
("GET", r"^/v1/recipes$", self._handle_list_recipes),
("GET", r"^/v1/recipes/([^/]+)$", self._handle_get_recipe),
("GET", r"^/v1/recipes/([^/]+)/([^/]+)$", self._handle_get_version),
("GET", r"^/v1/recipes/([^/]+)/([^/]+)/download$", self._handle_download),
("POST", r"^/v1/recipes/([^/]+)/([^/]+)$", self._handle_publish),
("DELETE", r"^/v1/recipes/([^/]+)/([^/]+)$", self._handle_delete_version),
("DELETE", r"^/v1/recipes/([^/]+)$", self._handle_delete_recipe),
("GET", r"^/v1/search$", self._handle_search),
]
def _check_auth(self, headers: Dict[str, str]) -> bool:
"""Check if request is authorized for write operations."""
if not self.token:
return True
auth = headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:] == self.token
return False
def _json_response(
self,
data: Any,
status: int = 200,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[int, Dict[str, str], bytes]:
"""Create JSON response."""
resp_headers = {
"Content-Type": "application/json",
"X-Registry-Version": "1.0",
}
if headers:
resp_headers.update(headers)
body = json.dumps(data, indent=2).encode("utf-8")
return status, resp_headers, body
def _error_response(
self,
message: str,
status: int = 400,
code: Optional[str] = None,
) -> Tuple[int, Dict[str, str], bytes]:
"""Create error response."""
return self._json_response({
"ok": False,
"error": message,
"code": code or "error",
}, status=status)
def _file_response(
self,
file_path: Path,
filename: str,
) -> Tuple[int, Dict[str, str], bytes]:
"""Create file download response."""
with open(file_path, "rb") as f:
content = f.read()
checksum = hashlib.sha256(content).hexdigest()
headers = {
"Content-Type": "application/gzip",
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(content)),
"ETag": f'"{checksum[:16]}"',
"X-Checksum-SHA256": checksum,
}
return 200, headers, content
def _parse_multipart(
self,
body: bytes,
content_type: str,
) -> Dict[str, Any]:
"""Parse multipart form data."""
# Extract boundary
boundary = None
for part in content_type.split(";"):
part = part.strip()
if part.startswith("boundary="):
boundary = part[9:].strip('"')
break
if not boundary:
raise ValueError("Missing boundary in multipart data")
result = {"fields": {}, "files": {}}
boundary_bytes = f"--{boundary}".encode()
parts = body.split(boundary_bytes)
for part in parts[1:]: # Skip preamble
if part.startswith(b"--"):
break # End marker
# Split headers and content
if b"\r\n\r\n" in part:
header_section, content = part.split(b"\r\n\r\n", 1)
else:
continue
# Parse headers
headers = {}
for line in header_section.decode("utf-8", errors="ignore").split("\r\n"):
if ":" in line:
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
# Get field name and filename
disposition = headers.get("content-disposition", "")
name = None
filename = None
for item in disposition.split(";"):
item = item.strip()
if item.startswith("name="):
name = item[5:].strip('"')
elif item.startswith("filename="):
filename = item[9:].strip('"')
if not name:
continue
# Remove trailing \r\n
content = content.rstrip(b"\r\n")
if filename:
result["files"][name] = {
"filename": filename,
"content": content,
"content_type": headers.get("content-type", "application/octet-stream"),
}
else:
result["fields"][name] = content.decode("utf-8", errors="ignore")
return result
def handle_request(
self,
method: str,
path: str,
headers: Dict[str, str],
body: bytes,
query_string: str = "",
) -> Tuple[int, Dict[str, str], bytes]:
"""
Handle HTTP request.
Args:
method: HTTP method
path: URL path
headers: Request headers
body: Request body
query_string: Query string
Returns:
Tuple of (status_code, headers, body)
"""
# Parse query string
query = parse_qs(query_string)
# Find matching route
for route_method, pattern, handler in self._routes:
if method != route_method:
continue
match = re.match(pattern, path)
if match:
try:
return handler(
headers=headers,
body=body,
query=query,
path_params=match.groups(),
)
except RecipeNotFoundError as e:
return self._error_response(str(e), status=404, code="not_found")
except RecipeExistsError as e:
return self._error_response(str(e), status=409, code="conflict")
except RegistryAuthError as e:
return self._error_response(str(e), status=401, code="auth_error")
except RegistryError as e:
return self._error_response(str(e), status=400, code="registry_error")
except Exception as e:
return self._error_response(f"Internal error: {e}", status=500, code="internal_error")
return self._error_response("Not found", status=404, code="not_found")
def _handle_health(self, **kwargs) -> Tuple[int, Dict[str, str], bytes]:
"""Handle GET /healthz."""
return self._json_response({
"ok": True,
"status": "healthy",
"timestamp": _get_timestamp(),
"read_only": self.read_only,
"auth_required": bool(self.token),
})
def _handle_list_recipes(
self,
query: Dict[str, List[str]],
**kwargs,
) -> Tuple[int, Dict[str, str], bytes]:
"""Handle GET /v1/recipes."""
page = int(query.get("page", ["1"])[0])
per_page = min(int(query.get("per_page", ["50"])[0]), 100)
tags = query.get("tags", [""])[0].split(",") if query.get("tags") else None
if tags and tags[0] == "":
tags = None
recipes = self.registry.list_recipes(tags=tags)
# Paginate
start = (page - 1) * per_page
end = start + per_page
paginated = recipes[start:end]
return self._json_response({
"ok": True,
"recipes": paginated,
"total": len(recipes),
"page": page,
"per_page": per_page,
"has_more": end < len(recipes),
})
def _handle_get_recipe(
self,
path_params: Tuple[str, ...],
**kwargs,
) -> Tuple[int, Dict[str, str], bytes]:
"""Handle GET /v1/recipes/{name}."""
name = unquote(path_params[0])
versions = self.registry.get_versions(name)
info = self.registry.get_info(name)
return self._json_response({
"ok": True,
"name": name,
"versions": versions,
"latest": info.get("version"),
"description": info.get("description", ""),
"tags": info.get("tags", []),
})
def _handle_get_version(
self,
path_params: Tuple[str, ...],
**kwargs,
) -> Tuple[int, Dict[str, str], bytes]:
"""Handle GET /v1/recipes/{name}/{version}."""
name = unquote(path_params[0])
version = unquote(path_params[1])
info = self.registry.get_info(name, version)
return self._json_response({
"ok": True,
**info,
})
def _handle_download(
self,
path_params: Tuple[str, ...],
headers: Dict[str, str],
**kwargs,
) -> Tuple[int, Dict[str, str], bytes]:
"""Handle GET /v1/recipes/{name}/{version}/download."""
name = unquote(path_params[0])
version = unquote(path_params[1])
# Get bundle path
bundle_name = f"{name}-{version}.praison"
bundle_path = self.registry.recipes_path / name / version / bundle_name
if not bundle_path.exists():
raise RecipeNotFoundError(f"Bundle not found: {name}@{version}")
# Check ETag for caching
checksum = _calculate_checksum(bundle_path)
etag = f'"{checksum[:16]}"'
if_none_match = headers.get("If-None-Match", "")
if if_none_match == etag:
return 304, {"ETag": etag}, b""
return self._file_response(bundle_path, bundle_name)
def _handle_publish(
self,
path_params: Tuple[str, ...],
headers: Dict[str, str],
body: bytes,
**kwargs,
) -> Tuple[int, Dict[str, str], bytes]:
"""Handle POST /v1/recipes/{name}/{version}."""
if self.read_only:
return self._error_response("Registry is read-only", status=403, code="read_only")
if not self._check_auth(headers):
return self._error_response("Authentication required", status=401, code="auth_required")
name = unquote(path_params[0])
version = unquote(path_params[1])
# Parse multipart data
content_type = headers.get("Content-Type", "")
if not content_type.startswith("multipart/form-data"):
return self._error_response("Expected multipart/form-data", status=400)
try:
data = self._parse_multipart(body, content_type)
except ValueError as e:
return self._error_response(f"Invalid multipart data: {e}", status=400)
if "bundle" not in data["files"]:
return self._error_response("Missing bundle file", status=400)
force = data["fields"].get("force", "false").lower() == "true"
bundle_content = data["files"]["bundle"]["content"]
# Save to temp file and publish
with tempfile.NamedTemporaryFile(suffix=".praison", delete=False) as tmp:
tmp.write(bundle_content)
tmp_path = Path(tmp.name)
try:
result = self.registry.publish(tmp_path, force=force)
# Verify name/version match
if result["name"] != name or result["version"] != version:
# Rollback
self.registry.delete(result["name"], result["version"])
return self._error_response(
f"Bundle name/version ({result['name']}@{result['version']}) "
f"doesn't match URL ({name}@{version})",
status=400,
)
return self._json_response({
"ok": True,
**result,
}, status=201)
finally:
tmp_path.unlink(missing_ok=True)
def _handle_delete_version(
self,
path_params: Tuple[str, ...],
headers: Dict[str, str],
**kwargs,
) -> Tuple[int, Dict[str, str], bytes]:
"""Handle DELETE /v1/recipes/{name}/{version}."""
if self.read_only:
return self._error_response("Registry is read-only", status=403, code="read_only")
if not self._check_auth(headers):
return self._error_response("Authentication required", status=401, code="auth_required")
name = unquote(path_params[0])
version = unquote(path_params[1])
self.registry.delete(name, version)
return self._json_response({
"ok": True,
"deleted": f"{name}@{version}",
})
def _handle_delete_recipe(
self,
path_params: Tuple[str, ...],
headers: Dict[str, str],
**kwargs,
) -> Tuple[int, Dict[str, str], bytes]:
"""Handle DELETE /v1/recipes/{name}."""
if self.read_only:
return self._error_response("Registry is read-only", status=403, code="read_only")
if not self._check_auth(headers):
return self._error_response("Authentication required", status=401, code="auth_required")
name = unquote(path_params[0])
self.registry.delete(name)
return self._json_response({
"ok": True,
"deleted": name,
})
def _handle_search(
self,
query: Dict[str, List[str]],
**kwargs,
) -> Tuple[int, Dict[str, str], bytes]:
"""Handle GET /v1/search."""
q = query.get("q", [""])[0]
tags = query.get("tags", [""])[0].split(",") if query.get("tags") else None
if tags and tags[0] == "":
tags = None
if not q:
return self._error_response("Query parameter 'q' required", status=400)
results = self.registry.search(q, tags=tags)
return self._json_response({
"ok": True,
"query": q,
"results": results,
"count": len(results),
})
def create_wsgi_app(
registry_path: Optional[Path] = None,
token: Optional[str] = None,
read_only: bool = False,
) -> Callable:
"""
Create WSGI application for the registry server.
Args:
registry_path: Path to registry directory
token: Required token for write operations
read_only: If True, disable write operations
Returns:
WSGI application callable
"""
server = RegistryServer(
registry_path=registry_path,
token=token,
read_only=read_only,
)
def app(environ, start_response):
method = environ.get("REQUEST_METHOD", "GET")
path = environ.get("PATH_INFO", "/")
query_string = environ.get("QUERY_STRING", "")
# Read headers
headers = {}
for key, value in environ.items():
if key.startswith("HTTP_"):
header_name = key[5:].replace("_", "-").title()
headers[header_name] = value
headers["Content-Type"] = environ.get("CONTENT_TYPE", "")
# Read body
try:
content_length = int(environ.get("CONTENT_LENGTH", 0))
except (ValueError, TypeError):
content_length = 0
body = environ["wsgi.input"].read(content_length) if content_length > 0 else b""
# Handle request
status_code, resp_headers, resp_body = server.handle_request(
method=method,
path=path,
headers=headers,
body=body,
query_string=query_string,
)
# Send response
status = f"{status_code} {'OK' if status_code < 400 else 'Error'}"
response_headers = [(k, v) for k, v in resp_headers.items()]
start_response(status, response_headers)
return [resp_body]
return app
def run_server(
host: str = "127.0.0.1",
port: int = DEFAULT_REGISTRY_PORT,
registry_path: Optional[Path] = None,
token: Optional[str] = None,
read_only: bool = False,
):
"""
Run the registry server using stdlib wsgiref.
Args:
host: Host to bind to
port: Port to bind to
registry_path: Path to registry directory
token: Required token for write operations
read_only: If True, disable write operations
"""
from wsgiref.simple_server import make_server, WSGIRequestHandler
# Custom handler to suppress logs unless verbose
class QuietHandler(WSGIRequestHandler):
def log_message(self, format, *args):
pass # Suppress default logging
app = create_wsgi_app(
registry_path=registry_path,
token=token,
read_only=read_only,
)
server = make_server(host, port, app, handler_class=QuietHandler)
print(f"Recipe Registry Server running on http://{host}:{port}")
print(f"Registry path: {registry_path or DEFAULT_REGISTRY_PATH}")
if token:
print("Authentication: enabled (token required for writes)")
if read_only:
print("Mode: read-only")
print("\nEndpoints:")
print(" GET /healthz - Health check")
print(" GET /v1/recipes - List recipes")
print(" GET /v1/recipes/{name} - Get recipe info")
print(" GET /v1/recipes/{name}/{version} - Get version info")
print(" GET /v1/recipes/{name}/{version}/download - Download bundle")
print(" POST /v1/recipes/{name}/{version} - Publish bundle")
print(" DELETE /v1/recipes/{name}/{version} - Delete version")
print(" GET /v1/search?q=... - Search recipes")
print("\nPress Ctrl+C to stop")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")
server.shutdown()
@@ -436,6 +436,103 @@ class AgentScheduler:
return self.start(interval, max_retries, run_immediately)
@classmethod
def from_recipe(
cls,
recipe_name: str,
*,
input_data: Any = None,
config: Optional[Dict[str, Any]] = None,
interval_override: Optional[str] = None,
max_retries_override: Optional[int] = None,
timeout_override: Optional[int] = None,
max_cost_override: Optional[float] = None,
on_success: Optional[Callable] = None,
on_failure: Optional[Callable] = None
) -> 'AgentScheduler':
"""
Create AgentScheduler from a recipe name.
Args:
recipe_name: Name of the recipe to schedule
input_data: Input data for the recipe
config: Configuration overrides for the recipe
interval_override: Override schedule interval from recipe runtime config
max_retries_override: Override max_retries from recipe runtime config
timeout_override: Override timeout from recipe runtime config
max_cost_override: Override max_cost from recipe runtime config
on_success: Callback function on successful execution
on_failure: Callback function on failed execution
Returns:
Configured AgentScheduler instance
Example:
scheduler = AgentScheduler.from_recipe("news-monitor")
scheduler.start(schedule_expr="hourly")
"""
from praisonai.recipe.bridge import resolve, execute_resolved_recipe, get_recipe_task_description
# Resolve the recipe
resolved = resolve(
recipe_name,
input_data=input_data,
config=config or {},
options={'timeout_sec': timeout_override or 300},
)
# Get runtime config defaults from recipe
interval = interval_override or "hourly"
max_retries = max_retries_override if max_retries_override is not None else 3
timeout = timeout_override or 300
max_cost = max_cost_override if max_cost_override is not None else 1.00
runtime = resolved.runtime_config
if runtime and hasattr(runtime, 'schedule'):
sched_config = runtime.schedule
interval = interval_override or sched_config.interval
max_retries = max_retries_override if max_retries_override is not None else sched_config.max_retries
timeout = timeout_override or sched_config.timeout_sec
max_cost = max_cost_override if max_cost_override is not None else sched_config.max_cost_usd
# Create a recipe executor agent wrapper
class RecipeExecutorAgent:
"""Wrapper that makes a recipe look like an agent for the scheduler."""
def __init__(self, resolved_recipe):
self.resolved = resolved_recipe
self.name = f"RecipeAgent:{resolved_recipe.name}"
def start(self, task: str) -> Any:
return execute_resolved_recipe(self.resolved)
# Create the agent wrapper
agent = RecipeExecutorAgent(resolved)
task = get_recipe_task_description(resolved)
# Create scheduler instance
scheduler = cls(
agent=agent,
task=task,
timeout=timeout,
max_cost=max_cost,
on_success=on_success,
on_failure=on_failure,
)
# Store recipe metadata and schedule config
scheduler._recipe_name = recipe_name
scheduler._recipe_resolved = resolved
scheduler._yaml_schedule_config = {
'interval': interval,
'max_retries': max_retries,
'run_immediately': False,
'timeout': timeout,
'max_cost': max_cost,
}
return scheduler
def create_agent_scheduler(
agent,
task: str,
@@ -69,8 +69,9 @@ class DaemonManager:
def start_scheduler_daemon(
self,
name: str,
task: str,
interval: str,
task: Optional[str] = None,
recipe_name: Optional[str] = None,
interval: str = "hourly",
max_cost: Optional[float] = None,
timeout: Optional[int] = None,
max_retries: int = 3
@@ -80,7 +81,8 @@ class DaemonManager:
Args:
name: Scheduler name
task: Task to schedule
task: Task to schedule (mutually exclusive with recipe_name)
recipe_name: Recipe name to schedule (mutually exclusive with task)
interval: Schedule interval
max_cost: Maximum cost budget
timeout: Timeout per execution
@@ -95,18 +97,26 @@ class DaemonManager:
"-m",
"praisonai.cli.main",
"schedule",
task,
"--interval", interval,
"--max-retries", str(max_retries)
]
# Add task or recipe
if recipe_name:
command.append(recipe_name) # Recipe name will be auto-detected
elif task:
command.append(task)
else:
raise ValueError("Either task or recipe_name must be provided")
command.extend(["--interval", interval, "--max-retries", str(max_retries)])
if timeout:
command.extend(["--timeout", str(timeout)])
if max_cost:
command.extend(["--max-cost", str(max_cost)])
return self.start_daemon(name, task, interval, command)
display_task = recipe_name or task
return self.start_daemon(name, display_task, interval, command)
def stop_daemon(self, pid: int, timeout: int = 10) -> bool:
"""
@@ -47,6 +47,9 @@ class TemplateConfig:
# CLI integration
cli: Dict[str, Any] = field(default_factory=dict)
# Runtime configuration (background/job/schedule)
runtime: Optional[Any] = None # RuntimeConfig, lazy loaded
# Raw config dict
raw: Dict[str, Any] = field(default_factory=dict)
@@ -162,6 +165,12 @@ class TemplateLoader:
# Handle agents - can be inline list or file reference string
agents = raw.get("agents", "agents.yaml")
# Parse runtime configuration (lazy import to avoid circular deps)
runtime = None
if "runtime" in raw:
from praisonai.recipe.runtime import parse_runtime_config
runtime = parse_runtime_config(raw.get("runtime"), expand_env=True)
return TemplateConfig(
name=raw.get("name", template_dir.name),
description=raw.get("description", ""),
@@ -176,6 +185,7 @@ class TemplateLoader:
defaults=raw.get("defaults", {}),
skills=raw.get("skills", []),
cli=raw.get("cli", {}),
runtime=runtime,
raw=raw,
path=template_dir
)
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.7.2"
__version__ = "2.7.6"
@@ -0,0 +1,369 @@
"""
Integration tests for recipe registry HTTP server.
Tests the full HTTP server with real requests.
"""
import json
import tarfile
import pytest
from praisonai.recipe.server import RegistryServer, create_wsgi_app
class TestRegistryServer:
"""Test RegistryServer class."""
@pytest.fixture
def registry_path(self, tmp_path):
"""Create temporary registry path."""
return tmp_path / "registry"
@pytest.fixture
def server(self, registry_path):
"""Create a RegistryServer instance."""
return RegistryServer(registry_path=registry_path)
@pytest.fixture
def sample_bundle(self, tmp_path):
"""Create a sample .praison bundle."""
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
manifest = {
"name": "test-recipe",
"version": "1.0.0",
"description": "A test recipe",
"tags": ["test", "sample"],
"author": "test",
"files": ["recipe.yaml"],
}
(bundle_dir / "manifest.json").write_text(json.dumps(manifest))
(bundle_dir / "recipe.yaml").write_text("name: test")
bundle_path = tmp_path / "test-recipe-1.0.0.praison"
with tarfile.open(bundle_path, "w:gz") as tar:
tar.add(bundle_dir / "manifest.json", arcname="manifest.json")
tar.add(bundle_dir / "recipe.yaml", arcname="recipe.yaml")
return bundle_path
def test_health_endpoint(self, server):
"""Test GET /healthz."""
status, headers, body = server.handle_request(
method="GET",
path="/healthz",
headers={},
body=b"",
)
assert status == 200
data = json.loads(body)
assert data["ok"] is True
assert data["status"] == "healthy"
def test_list_recipes_empty(self, server):
"""Test GET /v1/recipes with empty registry."""
status, headers, body = server.handle_request(
method="GET",
path="/v1/recipes",
headers={},
body=b"",
)
assert status == 200
data = json.loads(body)
assert data["ok"] is True
assert data["recipes"] == []
assert data["total"] == 0
def test_list_recipes_with_data(self, server, sample_bundle):
"""Test GET /v1/recipes with recipes."""
# First publish a recipe
server.registry.publish(sample_bundle)
status, headers, body = server.handle_request(
method="GET",
path="/v1/recipes",
headers={},
body=b"",
)
assert status == 200
data = json.loads(body)
assert data["total"] == 1
assert data["recipes"][0]["name"] == "test-recipe"
def test_get_recipe_info(self, server, sample_bundle):
"""Test GET /v1/recipes/{name}."""
server.registry.publish(sample_bundle)
status, headers, body = server.handle_request(
method="GET",
path="/v1/recipes/test-recipe",
headers={},
body=b"",
)
assert status == 200
data = json.loads(body)
assert data["name"] == "test-recipe"
assert "1.0.0" in data["versions"]
def test_get_recipe_not_found(self, server):
"""Test GET /v1/recipes/{name} for non-existent recipe."""
status, headers, body = server.handle_request(
method="GET",
path="/v1/recipes/nonexistent",
headers={},
body=b"",
)
assert status == 404
data = json.loads(body)
assert data["ok"] is False
def test_get_version_info(self, server, sample_bundle):
"""Test GET /v1/recipes/{name}/{version}."""
server.registry.publish(sample_bundle)
status, headers, body = server.handle_request(
method="GET",
path="/v1/recipes/test-recipe/1.0.0",
headers={},
body=b"",
)
assert status == 200
data = json.loads(body)
assert data["name"] == "test-recipe"
assert data["version"] == "1.0.0"
def test_download_bundle(self, server, sample_bundle):
"""Test GET /v1/recipes/{name}/{version}/download."""
server.registry.publish(sample_bundle)
status, headers, body = server.handle_request(
method="GET",
path="/v1/recipes/test-recipe/1.0.0/download",
headers={},
body=b"",
)
assert status == 200
assert headers["Content-Type"] == "application/gzip"
assert "X-Checksum-SHA256" in headers
assert len(body) > 0
def test_download_with_etag(self, server, sample_bundle):
"""Test ETag caching for downloads."""
server.registry.publish(sample_bundle)
# First request
status, headers, body = server.handle_request(
method="GET",
path="/v1/recipes/test-recipe/1.0.0/download",
headers={},
body=b"",
)
assert status == 200
etag = headers.get("ETag")
assert etag is not None
# Second request with If-None-Match
status, headers, body = server.handle_request(
method="GET",
path="/v1/recipes/test-recipe/1.0.0/download",
headers={"If-None-Match": etag},
body=b"",
)
assert status == 304 # Not Modified
def test_search_recipes(self, server, sample_bundle):
"""Test GET /v1/search."""
server.registry.publish(sample_bundle)
status, headers, body = server.handle_request(
method="GET",
path="/v1/search",
headers={},
body=b"",
query_string="q=test",
)
assert status == 200
data = json.loads(body)
assert data["ok"] is True
assert len(data["results"]) == 1
def test_search_no_query(self, server):
"""Test GET /v1/search without query."""
status, headers, body = server.handle_request(
method="GET",
path="/v1/search",
headers={},
body=b"",
)
assert status == 400
data = json.loads(body)
assert data["ok"] is False
def test_publish_requires_auth(self, registry_path, sample_bundle):
"""Test POST /v1/recipes/{name}/{version} requires auth when token set."""
server = RegistryServer(registry_path=registry_path, token="secret")
status, headers, body = server.handle_request(
method="POST",
path="/v1/recipes/test-recipe/1.0.0",
headers={"Content-Type": "multipart/form-data; boundary=----test"},
body=b"",
)
assert status == 401
def test_publish_with_auth(self, registry_path, sample_bundle):
"""Test POST /v1/recipes/{name}/{version} with valid auth."""
server = RegistryServer(registry_path=registry_path, token="secret")
# Build multipart body
boundary = "----test"
bundle_content = sample_bundle.read_bytes()
body_parts = [
f"--{boundary}".encode(),
b'Content-Disposition: form-data; name="force"',
b"",
b"false",
f"--{boundary}".encode(),
f'Content-Disposition: form-data; name="bundle"; filename="test.praison"'.encode(),
b"Content-Type: application/gzip",
b"",
bundle_content,
f"--{boundary}--".encode(),
b"",
]
body = b"\r\n".join(body_parts)
status, headers, body_response = server.handle_request(
method="POST",
path="/v1/recipes/test-recipe/1.0.0",
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Authorization": "Bearer secret",
},
body=body,
)
assert status == 201
data = json.loads(body_response)
assert data["ok"] is True
assert data["name"] == "test-recipe"
def test_delete_requires_auth(self, registry_path, sample_bundle):
"""Test DELETE /v1/recipes/{name}/{version} requires auth."""
server = RegistryServer(registry_path=registry_path, token="secret")
server.registry.publish(sample_bundle)
status, headers, body = server.handle_request(
method="DELETE",
path="/v1/recipes/test-recipe/1.0.0",
headers={},
body=b"",
)
assert status == 401
def test_delete_with_auth(self, registry_path, sample_bundle):
"""Test DELETE /v1/recipes/{name}/{version} with valid auth."""
server = RegistryServer(registry_path=registry_path, token="secret")
server.registry.publish(sample_bundle)
status, headers, body = server.handle_request(
method="DELETE",
path="/v1/recipes/test-recipe/1.0.0",
headers={"Authorization": "Bearer secret"},
body=b"",
)
assert status == 200
data = json.loads(body)
assert data["ok"] is True
def test_read_only_mode(self, registry_path, sample_bundle):
"""Test read-only mode blocks writes."""
server = RegistryServer(registry_path=registry_path, read_only=True)
status, headers, body = server.handle_request(
method="POST",
path="/v1/recipes/test-recipe/1.0.0",
headers={"Content-Type": "multipart/form-data; boundary=----test"},
body=b"",
)
assert status == 403
data = json.loads(body)
assert "read-only" in data["error"].lower()
def test_not_found_route(self, server):
"""Test 404 for unknown routes."""
status, headers, body = server.handle_request(
method="GET",
path="/unknown",
headers={},
body=b"",
)
assert status == 404
class TestWSGIApp:
"""Test WSGI application."""
@pytest.fixture
def app(self, tmp_path):
"""Create WSGI app."""
return create_wsgi_app(registry_path=tmp_path / "registry")
def test_wsgi_health(self, app):
"""Test WSGI app health endpoint."""
environ = {
"REQUEST_METHOD": "GET",
"PATH_INFO": "/healthz",
"QUERY_STRING": "",
"CONTENT_LENGTH": "0",
"wsgi.input": MockInput(b""),
}
response_started = []
def start_response(status, headers):
response_started.append((status, headers))
result = list(app(environ, start_response))
assert len(response_started) == 1
assert "200" in response_started[0][0]
body = b"".join(result)
data = json.loads(body)
assert data["ok"] is True
class MockInput:
"""Mock WSGI input stream."""
def __init__(self, data):
self.data = data
self.pos = 0
def read(self, size=-1):
if size < 0:
result = self.data[self.pos:]
self.pos = len(self.data)
else:
result = self.data[self.pos:self.pos + size]
self.pos += size
return result
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""
Live Smoke Tests for PraisonAI Recipe System
These tests require real API keys and should NOT be run in CI by default.
Guard with: RUN_LIVE_TESTS=1
Usage:
RUN_LIVE_TESTS=1 OPENAI_API_KEY=... python -m pytest tests/smoke_test_live.py -v
Or run directly:
RUN_LIVE_TESTS=1 OPENAI_API_KEY=... python tests/smoke_test_live.py
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
# Skip all tests if RUN_LIVE_TESTS is not set
pytestmark = pytest.mark.skipif(
os.environ.get("RUN_LIVE_TESTS") != "1",
reason="Live tests require RUN_LIVE_TESTS=1"
)
def require_openai_key():
"""Check that OPENAI_API_KEY is set."""
if not os.environ.get("OPENAI_API_KEY"):
pytest.skip("OPENAI_API_KEY required for live tests")
class TestLiveRecipeExecution:
"""Live tests for recipe execution with real API keys."""
@pytest.fixture
def simple_recipe(self, tmp_path):
"""Create a simple recipe that uses OpenAI."""
recipe_dir = tmp_path / "simple-llm-test"
recipe_dir.mkdir()
template = """
name: simple-llm-test
version: "1.0.0"
description: Simple LLM test recipe
requires:
env:
- OPENAI_API_KEY
config:
input:
prompt:
type: string
required: true
description: Prompt to send to LLM
agents:
- name: assistant
role: Assistant
goal: Answer the user's question
backstory: You are a helpful assistant.
llm:
model: gpt-4o-mini
tools: []
"""
with open(recipe_dir / "TEMPLATE.yaml", "w") as f:
f.write(template.strip())
return recipe_dir
def test_python_api_execution(self, simple_recipe):
"""Test recipe execution via Python API."""
require_openai_key()
# This would require the recipe to be discoverable
# For now, test that the API is callable
from praisonai import recipe
# List recipes (should work without API key)
recipes = recipe.list_recipes()
assert isinstance(recipes, list)
# Validate a recipe (dry run)
# Note: This tests the API structure, not actual LLM execution
result = recipe.validate("nonexistent-recipe")
assert result is not None
assert hasattr(result, "valid")
def test_cli_list_command(self):
"""Test CLI list command."""
result = subprocess.run(
[sys.executable, "-m", "praisonai.cli.main", "recipe", "list", "--json"],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0
# Should output valid JSON
try:
data = json.loads(result.stdout)
assert "recipes" in data or isinstance(data, list)
except json.JSONDecodeError:
# May output non-JSON if no recipes found
pass
def test_cli_help_command(self):
"""Test CLI help command."""
result = subprocess.run(
[sys.executable, "-m", "praisonai.cli.main", "recipe", "help"],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0
assert "praisonai recipe" in result.stdout.lower() or "praisonai recipe" in result.stdout
class TestLiveRegistryWorkflow:
"""Live tests for registry publish/pull workflow."""
def test_local_registry_workflow(self, tmp_path):
"""Test full publish/pull workflow with local registry."""
import tarfile
import io
# Create a test recipe bundle
recipe_dir = tmp_path / "test-recipe"
recipe_dir.mkdir()
template = """
name: test-registry-recipe
version: "1.0.0"
description: Test recipe for registry
"""
with open(recipe_dir / "TEMPLATE.yaml", "w") as f:
f.write(template.strip())
# Create bundle
bundle_path = tmp_path / "test-registry-recipe-1.0.0.praison"
with tarfile.open(bundle_path, "w:gz") as tar:
manifest = {
"name": "test-registry-recipe",
"version": "1.0.0",
"description": "Test recipe for registry",
"files": [],
}
manifest_bytes = json.dumps(manifest, indent=2).encode()
manifest_info = tarfile.TarInfo(name="manifest.json")
manifest_info.size = len(manifest_bytes)
tar.addfile(manifest_info, io.BytesIO(manifest_bytes))
tar.add(recipe_dir / "TEMPLATE.yaml", arcname="TEMPLATE.yaml")
# Create local registry
registry_path = tmp_path / "registry"
from praisonai.recipe.registry import LocalRegistry
registry = LocalRegistry(registry_path)
# Publish
result = registry.publish(bundle_path)
assert result["name"] == "test-registry-recipe"
assert result["version"] == "1.0.0"
# List
recipes = registry.list_recipes()
assert len(recipes) == 1
assert recipes[0]["name"] == "test-registry-recipe"
# Search
results = registry.search("test")
assert len(results) == 1
# Pull
output_dir = tmp_path / "pulled"
registry.pull("test-registry-recipe", output_dir=output_dir)
assert (output_dir / "test-registry-recipe").exists()
# Verify pulled content
assert (output_dir / "test-registry-recipe" / "TEMPLATE.yaml").exists()
class TestLiveRunHistory:
"""Live tests for run history storage."""
def test_run_history_workflow(self, tmp_path):
"""Test run history store/list/export workflow."""
from praisonai.recipe.history import RunHistory
from praisonai.recipe.models import RecipeResult, RecipeStatus
history = RunHistory(tmp_path / "runs")
# Store a run
result = RecipeResult(
run_id="live-test-run-001",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"message": "Live test completed"},
metrics={"duration_sec": 0.5},
trace={"session_id": "live-test-session"},
)
run_id = history.store(result, input_data={"test": True})
assert run_id == "live-test-run-001"
# List runs
runs = history.list_runs()
assert len(runs) == 1
assert runs[0]["run_id"] == "live-test-run-001"
# Get run
run_data = history.get("live-test-run-001")
assert run_data["recipe"] == "test-recipe"
assert run_data["input"]["test"] is True
# Export
export_path = history.export("live-test-run-001", tmp_path / "export.json")
assert export_path.exists()
with open(export_path) as f:
export_data = json.load(f)
assert export_data["format"] == "praison-run-export"
class TestLiveSecurity:
"""Live tests for security features."""
def test_sbom_generation(self, tmp_path):
"""Test SBOM generation."""
from praisonai.recipe.security import generate_sbom
# Create a recipe with dependencies
recipe_dir = tmp_path / "sbom-test"
recipe_dir.mkdir()
import yaml
template = {
"name": "sbom-test",
"version": "1.0.0",
"requires": {"tools": ["web_search"]},
}
with open(recipe_dir / "TEMPLATE.yaml", "w") as f:
yaml.dump(template, f)
# Generate SBOM
sbom = generate_sbom(recipe_dir, format="cyclonedx")
assert sbom["bomFormat"] == "CycloneDX"
assert len(sbom["components"]) > 0
def test_pii_redaction(self, tmp_path):
"""Test PII redaction."""
from praisonai.recipe.security import redact_pii, detect_pii
data = {
"email": "[email protected]",
"phone": "555-123-4567",
"message": "Contact me at [email protected]",
}
# Detect
detections = detect_pii(data)
assert len(detections) >= 2
# Redact
policy = {"pii": {"mode": "redact", "fields": ["email", "phone"]}}
redacted = redact_pii(data, policy)
assert "[REDACTED:email]" in redacted["email"]
assert "[REDACTED:phone]" in redacted["phone"]
class TestLivePolicy:
"""Live tests for policy packs."""
def test_policy_workflow(self, tmp_path):
"""Test policy pack workflow."""
from praisonai.recipe.policy import PolicyPack, get_default_policy, PolicyDeniedError
# Get default policy
policy = get_default_policy("dev")
assert policy.name == "default-dev"
# Check tool permissions
assert policy.check_tool("web.search") is True
with pytest.raises(PolicyDeniedError):
policy.check_tool("shell.exec")
# Save and load policy
policy_file = tmp_path / "test-policy.yaml"
policy.save(policy_file)
loaded = PolicyPack.load(policy_file)
assert loaded.name == policy.name
def run_smoke_tests():
"""Run smoke tests directly (not via pytest)."""
print("=" * 60)
print("PraisonAI Live Smoke Tests")
print("=" * 60)
if os.environ.get("RUN_LIVE_TESTS") != "1":
print("\nSkipping: Set RUN_LIVE_TESTS=1 to run")
return 0
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
tests = [
("Registry Workflow", TestLiveRegistryWorkflow().test_local_registry_workflow),
("Run History", TestLiveRunHistory().test_run_history_workflow),
("SBOM Generation", TestLiveSecurity().test_sbom_generation),
("PII Redaction", TestLiveSecurity().test_pii_redaction),
("Policy Workflow", TestLivePolicy().test_policy_workflow),
]
passed = 0
failed = 0
for name, test_func in tests:
try:
print(f"\n[TEST] {name}...", end=" ")
test_func(tmp_path)
print("✓ PASSED")
passed += 1
except Exception as e:
print(f"✗ FAILED: {e}")
failed += 1
print("\n" + "=" * 60)
print(f"Results: {passed} passed, {failed} failed")
print("=" * 60)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(run_smoke_tests())
+568
View File
@@ -0,0 +1,568 @@
"""
Tests for Recipe Registry, History, Security, and Policy modules.
"""
import json
import tarfile
import pytest
# ============================================================================
# Registry Tests
# ============================================================================
class TestLocalRegistry:
"""Tests for LocalRegistry."""
@pytest.fixture
def temp_registry(self, tmp_path):
"""Create a temporary registry."""
from praisonai.recipe.registry import LocalRegistry
return LocalRegistry(tmp_path / "registry")
@pytest.fixture
def sample_bundle(self, tmp_path):
"""Create a sample bundle for testing."""
bundle_path = tmp_path / "test-recipe-1.0.0.praison"
with tarfile.open(bundle_path, "w:gz") as tar:
# Create manifest
manifest = {
"name": "test-recipe",
"version": "1.0.0",
"description": "Test recipe",
"tags": ["test", "example"],
"files": [],
}
import io
manifest_bytes = json.dumps(manifest, indent=2).encode()
manifest_info = tarfile.TarInfo(name="manifest.json")
manifest_info.size = len(manifest_bytes)
tar.addfile(manifest_info, io.BytesIO(manifest_bytes))
# Add a dummy file
content = b"# Test Recipe\nprint('hello')"
file_info = tarfile.TarInfo(name="main.py")
file_info.size = len(content)
tar.addfile(file_info, io.BytesIO(content))
return bundle_path
def test_publish_and_pull(self, temp_registry, sample_bundle, tmp_path):
"""Test publishing and pulling a recipe."""
# Publish
result = temp_registry.publish(sample_bundle)
assert result["name"] == "test-recipe"
assert result["version"] == "1.0.0"
assert "checksum" in result
# Pull
output_dir = tmp_path / "pulled"
pull_result = temp_registry.pull("test-recipe", output_dir=output_dir)
assert pull_result["name"] == "test-recipe"
assert pull_result["version"] == "1.0.0"
assert (output_dir / "test-recipe").exists()
def test_list_recipes(self, temp_registry, sample_bundle):
"""Test listing recipes."""
temp_registry.publish(sample_bundle)
recipes = temp_registry.list_recipes()
assert len(recipes) == 1
assert recipes[0]["name"] == "test-recipe"
def test_search_recipes(self, temp_registry, sample_bundle):
"""Test searching recipes."""
temp_registry.publish(sample_bundle)
# Search by name
results = temp_registry.search("test")
assert len(results) == 1
# Search by tag
results = temp_registry.search("example")
assert len(results) == 1
# Search with no match
results = temp_registry.search("nonexistent")
assert len(results) == 0
def test_get_versions(self, temp_registry, sample_bundle, tmp_path):
"""Test getting versions."""
temp_registry.publish(sample_bundle)
versions = temp_registry.get_versions("test-recipe")
assert "1.0.0" in versions
def test_publish_duplicate_fails(self, temp_registry, sample_bundle):
"""Test that publishing duplicate version fails without force."""
temp_registry.publish(sample_bundle)
from praisonai.recipe.registry import RecipeExistsError
with pytest.raises(RecipeExistsError):
temp_registry.publish(sample_bundle)
def test_publish_force_overwrites(self, temp_registry, sample_bundle):
"""Test that force=True overwrites existing version."""
temp_registry.publish(sample_bundle)
result = temp_registry.publish(sample_bundle, force=True)
assert result["name"] == "test-recipe"
def test_delete_recipe(self, temp_registry, sample_bundle):
"""Test deleting a recipe."""
temp_registry.publish(sample_bundle)
temp_registry.delete("test-recipe")
recipes = temp_registry.list_recipes()
assert len(recipes) == 0
# ============================================================================
# Run History Tests
# ============================================================================
class TestRunHistory:
"""Tests for RunHistory."""
@pytest.fixture
def temp_history(self, tmp_path):
"""Create a temporary history storage."""
from praisonai.recipe.history import RunHistory
return RunHistory(tmp_path / "runs")
@pytest.fixture
def sample_result(self):
"""Create a sample RecipeResult."""
from praisonai.recipe.models import RecipeResult, RecipeStatus
return RecipeResult(
run_id="run-test123",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"result": "hello"},
metrics={"duration_sec": 1.5},
trace={"session_id": "session-abc"},
)
def test_store_and_get(self, temp_history, sample_result):
"""Test storing and retrieving a run."""
run_id = temp_history.store(
result=sample_result,
input_data={"query": "test"},
)
assert run_id == "run-test123"
# Retrieve
run_data = temp_history.get(run_id)
assert run_data["recipe"] == "test-recipe"
assert run_data["status"] == "success"
assert run_data["input"]["query"] == "test"
def test_list_runs(self, temp_history, sample_result):
"""Test listing runs."""
temp_history.store(result=sample_result)
runs = temp_history.list_runs()
assert len(runs) == 1
assert runs[0]["run_id"] == "run-test123"
def test_list_runs_filter_by_recipe(self, temp_history, sample_result):
"""Test filtering runs by recipe."""
temp_history.store(result=sample_result)
runs = temp_history.list_runs(recipe="test-recipe")
assert len(runs) == 1
runs = temp_history.list_runs(recipe="other-recipe")
assert len(runs) == 0
def test_export_run(self, temp_history, sample_result, tmp_path):
"""Test exporting a run."""
temp_history.store(result=sample_result, input_data={"query": "test"})
export_path = temp_history.export("run-test123", tmp_path / "export.json")
assert export_path.exists()
with open(export_path) as f:
export_data = json.load(f)
assert export_data["format"] == "praison-run-export"
assert export_data["run"]["run_id"] == "run-test123"
def test_delete_run(self, temp_history, sample_result):
"""Test deleting a run."""
temp_history.store(result=sample_result)
temp_history.delete("run-test123")
runs = temp_history.list_runs()
assert len(runs) == 0
def test_get_stats(self, temp_history, sample_result):
"""Test getting storage stats."""
temp_history.store(result=sample_result)
stats = temp_history.get_stats()
assert stats["total_runs"] == 1
assert stats["total_size_bytes"] > 0
# ============================================================================
# Security Tests
# ============================================================================
class TestSBOM:
"""Tests for SBOM generation."""
@pytest.fixture
def sample_recipe(self, tmp_path):
"""Create a sample recipe directory."""
recipe_dir = tmp_path / "test-recipe"
recipe_dir.mkdir()
# Create TEMPLATE.yaml
template = {
"name": "test-recipe",
"version": "1.0.0",
"requires": {
"tools": ["web_search"],
"external": ["ffmpeg"],
},
}
import yaml
with open(recipe_dir / "TEMPLATE.yaml", "w") as f:
yaml.dump(template, f)
# Create lock directory with requirements
lock_dir = recipe_dir / "lock"
lock_dir.mkdir()
with open(lock_dir / "requirements.lock", "w") as f:
f.write("openai==1.0.0\n")
f.write("requests==2.31.0\n")
return recipe_dir
def test_generate_cyclonedx_sbom(self, sample_recipe):
"""Test generating CycloneDX SBOM."""
from praisonai.recipe.security import generate_sbom
sbom = generate_sbom(sample_recipe, format="cyclonedx")
assert sbom["bomFormat"] == "CycloneDX"
assert sbom["specVersion"] == "1.4"
assert len(sbom["components"]) > 0
def test_generate_spdx_sbom(self, sample_recipe):
"""Test generating SPDX SBOM."""
from praisonai.recipe.security import generate_sbom
sbom = generate_sbom(sample_recipe, format="spdx")
assert sbom["spdxVersion"] == "SPDX-2.3"
assert len(sbom["packages"]) > 0
class TestLockfileValidation:
"""Tests for lockfile validation."""
def test_validate_with_lockfile(self, tmp_path):
"""Test validation with lockfile present."""
from praisonai.recipe.security import validate_lockfile
recipe_dir = tmp_path / "recipe"
recipe_dir.mkdir()
lock_dir = recipe_dir / "lock"
lock_dir.mkdir()
with open(lock_dir / "requirements.lock", "w") as f:
f.write("openai==1.0.0\n")
result = validate_lockfile(recipe_dir)
assert result["valid"] is True
assert result["lockfile_type"] == "pip"
def test_validate_without_lockfile_strict(self, tmp_path):
"""Test strict validation without lockfile."""
from praisonai.recipe.security import validate_lockfile
recipe_dir = tmp_path / "recipe"
recipe_dir.mkdir()
result = validate_lockfile(recipe_dir, strict=True)
assert result["valid"] is False
assert "No lockfile found" in result["errors"][0]
class TestAudit:
"""Tests for dependency auditing."""
def test_audit_dependencies(self, tmp_path):
"""Test auditing dependencies."""
from praisonai.recipe.security import audit_dependencies
recipe_dir = tmp_path / "recipe"
recipe_dir.mkdir()
report = audit_dependencies(recipe_dir)
assert "audited_at" in report
assert "warnings" in report
class TestPIIRedaction:
"""Tests for PII redaction."""
def test_redact_email(self):
"""Test redacting email addresses."""
from praisonai.recipe.security import redact_pii
data = {"message": "Contact me at [email protected]"}
policy = {"pii": {"mode": "redact", "fields": ["email"]}}
result = redact_pii(data, policy)
assert "[REDACTED:email]" in result["message"]
assert "[email protected]" not in result["message"]
def test_redact_phone(self):
"""Test redacting phone numbers."""
from praisonai.recipe.security import redact_pii
data = {"phone": "123-456-7890"}
policy = {"pii": {"mode": "redact", "fields": ["phone"]}}
result = redact_pii(data, policy)
assert "[REDACTED:phone]" in result["phone"]
def test_detect_pii(self):
"""Test detecting PII without redacting."""
from praisonai.recipe.security import detect_pii
data = {
"email": "[email protected]",
"nested": {"phone": "123-456-7890"},
}
detections = detect_pii(data)
assert len(detections) >= 2
assert any(d["type"] == "email" for d in detections)
assert any(d["type"] == "phone" for d in detections)
# ============================================================================
# Policy Tests
# ============================================================================
class TestPolicyPack:
"""Tests for PolicyPack."""
def test_default_policy(self):
"""Test default policy."""
from praisonai.recipe.policy import get_default_policy
policy = get_default_policy("dev")
assert policy.name == "default-dev"
assert "shell.exec" in policy.denied_tools
def test_check_tool_allowed(self):
"""Test checking allowed tool."""
from praisonai.recipe.policy import PolicyPack
policy = PolicyPack(config={
"tools": {"allow": ["web.search"], "deny": []},
})
assert policy.check_tool("web.search") is True
def test_check_tool_denied(self):
"""Test checking denied tool."""
from praisonai.recipe.policy import PolicyPack, PolicyDeniedError
policy = PolicyPack(config={
"tools": {"allow": [], "deny": ["shell.exec"]},
})
with pytest.raises(PolicyDeniedError):
policy.check_tool("shell.exec")
def test_load_policy_from_file(self, tmp_path):
"""Test loading policy from file."""
from praisonai.recipe.policy import PolicyPack
policy_file = tmp_path / "policy.yaml"
policy_file.write_text("""
name: test-policy
tools:
allow:
- web.search
deny:
- shell.exec
pii:
mode: redact
fields:
- email
""")
policy = PolicyPack.load(policy_file)
assert policy.name == "test-policy"
assert "web.search" in policy.allowed_tools
assert policy.pii_mode == "redact"
def test_save_policy(self, tmp_path):
"""Test saving policy to file."""
from praisonai.recipe.policy import PolicyPack
policy = PolicyPack(name="test", config={
"tools": {"allow": ["web.search"]},
})
output_path = tmp_path / "output.yaml"
policy.save(output_path)
assert output_path.exists()
# Reload and verify
loaded = PolicyPack.load(output_path)
assert "web.search" in loaded.allowed_tools
# ============================================================================
# CLI Tests
# ============================================================================
class TestRecipeCLI:
"""Tests for recipe CLI commands."""
def test_publish_help(self):
"""Test publish command exists."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
assert hasattr(handler, "cmd_publish")
def test_pull_help(self):
"""Test pull command exists."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
assert hasattr(handler, "cmd_pull")
def test_sbom_help(self):
"""Test sbom command exists."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
assert hasattr(handler, "cmd_sbom")
def test_audit_help(self):
"""Test audit command exists."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
assert hasattr(handler, "cmd_audit")
def test_sign_help(self):
"""Test sign command exists."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
assert hasattr(handler, "cmd_sign")
def test_verify_help(self):
"""Test verify command exists."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
assert hasattr(handler, "cmd_verify")
def test_runs_help(self):
"""Test runs command exists."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
assert hasattr(handler, "cmd_runs")
def test_policy_help(self):
"""Test policy command exists."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
assert hasattr(handler, "cmd_policy")
# ============================================================================
# Integration Tests
# ============================================================================
class TestRegistryIntegration:
"""Integration tests for registry workflow."""
def test_full_publish_pull_workflow(self, tmp_path):
"""Test complete publish and pull workflow."""
from praisonai.recipe.registry import LocalRegistry
# Create registry
registry = LocalRegistry(tmp_path / "registry")
# Create a recipe directory
recipe_dir = tmp_path / "my-recipe"
recipe_dir.mkdir()
import yaml
with open(recipe_dir / "TEMPLATE.yaml", "w") as f:
yaml.dump({
"name": "my-recipe",
"version": "1.0.0",
"description": "My test recipe",
}, f)
with open(recipe_dir / "main.py", "w") as f:
f.write("print('hello')")
# Pack the recipe
bundle_path = tmp_path / "my-recipe-1.0.0.praison"
with tarfile.open(bundle_path, "w:gz") as tar:
manifest = {
"name": "my-recipe",
"version": "1.0.0",
"description": "My test recipe",
"files": [],
}
import io
manifest_bytes = json.dumps(manifest, indent=2).encode()
manifest_info = tarfile.TarInfo(name="manifest.json")
manifest_info.size = len(manifest_bytes)
tar.addfile(manifest_info, io.BytesIO(manifest_bytes))
for f in recipe_dir.iterdir():
tar.add(f, arcname=f.name)
# Publish
pub_result = registry.publish(bundle_path)
assert pub_result["name"] == "my-recipe"
# List
recipes = registry.list_recipes()
assert len(recipes) == 1
# Pull
output_dir = tmp_path / "pulled"
registry.pull("my-recipe", output_dir=output_dir)
assert (output_dir / "my-recipe").exists()
+519
View File
@@ -0,0 +1,519 @@
"""
Tests for Recipe Serve Features - Rate Limiting, Size Limits, Metrics, Admin, OpenTelemetry
TDD tests for gap implementation:
- Rate limiting middleware
- Request size limits
- GET /metrics endpoint (Prometheus format)
- POST /admin/reload endpoint
- OpenTelemetry export (lazy import)
- --workers CLI flag
- OpenAPI export
"""
import json
import os
import sys
import time
from unittest.mock import MagicMock, patch
import pytest
# ============================================================================
# Rate Limiting Tests
# ============================================================================
class TestRateLimiting:
"""Tests for rate limiting middleware."""
def test_rate_limiter_creation(self):
"""Test rate limiter can be created with config."""
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=100)
assert limiter is not None
def test_rate_limiter_allows_requests_under_limit(self):
"""Test rate limiter allows requests under limit."""
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=10)
client_ip = "127.0.0.1"
# Should allow 10 requests
for _ in range(10):
allowed, _ = limiter.check(client_ip)
assert allowed is True
def test_rate_limiter_blocks_over_limit(self):
"""Test rate limiter blocks requests over limit."""
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=5)
client_ip = "127.0.0.1"
# Use up the limit
for _ in range(5):
limiter.check(client_ip)
# Next request should be blocked
allowed, retry_after = limiter.check(client_ip)
assert allowed is False
assert retry_after > 0
def test_rate_limiter_per_client(self):
"""Test rate limiter tracks per-client."""
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=2)
# Client 1 uses up limit
for _ in range(2):
limiter.check("client1")
# Client 2 should still be allowed
allowed, _ = limiter.check("client2")
assert allowed is True
def test_rate_limit_middleware_returns_429(self):
"""Test rate limit middleware returns 429 with proper error."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {
"rate_limit": 2, # 2 requests per minute
"rate_limit_exempt_paths": ["/health"],
}
app = create_app(config)
client = TestClient(app)
# First 2 requests should succeed
for _ in range(2):
response = client.get("/v1/recipes")
assert response.status_code in [200, 404] # OK or no recipes
# Third request should be rate limited
response = client.get("/v1/recipes")
assert response.status_code == 429
data = response.json()
assert "error" in data
assert data["error"]["code"] == "rate_limited"
assert "Retry-After" in response.headers
def test_rate_limit_exempts_health(self):
"""Test /health is exempt from rate limiting by default."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"rate_limit": 1}
app = create_app(config)
client = TestClient(app)
# Use up rate limit
client.get("/v1/recipes")
# Health should still work
response = client.get("/health")
assert response.status_code == 200
# ============================================================================
# Request Size Limit Tests
# ============================================================================
class TestRequestSizeLimits:
"""Tests for request size limits."""
def test_size_limit_allows_small_requests(self):
"""Test small requests are allowed."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"max_request_size": 1024 * 1024} # 1MB
app = create_app(config)
client = TestClient(app)
# Small request should work
response = client.post(
"/v1/recipes/run",
json={"recipe": "test", "input": "small"}
)
# May return 404 if recipe doesn't exist, but not 413
assert response.status_code != 413
def test_size_limit_blocks_large_requests(self):
"""Test large requests are blocked with 413."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"max_request_size": 100} # 100 bytes
app = create_app(config)
client = TestClient(app)
# Large request should be blocked
large_data = {"recipe": "test", "input": "x" * 200}
response = client.post(
"/v1/recipes/run",
json=large_data
)
assert response.status_code == 413
data = response.json()
assert "error" in data
assert data["error"]["code"] == "request_too_large"
def test_default_size_limit_is_10mb(self):
"""Test default size limit is 10MB."""
from praisonai.recipe.serve import DEFAULT_MAX_REQUEST_SIZE
assert DEFAULT_MAX_REQUEST_SIZE == 10 * 1024 * 1024
# ============================================================================
# Metrics Endpoint Tests
# ============================================================================
class TestMetricsEndpoint:
"""Tests for /metrics endpoint."""
def test_metrics_endpoint_exists_when_enabled(self):
"""Test /metrics endpoint exists when enabled."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_metrics": True}
app = create_app(config)
client = TestClient(app)
response = client.get("/metrics")
assert response.status_code == 200
def test_metrics_endpoint_disabled_by_default_on_public(self):
"""Test /metrics is disabled by default on non-localhost."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
# Simulate non-localhost config
config = {"host": "0.0.0.0", "enable_metrics": False}
app = create_app(config)
client = TestClient(app)
response = client.get("/metrics")
assert response.status_code == 404
def test_metrics_prometheus_format(self):
"""Test metrics are in Prometheus exposition format."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_metrics": True}
app = create_app(config)
client = TestClient(app)
# Make some requests to generate metrics
client.get("/health")
client.get("/v1/recipes")
response = client.get("/metrics")
assert response.status_code == 200
content = response.text
# Check for Prometheus format markers
assert "praisonai_http_requests_total" in content
assert "praisonai_http_request_duration_seconds" in content
def test_metrics_include_required_labels(self):
"""Test metrics include path, method, status labels."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_metrics": True}
app = create_app(config)
client = TestClient(app)
client.get("/health")
response = client.get("/metrics")
content = response.text
# Should have labels
assert 'path="/health"' in content or 'path="health"' in content
assert 'method="GET"' in content
assert 'status="200"' in content
# ============================================================================
# Admin Reload Endpoint Tests
# ============================================================================
class TestAdminReload:
"""Tests for /admin/reload endpoint."""
def test_admin_reload_requires_auth(self):
"""Test /admin/reload requires authentication."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_admin": True, "auth": "api-key", "api_key": "secret"}
app = create_app(config)
client = TestClient(app)
# Without auth should fail
response = client.post("/admin/reload")
assert response.status_code == 401
def test_admin_reload_with_auth(self):
"""Test /admin/reload works with authentication."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_admin": True, "auth": "api-key", "api_key": "secret"}
app = create_app(config)
client = TestClient(app)
response = client.post(
"/admin/reload",
headers={"X-API-Key": "secret"}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "reloaded"
def test_admin_reload_disabled_by_default(self):
"""Test /admin/reload is disabled by default."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {} # No enable_admin
app = create_app(config)
client = TestClient(app)
response = client.post("/admin/reload")
assert response.status_code == 404
# ============================================================================
# OpenAPI Export Tests
# ============================================================================
class TestOpenAPIExport:
"""Tests for OpenAPI spec export."""
def test_openapi_endpoint_exists(self):
"""Test /openapi.json endpoint exists."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {}
app = create_app(config)
client = TestClient(app)
response = client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert "openapi" in data
assert "paths" in data
def test_openapi_includes_all_endpoints(self):
"""Test OpenAPI spec includes all endpoints."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_metrics": True, "enable_admin": True}
app = create_app(config)
client = TestClient(app)
response = client.get("/openapi.json")
data = response.json()
paths = data["paths"]
assert "/health" in paths
assert "/v1/recipes" in paths
assert "/v1/recipes/run" in paths
assert "/metrics" in paths
assert "/admin/reload" in paths
# ============================================================================
# Workers CLI Flag Tests
# ============================================================================
class TestWorkersCLI:
"""Tests for --workers CLI flag."""
def test_serve_command_accepts_workers_flag(self):
"""Test serve command accepts --workers flag."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
# Parse args with workers flag
spec = {
"workers": {"default": "1"},
"port": {"default": "8765"},
"host": {"default": "127.0.0.1"},
}
args = ["--workers", "4", "--port", "8000"]
parsed = handler._parse_args(args, spec)
assert parsed["workers"] == "4"
def test_workers_passed_to_uvicorn(self):
"""Test workers count is passed to uvicorn."""
# Test that serve function accepts workers parameter
from praisonai.recipe.serve import serve
import inspect
sig = inspect.signature(serve)
params = list(sig.parameters.keys())
assert "workers" in params, "serve() should accept workers parameter"
# Check default value
workers_param = sig.parameters["workers"]
assert workers_param.default == 1, "workers should default to 1"
# ============================================================================
# OpenTelemetry Lazy Import Tests
# ============================================================================
class TestOpenTelemetryLazyImport:
"""Tests for OpenTelemetry lazy import behavior."""
def test_no_crash_without_otel_installed(self):
"""Test server works without OpenTelemetry installed."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
# Should not crash even without otel
config = {"trace_exporter": "none"}
app = create_app(config)
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
def test_otel_config_options(self):
"""Test OpenTelemetry config options are recognized."""
from praisonai.recipe.serve import load_config
# These should be valid config keys
config = {
"trace_exporter": "otlp",
"otlp_endpoint": "http://localhost:4317",
"service_name": "praisonai-recipe",
}
# Should not raise
assert config["trace_exporter"] == "otlp"
# ============================================================================
# Agent-Recipes Integration Tests
# ============================================================================
class TestAgentRecipesIntegration:
"""Tests for Agent-Recipes template integration."""
def test_template_discovery_precedence(self):
"""Test template discovery follows precedence order."""
from praisonai.recipe.core import get_template_search_paths
paths = get_template_search_paths()
# Should include standard paths
assert any("praison" in str(p).lower() for p in paths)
def test_agent_recipes_path_included(self):
"""Test Agent-Recipes path is in search paths."""
from praisonai.recipe.core import get_template_search_paths
paths = get_template_search_paths()
path_strs = [str(p) for p in paths]
# Should include Agent-Recipes or similar
# The exact path depends on installation
assert len(paths) > 0
# ============================================================================
# Structured Error Tests
# ============================================================================
class TestStructuredErrors:
"""Tests for consistent structured error responses."""
def test_401_error_format(self):
"""Test 401 errors have correct format."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"auth": "api-key", "api_key": "secret"}
app = create_app(config)
client = TestClient(app)
response = client.get("/v1/recipes")
assert response.status_code == 401
data = response.json()
assert "error" in data
assert "code" in data["error"]
assert "message" in data["error"]
def test_429_error_format(self):
"""Test 429 errors have correct format."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"rate_limit": 1}
app = create_app(config)
client = TestClient(app)
# Use up limit
client.get("/v1/recipes")
response = client.get("/v1/recipes")
if response.status_code == 429:
data = response.json()
assert "error" in data
assert data["error"]["code"] == "rate_limited"
def test_413_error_format(self):
"""Test 413 errors have correct format."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"max_request_size": 10} # Very small
app = create_app(config)
client = TestClient(app)
response = client.post(
"/v1/recipes/run",
json={"recipe": "test", "input": "x" * 100}
)
if response.status_code == 413:
data = response.json()
assert "error" in data
assert data["error"]["code"] == "request_too_large"
@@ -0,0 +1,284 @@
"""
Unit tests for Recipe Runtime Configuration.
Tests:
- RuntimeConfig parsing from dict
- Environment variable expansion
- Default values
- Schema validation
"""
import os
from unittest.mock import patch
class TestRuntimeConfig:
"""Tests for RuntimeConfig dataclass."""
def test_empty_runtime_config(self):
"""Test creating RuntimeConfig with no data."""
from praisonai.recipe.runtime import RuntimeConfig
config = RuntimeConfig()
assert config.background.enabled is False
assert config.job.enabled is False
assert config.schedule.enabled is False
def test_runtime_config_from_dict(self):
"""Test creating RuntimeConfig from dictionary."""
from praisonai.recipe.runtime import RuntimeConfig
data = {
'background': {
'enabled': True,
'max_concurrent': 10,
},
'job': {
'enabled': True,
'timeout_sec': 600,
'webhook_url': 'https://example.com/webhook',
},
'schedule': {
'enabled': True,
'interval': 'daily',
'max_retries': 5,
},
}
config = RuntimeConfig.from_dict(data)
assert config.background.enabled is True
assert config.background.max_concurrent == 10
assert config.job.enabled is True
assert config.job.timeout_sec == 600
assert config.job.webhook_url == 'https://example.com/webhook'
assert config.schedule.enabled is True
assert config.schedule.interval == 'daily'
assert config.schedule.max_retries == 5
def test_runtime_config_defaults(self):
"""Test that defaults are applied correctly."""
from praisonai.recipe.runtime import (
RuntimeConfig,
DEFAULT_TIMEOUT_SEC,
DEFAULT_MAX_COST_USD,
DEFAULT_MAX_RETRIES,
DEFAULT_MAX_CONCURRENT,
)
config = RuntimeConfig.from_dict({})
assert config.background.max_concurrent == DEFAULT_MAX_CONCURRENT
assert config.job.timeout_sec == DEFAULT_TIMEOUT_SEC
assert config.schedule.max_retries == DEFAULT_MAX_RETRIES
assert config.schedule.max_cost_usd == DEFAULT_MAX_COST_USD
def test_runtime_config_to_dict(self):
"""Test converting RuntimeConfig to dictionary."""
from praisonai.recipe.runtime import RuntimeConfig
config = RuntimeConfig()
data = config.to_dict()
assert 'background' in data
assert 'job' in data
assert 'schedule' in data
assert data['background']['enabled'] is False
assert data['job']['enabled'] is False
assert data['schedule']['enabled'] is False
class TestEnvVarExpansion:
"""Tests for environment variable expansion."""
def test_expand_simple_env_var(self):
"""Test expanding a simple environment variable."""
from praisonai.recipe.runtime import expand_env_vars
with patch.dict(os.environ, {'MY_VAR': 'test_value'}):
result = expand_env_vars('${MY_VAR}')
assert result == 'test_value'
def test_expand_env_var_in_string(self):
"""Test expanding env var embedded in string."""
from praisonai.recipe.runtime import expand_env_vars
with patch.dict(os.environ, {'API_KEY': 'secret123'}):
result = expand_env_vars('Bearer ${API_KEY}')
assert result == 'Bearer secret123'
def test_expand_missing_env_var(self):
"""Test that missing env vars are kept as-is."""
from praisonai.recipe.runtime import expand_env_vars
result = expand_env_vars('${NONEXISTENT_VAR}')
assert result == '${NONEXISTENT_VAR}'
def test_expand_env_var_in_dict(self):
"""Test expanding env vars in nested dict."""
from praisonai.recipe.runtime import expand_env_vars
with patch.dict(os.environ, {'WEBHOOK': 'https://example.com'}):
data = {
'url': '${WEBHOOK}/callback',
'nested': {
'value': '${WEBHOOK}/nested'
}
}
result = expand_env_vars(data)
assert result['url'] == 'https://example.com/callback'
assert result['nested']['value'] == 'https://example.com/nested'
def test_expand_env_var_in_list(self):
"""Test expanding env vars in list."""
from praisonai.recipe.runtime import expand_env_vars
with patch.dict(os.environ, {'VAL': 'expanded'}):
data = ['${VAL}', 'static', '${VAL}_suffix']
result = expand_env_vars(data)
assert result == ['expanded', 'static', 'expanded_suffix']
def test_no_expansion_for_non_string(self):
"""Test that non-string values are returned as-is."""
from praisonai.recipe.runtime import expand_env_vars
assert expand_env_vars(123) == 123
assert expand_env_vars(True) is True
assert expand_env_vars(None) is None
class TestParseRuntimeConfig:
"""Tests for parse_runtime_config function."""
def test_parse_none(self):
"""Test parsing None returns default config."""
from praisonai.recipe.runtime import parse_runtime_config, RuntimeConfig
config = parse_runtime_config(None)
assert isinstance(config, RuntimeConfig)
assert config.background.enabled is False
def test_parse_with_env_expansion(self):
"""Test parsing with environment variable expansion."""
from praisonai.recipe.runtime import parse_runtime_config
with patch.dict(os.environ, {'WEBHOOK_URL': 'https://hooks.example.com'}):
data = {
'job': {
'enabled': True,
'webhook_url': '${WEBHOOK_URL}/notify',
}
}
config = parse_runtime_config(data, expand_env=True)
assert config.job.webhook_url == 'https://hooks.example.com/notify'
def test_parse_without_env_expansion(self):
"""Test parsing without environment variable expansion."""
from praisonai.recipe.runtime import parse_runtime_config
data = {
'job': {
'webhook_url': '${WEBHOOK_URL}',
}
}
config = parse_runtime_config(data, expand_env=False)
assert config.job.webhook_url == '${WEBHOOK_URL}'
class TestBackgroundRuntimeConfig:
"""Tests for BackgroundRuntimeConfig."""
def test_from_dict_with_all_fields(self):
"""Test creating from dict with all fields."""
from praisonai.recipe.runtime import BackgroundRuntimeConfig
data = {
'enabled': True,
'max_concurrent': 20,
'cleanup_delay_sec': 7200,
}
config = BackgroundRuntimeConfig.from_dict(data)
assert config.enabled is True
assert config.max_concurrent == 20
assert config.cleanup_delay_sec == 7200
def test_from_dict_partial(self):
"""Test creating from dict with partial fields."""
from praisonai.recipe.runtime import BackgroundRuntimeConfig, DEFAULT_MAX_CONCURRENT
data = {'enabled': True}
config = BackgroundRuntimeConfig.from_dict(data)
assert config.enabled is True
assert config.max_concurrent == DEFAULT_MAX_CONCURRENT
class TestJobRuntimeConfig:
"""Tests for JobRuntimeConfig."""
def test_from_dict_with_all_fields(self):
"""Test creating from dict with all fields."""
from praisonai.recipe.runtime import JobRuntimeConfig
data = {
'enabled': True,
'timeout_sec': 1800,
'webhook_url': 'https://example.com',
'idempotency_scope': 'global',
'events': ['completed'],
}
config = JobRuntimeConfig.from_dict(data)
assert config.enabled is True
assert config.timeout_sec == 1800
assert config.webhook_url == 'https://example.com'
assert config.idempotency_scope == 'global'
assert config.events == ['completed']
def test_default_events(self):
"""Test default events list."""
from praisonai.recipe.runtime import JobRuntimeConfig
config = JobRuntimeConfig.from_dict({})
assert config.events == ['completed', 'failed']
class TestScheduleRuntimeConfig:
"""Tests for ScheduleRuntimeConfig."""
def test_from_dict_with_all_fields(self):
"""Test creating from dict with all fields."""
from praisonai.recipe.runtime import ScheduleRuntimeConfig
data = {
'enabled': True,
'interval': '*/30m',
'max_retries': 5,
'run_immediately': True,
'timeout_sec': 600,
'max_cost_usd': 2.50,
}
config = ScheduleRuntimeConfig.from_dict(data)
assert config.enabled is True
assert config.interval == '*/30m'
assert config.max_retries == 5
assert config.run_immediately is True
assert config.timeout_sec == 600
assert config.max_cost_usd == 2.50
def test_default_interval(self):
"""Test default interval is hourly."""
from praisonai.recipe.runtime import ScheduleRuntimeConfig
config = ScheduleRuntimeConfig.from_dict({})
assert config.interval == 'hourly'
@@ -0,0 +1,470 @@
"""
Unit tests for unified endpoints module.
Tests discovery schema, provider adapters, registry, and CLI.
"""
import pytest
from unittest.mock import patch, MagicMock
class TestDiscoverySchema:
"""Tests for discovery schema."""
def test_discovery_document_creation(self):
"""Test creating a discovery document."""
from praisonai.endpoints.discovery import (
DiscoveryDocument,
create_discovery_document,
SCHEMA_VERSION,
)
doc = create_discovery_document(server_name="test-server")
assert doc.server_name == "test-server"
assert doc.schema_version == SCHEMA_VERSION
assert isinstance(doc.providers, list)
assert isinstance(doc.endpoints, list)
def test_endpoint_info_creation(self):
"""Test creating endpoint info."""
from praisonai.endpoints.discovery import EndpointInfo
endpoint = EndpointInfo(
name="test-endpoint",
description="Test endpoint",
provider_type="recipe",
tags=["test", "demo"],
)
assert endpoint.name == "test-endpoint"
assert endpoint.provider_type == "recipe"
assert "test" in endpoint.tags
def test_provider_info_creation(self):
"""Test creating provider info."""
from praisonai.endpoints.discovery import ProviderInfo
provider = ProviderInfo(
type="agents-api",
name="Agents API",
capabilities=["invoke", "health"],
)
assert provider.type == "agents-api"
assert "invoke" in provider.capabilities
def test_discovery_document_to_dict(self):
"""Test converting discovery document to dict."""
from praisonai.endpoints.discovery import (
DiscoveryDocument,
EndpointInfo,
ProviderInfo,
)
doc = DiscoveryDocument(server_name="test")
doc.add_provider(ProviderInfo(type="recipe", name="Recipe"))
doc.add_endpoint(EndpointInfo(name="test-ep", provider_type="recipe"))
data = doc.to_dict()
assert data["server_name"] == "test"
assert len(data["providers"]) == 1
assert len(data["endpoints"]) == 1
assert data["providers"][0]["type"] == "recipe"
def test_get_endpoints_by_type(self):
"""Test filtering endpoints by type."""
from praisonai.endpoints.discovery import DiscoveryDocument, EndpointInfo
doc = DiscoveryDocument()
doc.add_endpoint(EndpointInfo(name="ep1", provider_type="recipe"))
doc.add_endpoint(EndpointInfo(name="ep2", provider_type="agents-api"))
doc.add_endpoint(EndpointInfo(name="ep3", provider_type="recipe"))
recipe_eps = doc.get_endpoints_by_type("recipe")
assert len(recipe_eps) == 2
assert all(ep.provider_type == "recipe" for ep in recipe_eps)
def test_get_endpoint_by_name(self):
"""Test getting endpoint by name."""
from praisonai.endpoints.discovery import DiscoveryDocument, EndpointInfo
doc = DiscoveryDocument()
doc.add_endpoint(EndpointInfo(name="my-endpoint", description="Test"))
ep = doc.get_endpoint_by_name("my-endpoint")
assert ep is not None
assert ep.name == "my-endpoint"
missing = doc.get_endpoint_by_name("nonexistent")
assert missing is None
class TestProviderRegistry:
"""Tests for provider registry."""
def test_list_provider_types(self):
"""Test listing provider types."""
from praisonai.endpoints.registry import list_provider_types
types = list_provider_types()
assert "recipe" in types
assert "agents-api" in types
assert "mcp" in types
assert "a2a" in types
assert "a2u" in types
def test_get_provider(self):
"""Test getting a provider instance."""
from praisonai.endpoints.registry import get_provider
provider = get_provider("recipe", base_url="http://localhost:8000")
assert provider is not None
assert provider.provider_type == "recipe"
assert provider.base_url == "http://localhost:8000"
def test_get_unknown_provider(self):
"""Test getting unknown provider returns None."""
from praisonai.endpoints.registry import get_provider
provider = get_provider("unknown-type")
assert provider is None
def test_provider_registry_class(self):
"""Test ProviderRegistry class."""
from praisonai.endpoints.registry import ProviderRegistry
registry = ProviderRegistry()
assert "recipe" in registry.list_types()
provider = registry.get("agents-api")
assert provider is not None
class TestRecipeProvider:
"""Tests for recipe provider."""
def test_provider_info(self):
"""Test recipe provider info."""
from praisonai.endpoints.providers.recipe import RecipeProvider
provider = RecipeProvider()
info = provider.get_provider_info()
assert info.type == "recipe"
assert "invoke" in info.capabilities
@patch('praisonai.endpoints.providers.base.BaseProvider._make_request')
def test_list_endpoints(self, mock_request):
"""Test listing recipe endpoints."""
from praisonai.endpoints.providers.recipe import RecipeProvider
mock_request.return_value = {
"data": {
"recipes": [
{"name": "recipe1", "description": "Test 1"},
{"name": "recipe2", "description": "Test 2"},
]
}
}
provider = RecipeProvider()
endpoints = provider.list_endpoints()
assert len(endpoints) == 2
assert endpoints[0].name == "recipe1"
@patch('praisonai.endpoints.providers.base.BaseProvider._make_request')
def test_health_check(self, mock_request):
"""Test recipe health check."""
from praisonai.endpoints.providers.recipe import RecipeProvider
mock_request.return_value = {
"data": {"status": "healthy", "service": "recipe-runner"}
}
provider = RecipeProvider()
health = provider.health()
assert health.healthy is True
assert health.status == "healthy"
class TestAgentsAPIProvider:
"""Tests for agents-api provider."""
def test_provider_info(self):
"""Test agents-api provider info."""
from praisonai.endpoints.providers.agents_api import AgentsAPIProvider
provider = AgentsAPIProvider()
info = provider.get_provider_info()
assert info.type == "agents-api"
@patch('praisonai.endpoints.providers.base.BaseProvider._make_request')
def test_invoke(self, mock_request):
"""Test invoking agents-api endpoint."""
from praisonai.endpoints.providers.agents_api import AgentsAPIProvider
mock_request.return_value = {
"status": 200,
"data": {"response": "Hello!"}
}
provider = AgentsAPIProvider()
result = provider.invoke("test-agent", {"query": "Hi"})
assert result.ok is True
assert result.data == "Hello!"
class TestA2AProvider:
"""Tests for A2A provider."""
def test_provider_info(self):
"""Test A2A provider info."""
from praisonai.endpoints.providers.a2a import A2AProvider
provider = A2AProvider()
info = provider.get_provider_info()
assert info.type == "a2a"
assert "agent-card" in info.capabilities
class TestA2UProvider:
"""Tests for A2U provider."""
def test_provider_info(self):
"""Test A2U provider info."""
from praisonai.endpoints.providers.a2u import A2UProvider
provider = A2UProvider()
info = provider.get_provider_info()
assert info.type == "a2u"
assert "subscribe" in info.capabilities
class TestEndpointsCLI:
"""Tests for endpoints CLI."""
def test_handler_initialization(self):
"""Test CLI handler initialization."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
assert handler.base_url == "http://localhost:8765"
def test_help_command(self):
"""Test help command."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
result = handler.handle(["help"])
assert result == 0
def test_types_command(self):
"""Test types command."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
result = handler.cmd_types(["--format", "json"])
assert result == 0
def test_parse_args(self):
"""Test argument parsing."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
spec = {
"name": {"positional": True, "default": ""},
"url": {"default": None},
"json": {"flag": True, "default": False},
}
parsed = handler._parse_args(["my-endpoint", "--url", "http://test", "--json"], spec)
assert parsed["name"] == "my-endpoint"
assert parsed["url"] == "http://test"
assert parsed["json"] is True
class TestServeCLI:
"""Tests for serve CLI."""
def test_handler_initialization(self):
"""Test serve handler initialization."""
from praisonai.cli.features.serve import ServeHandler
handler = ServeHandler()
assert handler.DEFAULT_PORT == 8765
def test_help_command(self):
"""Test help command."""
from praisonai.cli.features.serve import ServeHandler
handler = ServeHandler()
result = handler.handle(["help"])
assert result == 0
def test_parse_args(self):
"""Test argument parsing."""
from praisonai.cli.features.serve import ServeHandler
handler = ServeHandler()
spec = {
"host": {"default": "127.0.0.1"},
"port": {"default": 8765, "type": "int"},
"reload": {"flag": True, "default": False},
}
parsed = handler._parse_args(["--host", "0.0.0.0", "--port", "9000", "--reload"], spec)
assert parsed["host"] == "0.0.0.0"
assert parsed["port"] == 9000
assert parsed["reload"] is True
class TestA2UServer:
"""Tests for A2U server components."""
def test_event_creation(self):
"""Test A2U event creation."""
from praisonai.endpoints.a2u_server import A2UEvent
event = A2UEvent(event_type="test", data={"key": "value"})
assert event.event_type == "test"
assert event.data["key"] == "value"
assert event.event_id is not None
def test_event_to_sse(self):
"""Test converting event to SSE format."""
from praisonai.endpoints.a2u_server import A2UEvent
event = A2UEvent(event_type="message", data={"text": "hello"})
sse = event.to_sse()
assert "event: message" in sse
assert "data:" in sse
assert "hello" in sse
def test_subscription_creation(self):
"""Test A2U subscription creation."""
from praisonai.endpoints.a2u_server import A2USubscription
sub = A2USubscription(
subscription_id="sub-123",
stream_name="events",
filters=["agent.started", "agent.completed"],
)
assert sub.subscription_id == "sub-123"
assert sub.stream_name == "events"
assert len(sub.filters) == 2
def test_subscription_matches_event(self):
"""Test subscription event matching."""
from praisonai.endpoints.a2u_server import A2USubscription, A2UEvent
sub = A2USubscription(
subscription_id="sub-123",
stream_name="events",
filters=["agent.started"],
)
event1 = A2UEvent(event_type="agent.started", data={})
event2 = A2UEvent(event_type="agent.completed", data={})
assert sub.matches_event(event1) is True
assert sub.matches_event(event2) is False
def test_subscription_matches_all_without_filters(self):
"""Test subscription matches all events without filters."""
from praisonai.endpoints.a2u_server import A2USubscription, A2UEvent
sub = A2USubscription(
subscription_id="sub-123",
stream_name="events",
)
event = A2UEvent(event_type="any.event", data={})
assert sub.matches_event(event) is True
def test_event_bus_subscribe(self):
"""Test event bus subscription."""
from praisonai.endpoints.a2u_server import A2UEventBus
bus = A2UEventBus()
sub = bus.subscribe("events", ["agent.started"])
assert sub.subscription_id.startswith("sub-")
assert sub.stream_name == "events"
def test_event_bus_unsubscribe(self):
"""Test event bus unsubscription."""
from praisonai.endpoints.a2u_server import A2UEventBus
bus = A2UEventBus()
sub = bus.subscribe("events")
result = bus.unsubscribe(sub.subscription_id)
assert result is True
result = bus.unsubscribe("nonexistent")
assert result is False
def test_emit_helpers(self):
"""Test emit helper functions."""
from praisonai.endpoints.a2u_server import (
emit_agent_started,
emit_agent_thinking,
emit_agent_response,
emit_agent_completed,
emit_agent_error,
)
# These should not raise
emit_agent_started("agent-1", "TestAgent")
emit_agent_thinking("agent-1", "Processing...")
emit_agent_response("agent-1", "Hello!")
emit_agent_completed("agent-1", {"result": "done"})
emit_agent_error("agent-1", "Something went wrong")
class TestServerUtilities:
"""Tests for server utilities."""
def test_add_discovery_routes(self):
"""Test adding discovery routes to app."""
from praisonai.endpoints.server import add_discovery_routes
from praisonai.endpoints.discovery import create_discovery_document
# Mock FastAPI app
mock_app = MagicMock()
mock_app.add_api_route = MagicMock()
discovery = create_discovery_document()
add_discovery_routes(mock_app, discovery)
# Should have added routes
assert mock_app.add_api_route.called
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+364
View File
@@ -0,0 +1,364 @@
"""
Unit tests for recipe registry module.
Tests LocalRegistry, HttpRegistry, and server components.
"""
import json
import tarfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from praisonai.recipe.registry import (
LocalRegistry,
HttpRegistry,
RemoteRegistry,
get_registry,
RegistryError,
RecipeNotFoundError,
RecipeExistsError,
_calculate_checksum,
_get_timestamp,
_normalize_name,
_validate_name,
_validate_version,
_atomic_write,
_atomic_write_json,
DEFAULT_REGISTRY_PATH,
DEFAULT_REGISTRY_PORT,
)
class TestHelperFunctions:
"""Test helper functions."""
def test_get_timestamp(self):
"""Test timestamp generation."""
ts = _get_timestamp()
assert ts is not None
assert "T" in ts # ISO format
def test_calculate_checksum(self, tmp_path):
"""Test checksum calculation."""
test_file = tmp_path / "test.txt"
test_file.write_text("hello world")
checksum = _calculate_checksum(test_file)
assert len(checksum) == 64 # SHA256 hex
# Same content should give same checksum
test_file2 = tmp_path / "test2.txt"
test_file2.write_text("hello world")
assert _calculate_checksum(test_file2) == checksum
def test_normalize_name(self):
"""Test name normalization."""
assert _normalize_name("My-Recipe") == "my-recipe"
assert _normalize_name("my_recipe") == "my-recipe"
assert _normalize_name("My.Recipe") == "my-recipe"
assert _normalize_name("my--recipe") == "my-recipe"
def test_validate_name(self):
"""Test name validation."""
assert _validate_name("my-recipe") is True
assert _validate_name("my_recipe") is True
assert _validate_name("MyRecipe123") is True
assert _validate_name("") is False
assert _validate_name("-invalid") is False
assert _validate_name("a" * 200) is False # Too long
def test_validate_version(self):
"""Test version validation."""
assert _validate_version("1.0.0") is True
assert _validate_version("0.1.0") is True
assert _validate_version("1.0.0-alpha") is True
assert _validate_version("1.0.0.beta1") is True
assert _validate_version("") is False
assert _validate_version("1.0") is False # Missing patch
assert _validate_version("invalid") is False
def test_atomic_write(self, tmp_path):
"""Test atomic write."""
test_file = tmp_path / "test.txt"
_atomic_write(test_file, b"hello world")
assert test_file.exists()
assert test_file.read_bytes() == b"hello world"
def test_atomic_write_json(self, tmp_path):
"""Test atomic JSON write."""
test_file = tmp_path / "test.json"
data = {"key": "value", "number": 42}
_atomic_write_json(test_file, data)
assert test_file.exists()
loaded = json.loads(test_file.read_text())
assert loaded == data
class TestLocalRegistry:
"""Test LocalRegistry class."""
@pytest.fixture
def registry(self, tmp_path):
"""Create a temporary registry."""
return LocalRegistry(tmp_path / "registry")
@pytest.fixture
def sample_bundle(self, tmp_path):
"""Create a sample .praison bundle."""
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
manifest = {
"name": "test-recipe",
"version": "1.0.0",
"description": "A test recipe",
"tags": ["test", "sample"],
"author": "test",
"files": ["recipe.yaml"],
}
(bundle_dir / "manifest.json").write_text(json.dumps(manifest))
(bundle_dir / "recipe.yaml").write_text("name: test")
bundle_path = tmp_path / "test-recipe-1.0.0.praison"
with tarfile.open(bundle_path, "w:gz") as tar:
tar.add(bundle_dir / "manifest.json", arcname="manifest.json")
tar.add(bundle_dir / "recipe.yaml", arcname="recipe.yaml")
return bundle_path
def test_init_creates_structure(self, registry):
"""Test registry initialization creates directory structure."""
assert registry.path.exists()
assert registry.recipes_path.exists()
assert registry.index_path.exists()
def test_publish_bundle(self, registry, sample_bundle):
"""Test publishing a bundle."""
result = registry.publish(sample_bundle)
assert result["name"] == "test-recipe"
assert result["version"] == "1.0.0"
assert "checksum" in result
assert "published_at" in result
def test_publish_duplicate_fails(self, registry, sample_bundle):
"""Test publishing duplicate version fails."""
registry.publish(sample_bundle)
with pytest.raises(RecipeExistsError):
registry.publish(sample_bundle)
def test_publish_force_overwrites(self, registry, sample_bundle):
"""Test force publish overwrites existing."""
registry.publish(sample_bundle)
result = registry.publish(sample_bundle, force=True)
assert result["name"] == "test-recipe"
def test_publish_invalid_bundle(self, registry, tmp_path):
"""Test publishing invalid bundle fails."""
invalid_bundle = tmp_path / "invalid.praison"
invalid_bundle.write_text("not a tarball")
with pytest.raises(RegistryError):
registry.publish(invalid_bundle)
def test_pull_recipe(self, registry, sample_bundle, tmp_path):
"""Test pulling a recipe."""
registry.publish(sample_bundle)
output_dir = tmp_path / "output"
result = registry.pull("test-recipe", output_dir=output_dir)
assert result["name"] == "test-recipe"
assert result["version"] == "1.0.0"
assert (output_dir / "test-recipe" / "manifest.json").exists()
def test_pull_specific_version(self, registry, sample_bundle, tmp_path):
"""Test pulling specific version."""
registry.publish(sample_bundle)
output_dir = tmp_path / "output"
result = registry.pull("test-recipe", version="1.0.0", output_dir=output_dir)
assert result["version"] == "1.0.0"
def test_pull_not_found(self, registry, tmp_path):
"""Test pulling non-existent recipe."""
with pytest.raises(RecipeNotFoundError):
registry.pull("nonexistent")
def test_list_recipes(self, registry, sample_bundle):
"""Test listing recipes."""
registry.publish(sample_bundle)
recipes = registry.list_recipes()
assert len(recipes) == 1
assert recipes[0]["name"] == "test-recipe"
def test_list_recipes_with_tags(self, registry, sample_bundle):
"""Test listing recipes filtered by tags."""
registry.publish(sample_bundle)
# Should find with matching tag
recipes = registry.list_recipes(tags=["test"])
assert len(recipes) == 1
# Should not find with non-matching tag
recipes = registry.list_recipes(tags=["nonexistent"])
assert len(recipes) == 0
def test_search_recipes(self, registry, sample_bundle):
"""Test searching recipes."""
registry.publish(sample_bundle)
# Search by name
results = registry.search("test")
assert len(results) == 1
# Search by tag (search includes tags)
results = registry.search("sample")
assert len(results) == 1 # "sample" is in tags
# Search for non-existent term
results = registry.search("nonexistent")
assert len(results) == 0
def test_get_versions(self, registry, sample_bundle):
"""Test getting versions."""
registry.publish(sample_bundle)
versions = registry.get_versions("test-recipe")
assert "1.0.0" in versions
def test_get_info(self, registry, sample_bundle):
"""Test getting recipe info."""
registry.publish(sample_bundle)
info = registry.get_info("test-recipe")
assert info["name"] == "test-recipe"
assert info["version"] == "1.0.0"
def test_delete_version(self, registry, sample_bundle):
"""Test deleting specific version."""
registry.publish(sample_bundle)
result = registry.delete("test-recipe", version="1.0.0")
assert result is True
with pytest.raises(RecipeNotFoundError):
registry.get_info("test-recipe")
def test_delete_all_versions(self, registry, sample_bundle):
"""Test deleting all versions."""
registry.publish(sample_bundle)
result = registry.delete("test-recipe")
assert result is True
with pytest.raises(RecipeNotFoundError):
registry.get_versions("test-recipe")
class TestHttpRegistry:
"""Test HttpRegistry class."""
@pytest.fixture
def registry(self):
"""Create an HttpRegistry instance."""
return HttpRegistry("http://localhost:7777", token="test-token")
def test_init(self, registry):
"""Test initialization."""
assert registry.url == "http://localhost:7777"
assert registry.token == "test-token"
assert registry.timeout == 30
def test_get_headers(self, registry):
"""Test header generation."""
headers = registry._get_headers()
assert headers["Content-Type"] == "application/json"
assert headers["Authorization"] == "Bearer test-token"
def test_get_headers_no_token(self):
"""Test headers without token."""
registry = HttpRegistry("http://localhost:7777")
registry.token = None
headers = registry._get_headers()
assert "Authorization" not in headers
@patch("urllib.request.urlopen")
def test_health_check(self, mock_urlopen, registry):
"""Test health check."""
mock_response = MagicMock()
mock_response.read.return_value = b'{"ok": true, "status": "healthy"}'
mock_response.headers = {}
mock_response.__enter__ = lambda s: mock_response
mock_response.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_response
result = registry.health()
assert result["ok"] is True
@patch("urllib.request.urlopen")
def test_list_recipes(self, mock_urlopen, registry):
"""Test listing recipes."""
mock_response = MagicMock()
mock_response.read.return_value = b'{"recipes": [{"name": "test"}]}'
mock_response.headers = {}
mock_response.__enter__ = lambda s: mock_response
mock_response.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_response
recipes = registry.list_recipes()
assert len(recipes) == 1
def test_remote_registry_alias(self):
"""Test RemoteRegistry is alias for HttpRegistry."""
assert RemoteRegistry is HttpRegistry
class TestGetRegistry:
"""Test get_registry factory function."""
def test_default_returns_local(self):
"""Test default returns LocalRegistry."""
registry = get_registry()
assert isinstance(registry, LocalRegistry)
def test_http_url_returns_http(self):
"""Test HTTP URL returns HttpRegistry."""
registry = get_registry("http://localhost:7777")
assert isinstance(registry, HttpRegistry)
def test_https_url_returns_http(self):
"""Test HTTPS URL returns HttpRegistry."""
registry = get_registry("https://registry.example.com")
assert isinstance(registry, HttpRegistry)
def test_path_returns_local(self, tmp_path):
"""Test path returns LocalRegistry."""
registry = get_registry(str(tmp_path))
assert isinstance(registry, LocalRegistry)
def test_token_passed_to_http(self):
"""Test token is passed to HttpRegistry."""
registry = get_registry("http://localhost:7777", token="secret")
assert registry.token == "secret"
class TestConstants:
"""Test module constants."""
def test_default_registry_path(self):
"""Test default registry path."""
assert DEFAULT_REGISTRY_PATH == Path.home() / ".praison" / "registry"
def test_default_registry_port(self):
"""Test default registry port."""
assert DEFAULT_REGISTRY_PORT == 7777
+61
View File
@@ -0,0 +1,61 @@
# Current State Snapshot
Generated: 2024-12-29
## Recipe Runtime Location
- **Path**: `/Users/praison/praisonai-package/src/praisonai/praisonai/recipe/`
- **Files**:
- `core.py` - Main runtime: run(), run_stream(), validate(), list_recipes(), describe()
- `registry.py` - LocalRegistry, RemoteRegistry, get_registry()
- `history.py` - RunHistory, store_run(), get_run(), export_run()
- `security.py` - generate_sbom(), sign_bundle(), verify_bundle(), audit_dependencies(), validate_lockfile(), redact_pii(), detect_pii()
- `policy.py` - PolicyPack, get_default_policy(), check_tool_policy()
- `serve.py` - HTTP server with API key + JWT auth, CORS config
- `models.py` - RecipeResult, RecipeEvent, RecipeConfig, ExitCode
- `exceptions.py` - RecipeError hierarchy
## CLI Handlers Location
- **Path**: `/Users/praison/praisonai-package/src/praisonai/praisonai/cli/features/`
- **Files**:
- `recipe.py` - RecipeHandler with all recipe commands
- `endpoints.py` - EndpointsHandler for endpoints CLI
## Main CLI Entry Point
- **Path**: `/Users/praison/praisonai-package/src/praisonai/praisonai/cli/main.py`
- **Recipe routing**: Line 1189-1192 routes 'recipe' command to handle_recipe_command()
- **Endpoints routing**: Line 1196+ routes 'endpoints' command
## Recipe CLI Commands (from handle_recipe_command)
- list, search, info, validate, run, init, test
- pack, unpack, export, replay, serve
- publish, pull, runs
- sbom, audit, sign, verify, policy
## Endpoints CLI Commands
- list, describe, invoke, health
## Registry Code
- **Path**: `/Users/praison/praisonai-package/src/praisonai/praisonai/recipe/registry.py`
- **Classes**: LocalRegistry, RemoteRegistry
- **Functions**: get_registry()
## Documentation
- **Recipe docs**: `/Users/praison/PraisonAIDocs/docs/cli/recipe-*.mdx`
- recipe-registry.mdx
- recipe-runs.mdx
- recipe-security.mdx
- recipe-policy.mdx
- recipe-serve.mdx
## Examples
- **Path**: `/Users/praison/praisonai-package/examples/`
- registry/
- run_history/
- security/
- policy/
- serve/
## Tests
- **Path**: `/Users/praison/praisonai-package/src/praisonai/tests/`
- test_registry.py - 35 tests
- test_recipe.py - 32 tests
- smoke_test_live.py - 5 live tests
+174
View File
@@ -0,0 +1,174 @@
# Implementation Verification Report
Generated: 2024-12-29
## 1. Checklist: Requirements → Implementation Files
### A) Recipe Runtime / Validation
| Requirement | Status | Implementation |
|-------------|--------|----------------|
| Input/output JSON Schema validation | ✅ | `recipe/security.py` - validate_lockfile() |
| YAML errors with file:line:col | ✅ | `recipe/core.py` - error handling |
| Policy mode dev/prod | ✅ | `recipe/policy.py` - PolicyPack with modes |
| PII redaction | ✅ | `recipe/security.py` - redact_pii(), detect_pii() |
| Structured audit logs | ✅ | `recipe/models.py` - trace dict with run_id/session_id/trace_id |
| Run history storage | ✅ | `recipe/history.py` - RunHistory class |
| recipe export complete | ✅ | `cli/features/recipe.py` - cmd_export() |
### B) CLI Commands
| Command | Status | Implementation |
|---------|--------|----------------|
| praisonai recipe publish | ✅ | `cli/features/recipe.py` - cmd_publish() |
| praisonai recipe pull | ✅ | `cli/features/recipe.py` - cmd_pull() |
| praisonai recipe sbom | ✅ | `cli/features/recipe.py` - cmd_sbom() |
| praisonai recipe audit | ✅ | `cli/features/recipe.py` - cmd_audit() |
| praisonai recipe sign | ✅ | `cli/features/recipe.py` - cmd_sign() |
| praisonai recipe verify | ✅ | `cli/features/recipe.py` - cmd_verify() |
| praisonai recipe export | ✅ | `cli/features/recipe.py` - cmd_export() |
| validate --require-lockfile | ✅ | `recipe/security.py` - validate_lockfile(strict=True) |
| run --strict | ✅ | `cli/features/recipe.py` - run options |
### C) Registry (HIGH)
| Requirement | Status | Implementation |
|-------------|--------|----------------|
| Local filesystem registry | ✅ | `recipe/registry.py` - LocalRegistry |
| publish/pull/list/search | ✅ | `recipe/registry.py` - all methods |
| CLI parity | ✅ | `cli/features/recipe.py` - cmd_publish, cmd_pull |
| Token support | ✅ | `recipe/registry.py` - RemoteRegistry with token |
### D) HTTP Serve / Endpoints
| Requirement | Status | Implementation |
|-------------|--------|----------------|
| praisonai recipe serve | ✅ | `recipe/serve.py` + `cli/features/recipe.py` |
| API key auth | ✅ | `recipe/serve.py` - APIKeyAuthMiddleware |
| JWT auth | ✅ | `recipe/serve.py` - JWTAuthMiddleware |
| CORS config | ✅ | `recipe/serve.py` - cors_origins, cors_methods, etc. |
| endpoints list | ✅ | `cli/features/endpoints.py` |
| endpoints describe | ✅ | `cli/features/endpoints.py` |
| endpoints invoke | ✅ | `cli/features/endpoints.py` |
| endpoints health | ✅ | `cli/features/endpoints.py` |
### E) Packaging / Supply Chain
| Requirement | Status | Implementation |
|-------------|--------|----------------|
| SBOM generation | ✅ | `recipe/security.py` - generate_sbom() |
| Signing/verification | ✅ | `recipe/security.py` - sign_bundle(), verify_bundle() |
| Provenance metadata | ✅ | `cli/features/recipe.py` - pack includes git info |
| Dependency audit | ✅ | `recipe/security.py` - audit_dependencies() |
## 2. Verification Commands
### Run Unit Tests
```bash
cd /Users/praison/praisonai-package/src/praisonai
python3 -m pytest tests/test_registry.py tests/test_recipe.py -v
# Expected: 67 passed
```
### Run Live Smoke Tests
```bash
cd /Users/praison/praisonai-package/src/praisonai
export OPENAI_API_KEY=your-key
RUN_LIVE_TESTS=1 python3 tests/smoke_test_live.py
# Expected: 5 passed, 0 failed
```
### Verify CLI Help
```bash
cd /Users/praison/praisonai-package/src/praisonai
python3 -c "from praisonai.cli.features.recipe import handle_recipe_command; handle_recipe_command(['help'])"
python3 -c "from praisonai.cli.features.endpoints import handle_endpoints_command; handle_endpoints_command(['help'])"
```
### Run Examples
```bash
cd /Users/praison/praisonai-package/src/praisonai
PYTHONPATH=. python3 ../../examples/registry/registry_example.py
PYTHONPATH=. python3 ../../examples/run_history/run_history_example.py
PYTHONPATH=. python3 ../../examples/security/security_example.py
PYTHONPATH=. python3 ../../examples/policy/policy_example.py
```
## 3. Live API Key Smoke Test Instructions
```bash
# Set environment variables
export OPENAI_API_KEY=your-openai-key
export RUN_LIVE_TESTS=1
# Run smoke tests
cd /Users/praison/praisonai-package/src/praisonai
python3 tests/smoke_test_live.py
# Expected output:
# [TEST] Registry Workflow... ✓ PASSED
# [TEST] Run History... ✓ PASSED
# [TEST] SBOM Generation... ✓ PASSED
# [TEST] PII Redaction... ✓ PASSED
# [TEST] Policy Workflow... ✓ PASSED
# Results: 5 passed, 0 failed
```
## 4. Documentation Pages Added
| Page | Path |
|------|------|
| Recipe Registry | `docs/cli/recipe-registry.mdx` |
| Run History | `docs/cli/recipe-runs.mdx` |
| Security Features | `docs/cli/recipe-security.mdx` |
| Policy Packs | `docs/cli/recipe-policy.mdx` |
| Recipe Serve | `docs/cli/recipe-serve.mdx` |
### mint.json Navigation Update
Added to "Templates & Recipes" group:
- `docs/cli/recipe-registry`
- `docs/cli/recipe-runs`
- `docs/cli/recipe-security`
- `docs/cli/recipe-policy`
## 5. Examples Added
| Example | Path | Run Command |
|---------|------|-------------|
| Registry | `examples/registry/` | `python3 registry_example.py` |
| Run History | `examples/run_history/` | `python3 run_history_example.py` |
| Security | `examples/security/` | `python3 security_example.py` |
| Policy | `examples/policy/` | `python3 policy_example.py` |
## 6. Performance Impact Analysis
### Lazy Import Verification
All recipe modules use lazy imports:
- `recipe/__init__.py` - Uses `__getattr__` for lazy loading
- `recipe/registry.py` - No heavy imports at module level
- `recipe/history.py` - No heavy imports at module level
- `recipe/security.py` - Lazy imports for yaml, cryptography
- `recipe/policy.py` - Lazy imports for yaml
- `recipe/serve.py` - Lazy imports for starlette, uvicorn
### No Impact on praisonaiagents
- Recipe modules are NOT imported by praisonaiagents
- All recipe imports are in praisonai wrapper package only
- Optional dependencies (cryptography, PyJWT) only loaded when needed
## Test Results Summary
| Test Category | Tests | Status |
|---------------|-------|--------|
| Registry Tests | 7 | ✅ Pass |
| Run History Tests | 6 | ✅ Pass |
| SBOM Tests | 2 | ✅ Pass |
| Lockfile Tests | 2 | ✅ Pass |
| Audit Tests | 1 | ✅ Pass |
| PII Redaction Tests | 3 | ✅ Pass |
| Policy Tests | 5 | ✅ Pass |
| CLI Tests | 8 | ✅ Pass |
| Integration Tests | 1 | ✅ Pass |
| Recipe Core Tests | 32 | ✅ Pass |
| **Total Unit Tests** | **67** | ✅ Pass |
| Live Smoke Tests | 5 | ✅ Pass |
---
**`missing = 0`**
All requirements have been implemented, tested, and verified.