mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf483f3bd6 | ||
|
|
00ee8eb2f3 |
No files matched your search
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.7.2" \
|
||||
"praisonai>=2.7.4" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.7.2" \
|
||||
"praisonai>=2.7.4" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.7.2" \
|
||||
"praisonai>=2.7.4" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,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)
|
||||
@@ -0,0 +1,48 @@
|
||||
# Recipe Registry Example
|
||||
|
||||
This example demonstrates how to use the PraisonAI recipe registry for publishing and pulling recipes.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
- Creating a local registry
|
||||
- Packing recipes into bundles
|
||||
- Publishing recipes to the registry
|
||||
- Listing and searching recipes
|
||||
- Pulling recipes from the registry
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run the example
|
||||
python registry_example.py
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Publish a recipe
|
||||
praisonai recipe publish ./my-recipe --json
|
||||
|
||||
# Pull a recipe
|
||||
praisonai recipe pull [email protected] -o ./recipes
|
||||
|
||||
# List recipes in registry
|
||||
praisonai recipe list --json
|
||||
|
||||
# Search recipes
|
||||
praisonai recipe search "hello"
|
||||
```
|
||||
|
||||
## Using a Custom Registry Path
|
||||
|
||||
```bash
|
||||
# Publish to custom registry
|
||||
praisonai recipe publish ./my-recipe --registry /path/to/registry
|
||||
|
||||
# Pull from custom registry
|
||||
praisonai recipe pull [email protected] --registry /path/to/registry
|
||||
```
|
||||
|
||||
## Default Registry Location
|
||||
|
||||
By default, the registry is stored at `~/.praison/registry`.
|
||||
@@ -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()
|
||||
@@ -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`.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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 | - |
|
||||
|
||||
## 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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.4.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.4.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -928,9 +944,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."""
|
||||
@@ -1071,6 +1103,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,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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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.4 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)
|
||||
@@ -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)
|
||||
@@ -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,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)
|
||||
@@ -0,0 +1,558 @@
|
||||
"""
|
||||
Recipe Registry Module
|
||||
|
||||
Provides local and remote registry support for recipe distribution.
|
||||
Supports:
|
||||
- Local filesystem registry (~/.praison/registry)
|
||||
- Remote HTTP registry (optional)
|
||||
- Publish, pull, search, list operations
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
# Default registry paths
|
||||
DEFAULT_REGISTRY_PATH = Path.home() / ".praison" / "registry"
|
||||
DEFAULT_RUNS_PATH = Path.home() / ".praison" / "runs"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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 RemoteRegistry:
|
||||
"""
|
||||
Remote HTTP-based recipe registry client.
|
||||
|
||||
Supports:
|
||||
- Token-based authentication
|
||||
- Publish/pull operations
|
||||
- Search and list
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
token: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
):
|
||||
"""
|
||||
Initialize remote registry client.
|
||||
|
||||
Args:
|
||||
url: Registry base URL
|
||||
token: Authentication token
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.url = url.rstrip("/")
|
||||
self.token = token or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
self.timeout = timeout
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
data: Optional[Dict] = None,
|
||||
files: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make HTTP request to registry."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"{self.url}{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
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=self.timeout) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise RegistryAuthError("Authentication failed")
|
||||
elif e.code == 404:
|
||||
raise RecipeNotFoundError(f"Not found: {path}")
|
||||
else:
|
||||
body = e.read().decode() if e.fp else ""
|
||||
raise RegistryError(f"Registry error ({e.code}): {body}")
|
||||
except urllib.error.URLError as e:
|
||||
raise RegistryError(f"Connection error: {e.reason}")
|
||||
|
||||
def publish(
|
||||
self,
|
||||
bundle_path: Union[str, Path],
|
||||
force: bool = False,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Publish bundle to remote registry."""
|
||||
bundle_path = Path(bundle_path)
|
||||
if not bundle_path.exists():
|
||||
raise RegistryError(f"Bundle not found: {bundle_path}")
|
||||
|
||||
# For remote, we'd upload the file
|
||||
# This is a simplified implementation
|
||||
with open(bundle_path, "rb") as f:
|
||||
bundle_data = f.read()
|
||||
|
||||
import base64
|
||||
encoded = base64.b64encode(bundle_data).decode()
|
||||
|
||||
return self._request("POST", "/v1/recipes/publish", {
|
||||
"bundle": encoded,
|
||||
"force": force,
|
||||
"metadata": metadata or {},
|
||||
})
|
||||
|
||||
def pull(
|
||||
self,
|
||||
name: str,
|
||||
version: Optional[str] = None,
|
||||
output_dir: Optional[Path] = None,
|
||||
verify_checksum: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Pull recipe from remote registry."""
|
||||
version_str = f"@{version}" if version else ""
|
||||
result = self._request("GET", f"/v1/recipes/{name}{version_str}/download")
|
||||
|
||||
# Decode and save bundle
|
||||
import base64
|
||||
bundle_data = base64.b64decode(result["bundle"])
|
||||
|
||||
output_dir = Path(output_dir) if output_dir else Path.cwd()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bundle_path = output_dir / f"{name}-{result['version']}.praison"
|
||||
with open(bundle_path, "wb") as f:
|
||||
f.write(bundle_data)
|
||||
|
||||
# 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": result["version"],
|
||||
"path": str(recipe_dir),
|
||||
"bundle_path": str(bundle_path),
|
||||
}
|
||||
|
||||
def list_recipes(self, tags: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""List recipes from remote registry."""
|
||||
params = ""
|
||||
if tags:
|
||||
params = f"?tags={','.join(tags)}"
|
||||
return self._request("GET", f"/v1/recipes{params}").get("recipes", [])
|
||||
|
||||
def search(self, query: str, tags: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""Search recipes in remote registry."""
|
||||
params = f"?q={query}"
|
||||
if tags:
|
||||
params += f"&tags={','.join(tags)}"
|
||||
return self._request("GET", f"/v1/recipes/search{params}").get("results", [])
|
||||
|
||||
|
||||
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))
|
||||
@@ -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
|
||||
@@ -65,7 +65,7 @@ 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):
|
||||
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 +97,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
|
||||
|
||||
@@ -312,19 +361,31 @@ def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
|
||||
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))
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.7.2"
|
||||
__version__ = "2.7.4"
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user