Compare commits

...
Author SHA1 Message Date
MervinPraison 5568b10077 Release v0.3.136 2026-07-28 11:33:51 +00:00
praisonai-triage-agent[bot] 557852b287 Merge pull request #253 from MervinPraison/claude/issue-251-20260728-000000
fix: make URL-token auth opt-in so env token no longer forces 401
2026-07-28 11:30:52 +00:00
MervinPraisonandClaude 1f578a4827 fix: make URL-token auth opt-in so env token no longer forces 401 (fixes #251)
TokenQueryMiddleware previously activated whenever GATEWAY_AUTH_TOKEN or
AIUI_URL_TOKEN was present in the process environment, forcing 401 on all
standalone /api/* routes. A token leaking into a developer shell after a
gateway run broke ad-hoc TestClient scripts (#238, #251).

Activation now requires explicit opt-in: AIUI_REQUIRE_TOKEN, YAML
auth.requireToken, or require_auth=True. Secured deployments keep full
Bearer / query-param / cookie token auth.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-28 10:16:03 +00:00
praisonai-triage-agent[bot] 84ebdbc695 Merge pull request #247 from MervinPraison/claude/issue-236-20260727-0911
fix: add UTF-8 encoding to frontend source test reads
2026-07-27 12:23:34 +00:00
praisonai-triage-agent[bot] 182d065524 Merge pull request #246 from MervinPraison/claude/issue-239-20260727-120000
fix: correct tracing integration test paths to /api/traces
2026-07-27 12:18:36 +00:00
praisonai-triage-agent[bot] 132018d123 Merge pull request #245 from MervinPraison/claude/issue-237-20260727-0911
fix: use explicit UTF-8 encoding in CLI integration test fixture
2026-07-27 12:13:41 +00:00
MervinPraison bb85860a33 Release v0.3.135 2026-07-27 12:11:31 +00:00
praisonai-triage-agent[bot] c074d0782c Merge pull request #244 from MervinPraison/claude/issue-234-20260727-fix
feat: configurable embedding model + degraded knowledge search reporting
2026-07-27 12:08:27 +00:00
praisonai-triage-agent[bot] f7e19b9a13 Merge pull request #243 from MervinPraison/claude/issue-233-20260727-0904
fix: correct tracing API test paths (/api/tracing -> /api/traces)
2026-07-27 12:02:58 +00:00
praisonai-triage-agent[bot] 1a91f574b9 Merge pull request #242 from MervinPraison/claude/issue-231-20260727-0857
feat: safe_print for examples to avoid cp1252 UnicodeEncodeError
2026-07-27 11:58:32 +00:00
praisonai-triage-agent[bot] 0c982f4959 Merge pull request #241 from MervinPraison/claude/issue-232-20260727-0900
fix: specify UTF-8 encoding for frontend source reads in tests
2026-07-27 11:53:17 +00:00
MervinPraisonandClaude 0cc7f70bbb fix: correct tracing integration test paths to /api/traces (fixes #239)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-27 09:14:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 30c34e7266 fix: use explicit UTF-8 encoding in CLI integration test fixture (fixes #237)
Test fixture wrote YAML/markdown and read JSON without encoding, defaulting
to cp1252 on Windows and breaking UTF-8 reads for the © character. Specify
encoding="utf-8" on all write_text/read_text calls for deterministic
cross-platform behaviour.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:13:28 +00:00
MervinPraisonandClaude 43b9fe7253 feat: configurable embedding model + degraded-mode reporting for knowledge search (fixes #234)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-27 09:10:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 5b61ece40d fix: correct tracing API test paths /api/tracing to /api/traces (fixes #233)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:05:29 +00:00
praisonai-triage-agent[bot]andMervin Praison 6fd27d0ed7 feat: safe_print for examples to avoid cp1252 UnicodeEncodeError (fixes #231)
Add examples/python/_shared/console.py with safe_print/icon helpers that
degrade emoji to ASCII on legacy Windows code pages (cp1252) instead of
crashing, mirroring the CLI _supports_unicode/_icon pattern. Wire the helper
into the high-severity gateway (16, 17, 18) and channel (20-23) examples so
they start on default Windows PowerShell without PYTHONUTF8.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:04:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 78a8a499c1 fix: specify UTF-8 encoding for frontend source reads in tests (fixes #232)
Standardize all Path.read_text() calls in the test suite to pass
encoding="utf-8" so committed UTF-8 frontend JS/HTML bundles decode
correctly on Windows (cp1252 default locale) instead of raising
UnicodeDecodeError.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:03:59 +00:00
20 changed files with 797 additions and 179 deletions

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
# Knowledge Embedding Model Configuration
Knowledge / RAG search uses vector **embeddings**. The default embedding model is
`text-embedding-3-small`. Many OpenAI keys or projects do not have access to that
model (organization tier, model allowlist, or regional restrictions), which caused
knowledge search to silently degrade to local substring matching.
PraisonAIUI now makes this failure mode configurable and observable.
## Configure the embedding model
Set an accessible model via environment variable:
```bash
export AIUI_EMBEDDING_MODEL=text-embedding-ada-002
```
`OPENAI_EMBEDDING_MODEL` is also honoured as a fallback if `AIUI_EMBEDDING_MODEL`
is not set.
## Fallback chain
The OpenAI-compatible `/v1/embeddings` endpoint tries the requested (or default)
model first, then each model in the fallback chain if the primary returns a
`model_not_found` / 403 access error:
```bash
export AIUI_EMBEDDING_FALLBACK_MODELS="text-embedding-ada-002,text-embedding-3-small"
```
If every candidate is inaccessible, the endpoint returns `503` with an actionable
`model_not_found` error instead of a generic `500`.
## Observing degraded search
`GET /api/knowledge/status` reports embedding availability:
```json
{
"total": 12,
"backend": "SDKKnowledgeManager",
"status": "degraded",
"embedding": {"available": false, "error": "Project does not have access to model `text-embedding-3-small`"},
"warnings": ["Embedding model unavailable; knowledge search degraded to local text matching. Set AIUI_EMBEDDING_MODEL to an accessible model."]
}
```
`POST /api/knowledge/search` indicates whether results came from vector search or a
degraded local fallback:
```json
{
"results": [],
"count": 0,
"search_mode": "fallback",
"warning": "Vector search unavailable: Project does not have access to model text-embedding-3-small. Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding models in your provider project."
}
```
A dashboard can show a warning banner whenever `search_mode == "fallback"` or
`embedding.available == false`.
## Model reference
| Model | Notes |
|-------|-------|
| `text-embedding-3-small` | Default — best cost/quality when available |
| `text-embedding-3-large` | Higher quality — may also be restricted |
| `text-embedding-ada-002` | Legacy — widely available on older projects |
+16 -13
View File
@@ -44,15 +44,18 @@ import sys
# ── Import praisonaiui ──────────────────────────────────────
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiui.integration import AIUIGateway
from praisonaiagents import Agent
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies missing: {e}")
print(" Install with: pip install praisonai praisonaiagents")
print(" Falling back to standalone mode.")
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies missing: {e}")
safe_print(" Install with: pip install praisonai praisonaiagents")
safe_print(" Falling back to standalone mode.")
GATEWAY_OK = False
# ── Set dashboard style ─────────────────────────────────────
@@ -149,15 +152,15 @@ def _register_agents_in_dashboard():
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Dashboard: {agent_def['icon']} {agent_def['name']}")
safe_print(f" {icon('✓', '[OK]')} Dashboard: {agent_def['name']}")
# ── Main ────────────────────────────────────────────────────
async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
safe_print()
# Create the gateway
gateway = AIUIGateway(
@@ -174,22 +177,22 @@ async def main():
self_reflect=False,
)
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
# ── Register agents in dashboard CRUD storage ───────
_register_agents_in_dashboard()
print()
print(f" Dashboard: http://localhost:8083")
print(f" WebSocket: ws://localhost:8083/ws")
print(f" Inspector: http://localhost:8083 → 🔍 Feature Inspector")
print()
safe_print()
safe_print(" Dashboard: http://localhost:8083")
safe_print(" WebSocket: ws://localhost:8083/ws")
safe_print(f" Inspector: http://localhost:8083 {icon('→', '->')} {icon('🔍', '')} Feature Inspector")
safe_print()
# Start the gateway — this runs the full server
await gateway.start()
else:
# Fallback to standalone mode (same as example 15)
print("📦 Starting in standalone mode (CRUD only, no agent execution)")
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (CRUD only, no agent execution)")
from praisonaiui.server import create_app
import uvicorn
+21 -18
View File
@@ -26,15 +26,18 @@ import sys
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiui.integration import AIUIGateway
from praisonaiagents import Agent
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies not found: {e}")
print(" Install with: pip install praisonai praisonaiagents")
print(" Falling back to standalone mode (REST APIs only).\n")
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
safe_print(" Install with: pip install praisonai praisonaiagents")
safe_print(" Falling back to standalone mode (REST APIs only).\n")
GATEWAY_OK = False
aiui.set_style("dashboard")
@@ -115,7 +118,7 @@ def _register_agents_in_dashboard():
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
# ── Main ────────────────────────────────────────────────────
@@ -125,8 +128,8 @@ async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
safe_print()
gateway = AIUIGateway(host=host, port=port)
@@ -139,32 +142,32 @@ async def main():
reflection=False,
)
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
# Also register in dashboard CRUD
_register_agents_in_dashboard()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" WebSocket: ws://localhost:{port}/ws")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(f" WebSocket: ws://localhost:{port}/ws")
safe_print()
await gateway.start()
else:
# Standalone: REST APIs only, no agent execution
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
from praisonaiui.server import create_app
import uvicorn
_register_agents_in_dashboard()
app = create_app()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" Note: POST endpoints may fail without gateway")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(" Note: POST endpoints may fail without gateway")
safe_print()
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
+22 -18
View File
@@ -25,9 +25,13 @@ Usage:
import asyncio
import os
import sys
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiagents import Agent
@@ -35,9 +39,9 @@ try:
from praisonaiui.integration import AIUIGateway
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies not found: {e}")
print(" Install with: pip install praisonai praisonaiagents")
print(" Falling back to standalone mode (REST APIs only).\n")
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
safe_print(" Install with: pip install praisonai praisonaiagents")
safe_print(" Falling back to standalone mode (REST APIs only).\n")
GATEWAY_OK = False
aiui.set_style("dashboard")
@@ -127,7 +131,7 @@ def _register_agents_in_dashboard():
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
# ── Main ────────────────────────────────────────────────────
@@ -137,8 +141,8 @@ async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
safe_print()
gateway = AIUIGateway(host=host, port=port)
@@ -151,21 +155,21 @@ async def main():
reflection=False,
)
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
# Also register in dashboard CRUD
_register_agents_in_dashboard()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" WebSocket: ws://localhost:{port}/ws")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(f" WebSocket: ws://localhost:{port}/ws")
safe_print()
await gateway.start()
else:
# Standalone: REST APIs only, no agent execution
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
import uvicorn
from praisonaiui.server import create_app
@@ -173,11 +177,11 @@ async def main():
_register_agents_in_dashboard()
app = create_app()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(" Note: POST endpoints may fail without gateway")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(" Note: POST endpoints may fail without gateway")
safe_print()
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
+21 -18
View File
@@ -32,6 +32,9 @@ import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check prerequisites ─────────────────────────────────────────────
try:
@@ -40,8 +43,8 @@ try:
SDK_OK = True
except ImportError:
SDK_OK = False
print("⚠️ Missing dependencies. Install with:")
print(" pip install praisonai[email] praisonaiagents")
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
safe_print(" pip install praisonai[email] praisonaiagents")
sys.exit(1)
@@ -68,8 +71,8 @@ agent = Agent(
async def run_simple():
"""Start email bot with automatic env var resolution."""
bot = Bot("email", agent=agent)
print(f"📧 Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
print(" Listening for new emails... (Ctrl+C to stop)")
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
safe_print(" Listening for new emails... (Ctrl+C to stop)")
await bot.start()
@@ -96,9 +99,9 @@ async def run_with_handlers():
@bot.on_message
async def log_email(message):
subject = message.metadata.get("subject", "(no subject)")
print(f"📨 From: {message.sender.username}")
print(f" Subject: {subject}")
print(f" Preview: {message.text[:100]}...")
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
safe_print(f" Subject: {subject}")
safe_print(f" Preview: {message.text[:100]}...")
# Custom command: emails with subject starting with "/status"
@bot.on_command("status")
@@ -118,11 +121,11 @@ async def run_with_handlers():
reply_to=message.message_id,
)
print(f"📧 Starting email bot with custom handlers")
print(f" Email: {bot._email_address}")
print(f" Poll interval: {config.polling_interval}s")
print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
print(" Listening... (Ctrl+C to stop)")
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot with custom handlers")
safe_print(f" Email: {bot._email_address}")
safe_print(f" Poll interval: {config.polling_interval}s")
safe_print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
safe_print(" Listening... (Ctrl+C to stop)")
await bot.start()
@@ -131,17 +134,17 @@ async def run_with_handlers():
async def run_probe():
"""Test email server connectivity."""
bot = Bot("email", agent=agent)
print("🔍 Testing email server connectivity...")
safe_print(f"{icon('🔍', '[PROBE]')} Testing email server connectivity...")
result = await bot.probe()
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
print(f" Platform: {result.platform}")
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
safe_print(f" Platform: {result.platform}")
if result.bot_username:
print(f" Email: {result.bot_username}")
safe_print(f" Email: {result.bot_username}")
if result.elapsed_ms:
print(f" Latency: {result.elapsed_ms:.0f}ms")
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
if result.error:
print(f" Error: {result.error}")
safe_print(f" Error: {result.error}")
# ── Main ────────────────────────────────────────────────────────────
+26 -24
View File
@@ -42,7 +42,9 @@ import sys
# ── Imports ─────────────────────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
from praisonaiui.server import create_app
import uvicorn
@@ -91,11 +93,11 @@ def seed_email_channel():
})
if r.status_code in (200, 201):
channel_id = r.json().get("id", "unknown")
print(f" ✅ Email channel registered: {channel_id}")
safe_print(f" {icon('✅', '[OK]')} Email channel registered: {channel_id}")
else:
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
else:
print(" ✓ Email channel already exists")
safe_print(f" {icon('✓', '[OK]')} Email channel already exists")
# Also seed other channels for comparison in dashboard
for name, platform in [("Discord #general", "discord"), ("Telegram Support", "telegram")]:
@@ -106,7 +108,7 @@ def seed_email_channel():
"platform": platform,
"config": {},
})
print(f" ✓ Seeded {platform} channel")
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
# ── Verify email platform support ──────────────────────────────────
@@ -122,8 +124,8 @@ def verify_email_support():
if r.status_code == 200:
platforms = r.json().get("platforms", [])
email_supported = "email" in platforms
print(f" Supported platforms: {platforms}")
print(f" Email supported: {'✅ Yes' if email_supported else '❌ No — add email to SUPPORTED_PLATFORMS'}")
safe_print(f" Supported platforms: {platforms}")
safe_print(f" Email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No - add email to SUPPORTED_PLATFORMS'}")
return email_supported
return False
@@ -137,51 +139,51 @@ def test_channel_api():
app = create_app()
client = TestClient(app)
print("\n📋 Channel API Test")
print("=" * 40)
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
safe_print("=" * 40)
# List channels
r = client.get("/api/channels")
channels = r.json().get("channels", [])
print(f" GET /api/channels: {len(channels)} channels")
safe_print(f" GET /api/channels: {len(channels)} channels")
for ch in channels:
icon = "📧" if ch.get("platform") == "email" else "💬"
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
ch_icon = icon("📧", "[email]") if ch.get("platform") == "email" else icon("💬", "[chat]")
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
# Get email channel status
email_ch = next((ch for ch in channels if ch.get("platform") == "email"), None)
if email_ch:
ch_id = email_ch.get("id")
r = client.get(f"/api/channels/{ch_id}/status")
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
print()
safe_print()
# ── Main ────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("📧 PraisonAIUI — Email Channel Integration")
print("=" * 50)
print()
safe_print(f"{icon('📧', '[EMAIL]')} PraisonAIUI - Email Channel Integration")
safe_print("=" * 50)
safe_print()
if "--test" in sys.argv:
# Test mode: verify APIs + seed data
print("🔍 Verifying email platform support...")
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying email platform support...")
verify_email_support()
print()
print("🌱 Seeding email channel...")
safe_print()
safe_print(f"{icon('🌱', '[SEED]')} Seeding email channel...")
seed_email_channel()
test_channel_api()
else:
# Run the dashboard
print("🌱 Seeding channels...")
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
seed_email_channel()
print()
safe_print()
app = create_app()
print(f"✅ Dashboard at http://localhost:8084")
print(f" Channels: http://localhost:8084/api/channels")
print(f" Platforms: http://localhost:8084/api/channels/platforms")
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
safe_print(" Channels: http://localhost:8084/api/channels")
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8084, log_level="info")
+32 -29
View File
@@ -33,6 +33,9 @@ import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check prerequisites ─────────────────────────────────────────────
try:
@@ -41,8 +44,8 @@ try:
SDK_OK = True
except ImportError:
SDK_OK = False
print("⚠️ Missing dependencies. Install with:")
print(" pip install praisonai agentmail praisonaiagents")
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
safe_print(" pip install praisonai agentmail praisonaiagents")
sys.exit(1)
@@ -69,9 +72,9 @@ agent = Agent(
async def run_simple():
"""Start agentmail bot with automatic env var resolution."""
bot = Bot("agentmail", agent=agent)
print(f"📩 Starting AgentMail bot")
print(f" API key: {'✅ set' if os.getenv('AGENTMAIL_API_KEY') else '❌ not set'}")
print(" Listening for new emails... (Ctrl+C to stop)")
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot")
safe_print(f" API key: {icon('✅', '[OK]') + ' set' if os.getenv('AGENTMAIL_API_KEY') else icon('❌', '[X]') + ' not set'}")
safe_print(" Listening for new emails... (Ctrl+C to stop)")
await bot.start()
@@ -99,9 +102,9 @@ async def run_with_handlers():
@bot.on_message
async def log_email(message):
subject = message.metadata.get("subject", "(no subject)")
print(f"📨 From: {message.sender.username}")
print(f" Subject: {subject}")
print(f" Preview: {message.text[:100]}...")
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
safe_print(f" Subject: {subject}")
safe_print(f" Preview: {message.text[:100]}...")
# Custom command: emails with subject starting with "/status"
@bot.on_command("status")
@@ -121,10 +124,10 @@ async def run_with_handlers():
reply_to=message.message_id,
)
print(f"📩 Starting AgentMail bot with custom handlers")
print(f" Email: {bot.email_address or '(will be assigned on start)'}")
print(f" Poll interval: {config.polling_interval}s")
print(" Listening... (Ctrl+C to stop)")
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot with custom handlers")
safe_print(f" Email: {bot.email_address or '(will be assigned on start)'}")
safe_print(f" Poll interval: {config.polling_interval}s")
safe_print(" Listening... (Ctrl+C to stop)")
await bot.start()
@@ -136,30 +139,30 @@ async def run_inbox_demo():
token=os.environ.get("AGENTMAIL_API_KEY", ""),
)
print("📩 AgentMail Inbox Lifecycle Demo")
print("=" * 50)
safe_print(f"{icon('📩', '[MAIL]')} AgentMail Inbox Lifecycle Demo")
safe_print("=" * 50)
# Create inbox
print("\n1. Creating inbox...")
safe_print("\n1. Creating inbox...")
inbox = await bot.create_inbox(domain=os.getenv("AGENTMAIL_DOMAIN"))
print(f" ✅ Created: {inbox.get('email_address', 'unknown')}")
print(f" ID: {inbox.get('id', 'unknown')}")
safe_print(f" {icon('✅', '[OK]')} Created: {inbox.get('email_address', 'unknown')}")
safe_print(f" ID: {inbox.get('id', 'unknown')}")
# List inboxes
print("\n2. Listing inboxes...")
safe_print("\n2. Listing inboxes...")
inboxes = await bot.list_inboxes()
print(f" Found: {len(inboxes)} inbox(es)")
safe_print(f" Found: {len(inboxes)} inbox(es)")
for ib in inboxes:
print(f" 📧 {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
safe_print(f" {icon('📧', '[EMAIL]')} {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
# Delete inbox
inbox_id = inbox.get("id", "")
if inbox_id:
print(f"\n3. Deleting inbox {inbox_id}...")
safe_print(f"\n3. Deleting inbox {inbox_id}...")
deleted = await bot.delete_inbox(inbox_id)
print(f" {'✅ Deleted' if deleted else '❌ Failed to delete'}")
safe_print(f" {icon('✅', '[OK]') + ' Deleted' if deleted else icon('❌', '[X]') + ' Failed to delete'}")
print("\n✅ Inbox lifecycle complete")
safe_print(f"\n{icon('✅', '[OK]')} Inbox lifecycle complete")
# ── Probe: Test connectivity ────────────────────────────────────────
@@ -167,17 +170,17 @@ async def run_inbox_demo():
async def run_probe():
"""Test AgentMail API connectivity."""
bot = Bot("agentmail", agent=agent)
print("🔍 Testing AgentMail API connectivity...")
safe_print(f"{icon('🔍', '[PROBE]')} Testing AgentMail API connectivity...")
result = await bot.probe()
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
print(f" Platform: {result.platform}")
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
safe_print(f" Platform: {result.platform}")
if result.bot_username:
print(f" Email: {result.bot_username}")
safe_print(f" Email: {result.bot_username}")
if result.elapsed_ms:
print(f" Latency: {result.elapsed_ms:.0f}ms")
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
if result.error:
print(f" Error: {result.error}")
safe_print(f" Error: {result.error}")
# ── Main ────────────────────────────────────────────────────────────
+27 -25
View File
@@ -40,7 +40,9 @@ import sys
# ── Imports ─────────────────────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
from praisonaiui.server import create_app
import uvicorn
@@ -88,11 +90,11 @@ def seed_agentmail_channel():
})
if r.status_code in (200, 201):
channel_id = r.json().get("id", "unknown")
print(f" ✅ AgentMail channel registered: {channel_id}")
safe_print(f" {icon('✅', '[OK]')} AgentMail channel registered: {channel_id}")
else:
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
else:
print(" ✓ AgentMail channel already exists")
safe_print(f" {icon('✓', '[OK]')} AgentMail channel already exists")
# Also seed email + telegram for comparison in dashboard
for name, platform in [("Support Email", "email"), ("Telegram Support", "telegram")]:
@@ -103,7 +105,7 @@ def seed_agentmail_channel():
"platform": platform,
"config": {},
})
print(f" ✓ Seeded {platform} channel")
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
# ── Verify agentmail platform support ──────────────────────────────
@@ -120,9 +122,9 @@ def verify_agentmail_support():
platforms = r.json().get("platforms", [])
am_supported = "agentmail" in platforms
email_supported = "email" in platforms
print(f" Supported platforms: {platforms}")
print(f" email supported: {'✅ Yes' if email_supported else '❌ No'}")
print(f" agentmail supported: {'✅ Yes' if am_supported else '❌ No'}")
safe_print(f" Supported platforms: {platforms}")
safe_print(f" email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No'}")
safe_print(f" agentmail supported: {icon('✅', '[OK]') + ' Yes' if am_supported else icon('❌', '[X]') + ' No'}")
return am_supported
return False
@@ -136,51 +138,51 @@ def test_channel_api():
app = create_app()
client = TestClient(app)
print("\n📋 Channel API Test")
print("=" * 40)
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
safe_print("=" * 40)
# List channels
r = client.get("/api/channels")
channels = r.json().get("channels", [])
print(f" GET /api/channels: {len(channels)} channels")
safe_print(f" GET /api/channels: {len(channels)} channels")
for ch in channels:
icon = {"email": "📧", "agentmail": "📩"}.get(ch.get("platform", ""), "💬")
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
ch_icon = {"email": icon("📧", "[email]"), "agentmail": icon("📩", "[mail]")}.get(ch.get("platform", ""), icon("💬", "[chat]"))
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
# Get agentmail channel status
am_ch = next((ch for ch in channels if ch.get("platform") == "agentmail"), None)
if am_ch:
ch_id = am_ch.get("id")
r = client.get(f"/api/channels/{ch_id}/status")
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
print()
safe_print()
# ── Main ────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("📩 PraisonAIUI — AgentMail Channel Integration")
print("=" * 50)
print()
safe_print(f"{icon('📩', '[MAIL]')} PraisonAIUI - AgentMail Channel Integration")
safe_print("=" * 50)
safe_print()
if "--test" in sys.argv:
# Test mode: verify APIs + seed data
print("🔍 Verifying agentmail platform support...")
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying agentmail platform support...")
verify_agentmail_support()
print()
print("🌱 Seeding agentmail channel...")
safe_print()
safe_print(f"{icon('🌱', '[SEED]')} Seeding agentmail channel...")
seed_agentmail_channel()
test_channel_api()
else:
# Run the dashboard
print("🌱 Seeding channels...")
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
seed_agentmail_channel()
print()
safe_print()
app = create_app()
print(f"✅ Dashboard at http://localhost:8084")
print(f" Channels: http://localhost:8084/api/channels")
print(f" Platforms: http://localhost:8084/api/channels/platforms")
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
safe_print(" Channels: http://localhost:8084/api/channels")
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8084, log_level="info")
+40
View File
@@ -0,0 +1,40 @@
"""Cross-platform console output for examples.
Legacy Windows consoles default stdout to a non-UTF code page (cp1252/cp437)
that cannot encode emoji such as U+1F680. Calling ``print`` with those
characters raises ``UnicodeEncodeError`` and crashes the example before the
server starts. These helpers mirror the ``_supports_unicode``/``_icon`` pattern
in ``praisonaiui.cli`` so examples degrade to ASCII instead of crashing while
preserving emoji on UTF-aware terminals.
Usage:
from _shared.console import safe_print, icon
safe_print(f"{icon('🚀', '[START]')} Starting gateway...")
"""
from __future__ import annotations
import sys
from typing import Any
def supports_unicode() -> bool:
try:
encoding = sys.stdout.encoding or ""
except Exception:
return False
return "utf" in encoding.lower()
def icon(symbol: str, fallback: str) -> str:
return symbol if supports_unicode() else fallback
def safe_print(*args: Any, **kwargs: Any) -> None:
sep = kwargs.pop("sep", " ")
text = sep.join(str(a) for a in args)
try:
print(text, **kwargs)
except UnicodeEncodeError:
print(text.encode("ascii", errors="replace").decode("ascii"), **kwargs)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.134"
version = "0.3.136"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.134"
__version__ = "0.3.136"
+74 -2
View File
@@ -27,6 +27,17 @@ from ._base import BaseFeatureProtocol
logger = logging.getLogger(__name__)
def _is_embedding_unavailable(err: BaseException) -> bool:
"""Detect an embedding model access / not-found failure."""
msg = str(err).lower()
return (
"does not have access" in msg
or "model_not_found" in msg
or "does not exist" in msg
or "notfounderror" in msg
)
# ── Knowledge Protocol ───────────────────────────────────────────────
@@ -95,6 +106,10 @@ class KnowledgeProtocol(ABC):
"""Health check for this backend."""
return {"status": "ok", "provider": self.__class__.__name__}
def last_search_meta(self) -> Dict[str, Any]:
"""Metadata about the most recent search (mode, error)."""
return {"mode": "vector", "error": None}
# ── Simple Knowledge Manager (Default, no deps) ─────────────────────
@@ -171,8 +186,12 @@ class SimpleKnowledgeManager(KnowledgeProtocol):
"status": "ok",
"provider": "SimpleKnowledgeManager",
"total_entries": len(self._entries),
"embedding": {"available": False, "reason": "no_vector_backend"},
}
def last_search_meta(self) -> Dict[str, Any]:
return {"mode": "local", "error": None}
# ── SDK Knowledge Manager (wraps praisonaiagents.knowledge) ──────────
@@ -191,6 +210,9 @@ class SDKKnowledgeManager(KnowledgeProtocol):
self._sdk_knowledge = None
self._sdk_probed = False # True once we've verified SDK actually works
self._local_index: Dict[str, Dict[str, Any]] = {}
self._embedding_unavailable = False
self._embedding_error: Optional[str] = None
self._last_search_meta: Dict[str, Any] = {"mode": "vector", "error": None}
def _get_sdk_knowledge(self) -> Any:
"""Lazy-init SDK Knowledge with health probe.
@@ -242,6 +264,10 @@ class SDKKnowledgeManager(KnowledgeProtocol):
"using local in-memory index"
)
self._sdk_knowledge = None
elif _is_embedding_unavailable(e):
self._embedding_unavailable = True
self._embedding_error = err_msg
logger.warning("Embedding model unavailable: %s", e)
else:
# Other errors (e.g. empty DB) are fine — SDK works
pass
@@ -294,6 +320,7 @@ class SDKKnowledgeManager(KnowledgeProtocol):
try:
results = sdk.search(query, limit=limit, user_id="praisonaiui")
if isinstance(results, list) and results:
self._last_search_meta = {"mode": "vector", "error": None}
return [
{
"id": str(r.get("id", i)),
@@ -305,6 +332,9 @@ class SDKKnowledgeManager(KnowledgeProtocol):
]
except BaseException as e:
logger.warning("SDK knowledge search failed: %s; falling back to local", e)
if _is_embedding_unavailable(e):
self._embedding_unavailable = True
self._embedding_error = str(e)
# Fallback to local text search
query_lower = query.lower()
@@ -314,8 +344,26 @@ class SDKKnowledgeManager(KnowledgeProtocol):
results.append(e)
if len(results) >= limit:
break
if self._embedding_unavailable:
self._last_search_meta = {
"mode": "fallback",
"error": (
"Vector search unavailable: "
f"{self._embedding_error or 'embedding model not accessible'}. "
"Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding "
"models in your provider project."
),
}
elif sdk is not None:
self._last_search_meta = {"mode": "vector", "error": None}
else:
self._last_search_meta = {"mode": "local", "error": None}
return results
def last_search_meta(self) -> Dict[str, Any]:
return dict(self._last_search_meta)
def add_file(
self,
file_path: str,
@@ -453,11 +501,25 @@ class SDKKnowledgeManager(KnowledgeProtocol):
def health(self) -> Dict[str, Any]:
sdk = self._get_sdk_knowledge()
embedding_available = sdk is not None and not self._embedding_unavailable
warnings: List[str] = []
embedding: Dict[str, Any] = {"available": embedding_available}
if self._embedding_unavailable:
embedding["error"] = self._embedding_error
warnings.append(
"Embedding model unavailable; knowledge search degraded to local text "
"matching. Set AIUI_EMBEDDING_MODEL to an accessible model."
)
status = "ok"
if sdk is None or self._embedding_unavailable:
status = "degraded"
return {
"status": "ok" if sdk is not None else "degraded",
"status": status,
"provider": "SDKKnowledgeManager",
"sdk_available": sdk is not None,
"total_indexed": len(self._local_index),
"embedding": embedding,
"warnings": warnings,
}
@@ -626,7 +688,15 @@ class KnowledgeFeature(BaseFeatureProtocol):
query=query,
limit=body.get("limit", 10),
)
return JSONResponse({"results": results, "count": len(results)})
meta = mgr.last_search_meta()
return JSONResponse(
{
"results": results,
"count": len(results),
"search_mode": meta.get("mode", "unknown"),
"warning": meta.get("error"),
}
)
async def _add_file(self, request: Request) -> JSONResponse:
"""POST /api/knowledge/add-file — ingest a file."""
@@ -675,6 +745,8 @@ class KnowledgeFeature(BaseFeatureProtocol):
"files": stats.get("files", 0),
"backend": h.get("provider", "unknown"),
"status": h.get("status", "ok"),
"embedding": h.get("embedding", {"available": True}),
"warnings": h.get("warnings", []),
}
)
+76 -18
View File
@@ -6,6 +6,7 @@ Wraps praisonai.capabilities functions as HTTP endpoints.
from __future__ import annotations
import os
import time
import uuid
from dataclasses import asdict
@@ -17,6 +18,35 @@ from starlette.routing import Route
from ._base import BaseFeatureProtocol
def _default_embedding_model() -> str:
"""Resolve the default embedding model from environment."""
return os.environ.get(
"AIUI_EMBEDDING_MODEL",
os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"),
)
def _embedding_fallback_models() -> list[str]:
"""Resolve the embedding fallback chain from environment."""
raw = os.environ.get(
"AIUI_EMBEDDING_FALLBACK_MODELS",
"text-embedding-ada-002,text-embedding-3-small",
)
return [m.strip() for m in raw.split(",") if m.strip()]
def _is_model_not_found(err: BaseException) -> bool:
"""Heuristically detect embedding model access / not-found errors."""
msg = str(err).lower()
return (
"does not have access" in msg
or "model_not_found" in msg
or "does not exist" in msg
or "notfounderror" in msg
)
# Lazy import capabilities
_capabilities = None
@@ -280,29 +310,57 @@ class OpenAIAPIFeature(BaseFeatureProtocol):
body = await request.json()
input_text = body.get("input", "")
model = body.get("model", "text-embedding-3-small")
model = body.get("model") or _default_embedding_model()
try:
result = await caps.aembed(
input=input_text,
model=model,
)
candidates = [model]
for fallback in _embedding_fallback_models():
if fallback not in candidates:
candidates.append(fallback)
data = _result_to_dict(result)
last_err: BaseException | None = None
for candidate in candidates:
try:
result = await caps.aembed(
input=input_text,
model=candidate,
)
data = _result_to_dict(result)
return JSONResponse(
{
"object": "list",
"data": [
{
"object": "embedding",
"index": 0,
"embedding": data.get("embedding", []),
}
],
"model": candidate,
"usage": data.get("usage", {"prompt_tokens": 0, "total_tokens": 0}),
}
)
except Exception as e:
last_err = e
if not _is_model_not_found(e):
break
if last_err is not None and _is_model_not_found(last_err):
return JSONResponse(
{
"object": "list",
"data": [
{"object": "embedding", "index": 0, "embedding": data.get("embedding", [])}
],
"model": model,
"usage": data.get("usage", {"prompt_tokens": 0, "total_tokens": 0}),
}
)
except Exception as e:
return JSONResponse(
{"error": {"message": str(e), "type": "api_error"}}, status_code=500
"error": {
"message": (
f"Embedding model unavailable: {last_err}. "
"Set AIUI_EMBEDDING_MODEL to a model your project can access "
"(e.g. text-embedding-ada-002)."
),
"type": "model_not_found",
}
},
status_code=503,
)
return JSONResponse(
{"error": {"message": str(last_err), "type": "api_error"}}, status_code=500
)
async def _image_generate(self, request: Request) -> JSONResponse:
"""POST /v1/images/generations — Generate images."""
+24 -4
View File
@@ -2860,7 +2860,14 @@ def create_app(
require_auth: bool = False,
config_path: Optional[Path] = None,
) -> Starlette:
"""Create the Starlette application."""
"""Create the Starlette application.
URL-token auth (``TokenQueryMiddleware``) is opt-in: it activates only
when ``AIUI_REQUIRE_TOKEN`` is truthy, YAML ``auth.requireToken`` is set,
or ``require_auth=True``. A bare ``GATEWAY_AUTH_TOKEN`` /
``AIUI_URL_TOKEN`` env var (e.g. leaked into a developer shell after a
gateway run) no longer forces 401 on standalone ``/api/*`` (#238, #251).
"""
from praisonaiui.auth import (
AuthMiddleware,
login_handler,
@@ -2874,10 +2881,13 @@ def create_app(
config = load_config_from_yaml(config_path)
# Extract auth settings from config
_config_requires_token = False
if config:
auth_config = config.get("auth", {})
if auth_config.get("requireAuth") or auth_config.get("require_auth"):
require_auth = True
if auth_config.get("requireToken") or auth_config.get("require_token"):
_config_requires_token = True
middleware = [
Middleware(
@@ -2891,10 +2901,20 @@ def create_app(
# Add optional AUTH_ENFORCE middleware (env var driven)
middleware.append(Middleware(AuthEnforcementMiddleware))
# URL-token auth — opt-in via env. Enables query-param / cookie auth so
# dashboards can be shared by URL (Jupyter model).
# URL-token auth — opt-in. Enables query-param / cookie auth so dashboards
# can be shared by URL (Jupyter model). The mere presence of a token env
# var (e.g. GATEWAY_AUTH_TOKEN leaking into a developer shell after a
# gateway run) must NOT force auth on standalone create_app() — that broke
# ad-hoc TestClient scripts (#238, #251). Activation now requires an
# explicit opt-in: AIUI_REQUIRE_TOKEN=1, YAML auth.requireToken, or the
# require_auth flag.
_tok = os.environ.get("AIUI_URL_TOKEN") or os.environ.get("GATEWAY_AUTH_TOKEN")
if _tok:
_token_auth_opt_in = (
os.environ.get("AIUI_REQUIRE_TOKEN", "").lower() in ("true", "1", "yes")
or _config_requires_token
or require_auth
)
if _tok and _token_auth_opt_in:
from praisonaiui.auth import TokenQueryMiddleware
middleware.append(
Middleware(TokenQueryMiddleware, expected_token=_tok)
+9 -5
View File
@@ -49,13 +49,16 @@ templates:
routes:
- match: "/docs/**"
template: docs
"""
""",
encoding="utf-8",
)
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "index.md").write_text("# Welcome\n\nHello from integration test.")
(docs_dir / "guide.md").write_text("# Guide\n\nA test guide.")
(docs_dir / "index.md").write_text(
"# Welcome\n\nHello from integration test.", encoding="utf-8"
)
(docs_dir / "guide.md").write_text("# Guide\n\nA test guide.", encoding="utf-8")
return tmp_path
@@ -116,7 +119,7 @@ class TestBuildCommand:
def test_build_invalid_config(self, tmp_path: Path):
config = tmp_path / "bad.yaml"
config.write_text("not: valid: yaml: config")
config.write_text("not: valid: yaml: config", encoding="utf-8")
result = runner.invoke(
app,
["build", "--config", str(config), "--output", str(tmp_path / "out")],
@@ -141,7 +144,8 @@ templates:
routes:
- match: "/docs/**"
template: docs
"""
""",
encoding="utf-8",
)
result = runner.invoke(
app,
+6 -2
View File
@@ -488,12 +488,16 @@ class TestTelemetryAPI:
class TestTracingAPI:
def test_list_traces(self, client):
r = client.get("/api/tracing")
r = client.get("/api/traces")
assert r.status_code == 200
data = r.json()
assert isinstance(data, (list, dict))
def test_tracing_status(self, client):
r = client.get("/api/tracing/status")
r = client.get("/api/traces/status")
assert r.status_code == 200
data = r.json()
assert "status" in data or "feature" in data
# ===========================================================================
+70
View File
@@ -0,0 +1,70 @@
"""Tests for the shared examples console helper.
These verify that examples degrade emoji to ASCII on legacy Windows code pages
(cp1252) instead of raising ``UnicodeEncodeError`` while preserving emoji on
UTF-aware terminals, mirroring the CLI ``_icon``/``_supports_unicode`` pattern.
"""
import io
import os
import sys
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "examples", "python", "_shared"))
from console import icon, safe_print, supports_unicode
class TestSupportsUnicode:
def test_utf8_encoding_is_supported(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "utf-8"
with mock.patch("sys.stdout", mock_stdout):
assert supports_unicode() is True
def test_cp1252_encoding_is_not_supported(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "cp1252"
with mock.patch("sys.stdout", mock_stdout):
assert supports_unicode() is False
def test_missing_encoding_falls_back_to_ascii(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = None
with mock.patch("sys.stdout", mock_stdout):
assert supports_unicode() is False
class TestIcon:
def test_returns_symbol_on_utf8(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "utf-8"
with mock.patch("sys.stdout", mock_stdout):
assert icon("🚀", "[START]") == "🚀"
def test_returns_fallback_on_cp1252(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "cp1252"
with mock.patch("sys.stdout", mock_stdout):
assert icon("🚀", "[START]") == "[START]"
class TestSafePrint:
def test_never_raises_on_cp1252(self):
buffer = io.BytesIO()
stream = io.TextIOWrapper(buffer, encoding="cp1252", errors="strict")
with mock.patch("sys.stdout", stream):
safe_print("🚀 Starting")
stream.flush()
assert buffer.getvalue()
def test_preserves_unicode_on_utf8(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
safe_print("🚀 Starting")
assert "🚀" in buffer.getvalue()
def test_respects_sep(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
safe_print("a", "b", sep="-")
assert buffer.getvalue().strip() == "a-b"
+194
View File
@@ -0,0 +1,194 @@
"""Unit tests for knowledge embedding fallback + degraded-mode reporting."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from starlette.testclient import TestClient
from praisonaiui.features import _features, register_feature
from praisonaiui.features import knowledge as knowledge_mod
from praisonaiui.features.knowledge import (
KnowledgeFeature,
SDKKnowledgeManager,
SimpleKnowledgeManager,
_is_embedding_unavailable,
)
from praisonaiui.features.openai_api import (
OpenAIAPIFeature,
_default_embedding_model,
_embedding_fallback_models,
_is_model_not_found,
)
from praisonaiui.server import create_app
class _FakeSDK:
def __init__(self, search_error: BaseException | None = None) -> None:
self._search_error = search_error
def search(self, query, limit=1, user_id=None):
if query == "__probe__":
return []
if self._search_error is not None:
raise self._search_error
return []
def test_is_model_not_found_detects_403():
assert _is_model_not_found(Exception("Project does not have access to model x"))
assert _is_model_not_found(Exception("litellm.NotFoundError: nope"))
assert not _is_model_not_found(Exception("timeout"))
def test_is_embedding_unavailable_detects_403():
assert _is_embedding_unavailable(Exception("model_not_found"))
assert not _is_embedding_unavailable(Exception("connection reset"))
def test_default_embedding_model_env(monkeypatch):
monkeypatch.delenv("AIUI_EMBEDDING_MODEL", raising=False)
monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False)
assert _default_embedding_model() == "text-embedding-3-small"
monkeypatch.setenv("AIUI_EMBEDDING_MODEL", "text-embedding-ada-002")
assert _default_embedding_model() == "text-embedding-ada-002"
def test_fallback_models_env(monkeypatch):
monkeypatch.setenv("AIUI_EMBEDDING_FALLBACK_MODELS", "a, b ,,c")
assert _embedding_fallback_models() == ["a", "b", "c"]
def test_simple_manager_reports_local_mode():
mgr = SimpleKnowledgeManager()
mgr.store("hello world")
mgr.search("hello")
assert mgr.last_search_meta()["mode"] == "local"
assert mgr.health()["embedding"]["available"] is False
def test_sdk_search_reports_fallback_on_embedding_error():
mgr = SDKKnowledgeManager()
err = Exception("Project does not have access to model text-embedding-3-small")
mgr._sdk_knowledge = _FakeSDK(search_error=err)
mgr._sdk_probed = True
mgr.store("PraisonAIUI is a YAML-driven UI generator.")
mgr.search("documentation generator")
meta = mgr.last_search_meta()
assert meta["mode"] == "fallback"
assert "text-embedding" in meta["error"].lower()
health = mgr.health()
assert health["embedding"]["available"] is False
assert health["status"] == "degraded"
assert health["warnings"]
def test_sdk_search_vector_mode_when_healthy():
class _OkSDK:
def search(self, query, limit=1, user_id=None):
if query == "__probe__":
return []
return [{"id": "1", "memory": "hit", "score": 0.9}]
mgr = SDKKnowledgeManager()
mgr._sdk_knowledge = _OkSDK()
mgr._sdk_probed = True
results = mgr.search("anything")
assert results
assert mgr.last_search_meta()["mode"] == "vector"
def test_probe_flags_embedding_unavailable():
mgr = SDKKnowledgeManager()
class _Kn:
def search(self, query, limit=1):
raise Exception("model_not_found: no access")
with patch("praisonaiagents.knowledge.Knowledge", return_value=_Kn(), create=True):
mgr._get_sdk_knowledge()
assert mgr._embedding_unavailable is True
@pytest.fixture
def client():
saved = dict(_features)
saved_mgr = knowledge_mod._knowledge_manager
_features.clear()
register_feature(KnowledgeFeature())
register_feature(OpenAIAPIFeature())
yield TestClient(create_app())
_features.clear()
_features.update(saved)
knowledge_mod._knowledge_manager = saved_mgr
def test_knowledge_search_reports_fallback(client):
mgr = SDKKnowledgeManager()
err = Exception("Project does not have access to model text-embedding-3-small")
mgr._sdk_knowledge = _FakeSDK(search_error=err)
mgr._sdk_probed = True
mgr.store("PraisonAIUI is a YAML-driven documentation site generator.")
knowledge_mod._knowledge_manager = mgr
r = client.post("/api/knowledge/search", json={"query": "documentation generator YAML"})
assert r.status_code == 200
body = r.json()
assert body["search_mode"] == "fallback"
assert body["warning"]
assert "embedding" in body["warning"].lower()
def test_knowledge_status_reports_embedding(client):
mgr = SDKKnowledgeManager()
mgr._sdk_knowledge = _FakeSDK()
mgr._sdk_probed = True
mgr._embedding_unavailable = True
mgr._embedding_error = "no access"
knowledge_mod._knowledge_manager = mgr
r = client.get("/api/knowledge/status")
assert r.status_code == 200
body = r.json()
assert body["embedding"]["available"] is False
assert body["warnings"]
def test_embeddings_endpoint_fallback_models(client):
call_models: list[str] = []
async def _aembed(input, model):
call_models.append(model)
if model == "text-embedding-3-small":
raise Exception("Project does not have access to model text-embedding-3-small")
return {"embedding": [0.1, 0.2], "usage": {"prompt_tokens": 1, "total_tokens": 1}}
fake_caps = type("Caps", (), {"aembed": staticmethod(AsyncMock(side_effect=_aembed))})
with patch(
"praisonaiui.features.openai_api._get_capabilities", return_value=fake_caps
):
r = client.post(
"/v1/embeddings",
json={"input": "test", "model": "text-embedding-3-small"},
)
assert r.status_code == 200
body = r.json()
assert body["model"] == "text-embedding-ada-002"
assert call_models[0] == "text-embedding-3-small"
def test_embeddings_endpoint_all_fail_returns_503(client):
async def _aembed(input, model):
raise Exception("model_not_found: no access to any embedding model")
fake_caps = type("Caps", (), {"aembed": staticmethod(AsyncMock(side_effect=_aembed))})
with patch(
"praisonaiui.features.openai_api._get_capabilities", return_value=fake_caps
):
r = client.post("/v1/embeddings", json={"input": "test"})
assert r.status_code == 503
assert r.json()["error"]["type"] == "model_not_found"
+67
View File
@@ -0,0 +1,67 @@
"""Regression tests for opt-in token auth activation (#238, #251).
The mere presence of GATEWAY_AUTH_TOKEN / AIUI_URL_TOKEN in the environment
(e.g. leaking into a developer shell after a gateway run) must not force 401
on standalone create_app() + TestClient. Token auth activates only when
explicitly opted in via AIUI_REQUIRE_TOKEN, YAML auth.requireToken, or
require_auth=True.
"""
import pytest
from starlette.testclient import TestClient
from praisonaiui import server
from praisonaiui.server import create_app
@pytest.fixture(autouse=True)
def _reset_state():
server.reset_state()
yield
server.reset_state()
def test_env_token_alone_does_not_force_401(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 200
def test_aiui_url_token_alone_does_not_force_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 200
def test_require_token_env_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.setenv("AIUI_REQUIRE_TOKEN", "1")
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 401
def test_require_token_env_opt_in_allows_bearer(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.setenv("AIUI_REQUIRE_TOKEN", "1")
client = TestClient(create_app())
resp = client.get(
"/api/agents",
headers={"Authorization": "Bearer test-secret-token"},
)
assert resp.status_code == 200
def test_config_require_token_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app(config={"auth": {"requireToken": True}}))
assert client.get("/api/agents").status_code == 401
def test_require_auth_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app(require_auth=True))
assert client.get("/api/agents").status_code == 401
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.134"
version = "0.3.136"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },