From 9127ad0d5257bd6735c86ab481e941b65fd04bf0 Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 08:21:41 +0000 Subject: [PATCH 1/3] fix: resolve architectural gaps in praisonai wrapper (fixes #1620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace bare except: blocks with specific exception handling and logging - metrics.py: Handle litellm import/attribute errors with debug logging - aicoder.py: Proper file read error handling (FileNotFoundError, PermissionError, UnicodeDecodeError) - agent_scheduler.py: Handle timestamp parsing errors - browser/cli.py: Handle page load timeout/connection errors - Consolidate LLM endpoint env-var precedence into single resolver - Created praisonai/llm/env.py with resolve_llm_endpoint() function - Enforces consistent precedence: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE - Updated 8+ call sites to use resolver, eliminating duplication - Move framework availability checks to CLI entry point for fail-fast behavior - Created framework_adapters/validators.py with actionable error messages - Validation happens before YAML parsing, saving startup time on failures - Removed duplicate availability checks from all adapter run() methods These changes address all three architectural gaps identified in the issue: silent error masking, fragmented env-var precedence, and late framework checks. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: MervinPraison --- src/praisonai/praisonai/agents_generator.py | 16 +++--- src/praisonai/praisonai/auto.py | 16 ++---- .../praisonai/bots/_approval_base.py | 9 ++- src/praisonai/praisonai/browser/cli.py | 5 +- .../praisonai/capabilities/realtime.py | 8 ++- .../praisonai/cli/features/agent_scheduler.py | 4 +- .../praisonai/cli/features/metrics.py | 35 ++++++------ src/praisonai/praisonai/cli/main.py | 32 ++++++----- src/praisonai/praisonai/deploy.py | 17 ++++-- .../framework_adapters/autogen_adapter.py | 13 ++--- .../framework_adapters/crewai_adapter.py | 5 +- .../framework_adapters/praisonai_adapter.py | 5 +- .../framework_adapters/validators.py | 37 ++++++++++++ src/praisonai/praisonai/llm/env.py | 56 +++++++++++++++++++ src/praisonai/praisonai/test.py | 9 ++- .../praisonai/ui/components/aicoder.py | 12 +++- 16 files changed, 194 insertions(+), 85 deletions(-) create mode 100644 src/praisonai/praisonai/framework_adapters/validators.py create mode 100644 src/praisonai/praisonai/llm/env.py diff --git a/src/praisonai/praisonai/agents_generator.py b/src/praisonai/praisonai/agents_generator.py index 2caac0183..63688e6ac 100644 --- a/src/praisonai/praisonai/agents_generator.py +++ b/src/praisonai/praisonai/agents_generator.py @@ -232,10 +232,8 @@ def __init__(self, agent_file, framework, config_list, log_level=None, agent_cal self.tool_registry = ToolRegistry() self.tool_registry.register_builtin_autogen_adapters() - # Get framework adapter and validate availability + # Get framework adapter (availability already validated at CLI entry) self.framework_adapter = self._get_framework_adapter(framework) - if not self.framework_adapter.is_available(): - raise ImportError(f"Framework '{framework}' is not available. Please install the required dependencies.") def _get_framework_adapter(self, framework: str) -> FrameworkAdapter: """ @@ -597,10 +595,7 @@ def generate_crew_and_kickoff(self): self.framework = framework self.framework_adapter = self._get_framework_adapter(framework) - # Final availability check - if not self.framework_adapter.is_available(): - raise ImportError(f"Framework '{framework}' is not available. Please install the required dependencies.") - + # Framework availability already validated at CLI entry self.logger.info(f"Using framework: {framework}") return self.framework_adapter.run( config, @@ -892,10 +887,13 @@ def _resolve(key, env_var=None, default=None): api_type = _resolve("api_type", default="openai").lower() model_name = _resolve("model", default="gpt-4o-mini") api_key = _resolve("api_key", env_var="OPENAI_API_KEY") + # Use resolver for consistent env-var precedence as fallback + from praisonai.llm.env import resolve_llm_endpoint + ep = resolve_llm_endpoint() + base_url = (model_config.get("base_url") or yaml_llm.get("base_url") - or os.environ.get("OPENAI_BASE_URL") - or os.environ.get("OPENAI_API_BASE")) + or ep.base_url) # Build LLMConfig — Bedrock needs no api_key if api_type == "bedrock": diff --git a/src/praisonai/praisonai/auto.py b/src/praisonai/praisonai/auto.py index d0d897f6c..cc7074a80 100644 --- a/src/praisonai/praisonai/auto.py +++ b/src/praisonai/praisonai/auto.py @@ -467,19 +467,15 @@ def __init__(self, config_list: Optional[List[Dict]] = None): Args: config_list: Optional LLM configuration list """ - # Support multiple environment variable patterns for better compatibility - model_name = os.environ.get("MODEL_NAME") or os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini") - base_url = ( - os.environ.get("OPENAI_BASE_URL") or - os.environ.get("OPENAI_API_BASE") or - os.environ.get("OLLAMA_API_BASE", "https://api.openai.com/v1") - ) + # Resolve LLM endpoint configuration from environment variables + from praisonai.llm.env import resolve_llm_endpoint + ep = resolve_llm_endpoint() self.config_list = config_list or [ { - 'model': model_name, - 'base_url': base_url, - 'api_key': os.environ.get("OPENAI_API_KEY") + 'model': ep.model, + 'base_url': ep.base_url, + 'api_key': ep.api_key } ] diff --git a/src/praisonai/praisonai/bots/_approval_base.py b/src/praisonai/praisonai/bots/_approval_base.py index 74363b6f5..5778bc2c2 100644 --- a/src/praisonai/praisonai/bots/_approval_base.py +++ b/src/praisonai/praisonai/bots/_approval_base.py @@ -94,12 +94,15 @@ async def classify_with_llm( ) try: + from praisonai.llm.env import resolve_llm_endpoint + ep = resolve_llm_endpoint() + client = OpenAI( - api_key=_os.environ.get("OPENAI_API_KEY", ""), - base_url=_os.environ.get("OPENAI_BASE_URL"), + api_key=ep.api_key or "", + base_url=ep.base_url, ) response = client.chat.completions.create( - model=_os.environ.get("APPROVAL_LLM_MODEL", "gpt-4o-mini"), + model=_os.environ.get("APPROVAL_LLM_MODEL", ep.model), messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, diff --git a/src/praisonai/praisonai/browser/cli.py b/src/praisonai/praisonai/browser/cli.py index 262f0da5a..978b6dfc5 100644 --- a/src/praisonai/praisonai/browser/cli.py +++ b/src/praisonai/praisonai/browser/cli.py @@ -2707,9 +2707,10 @@ async def run_goal(): if debug: console.print("[dim] [DEBUG] Page loaded, content script should be active[/dim]") break - except: + except (asyncio.TimeoutError, Exception) as e: if debug: - console.print("[dim] [DEBUG] Page load event timeout, continuing anyway[/dim]") + console.print(f"[dim] [DEBUG] Page load event timeout/error ({type(e).__name__}), continuing anyway[/dim]") + logging.debug("Page load event timeout/error: %s", e) # Wait for content script to inject await asyncio.sleep(2) diff --git a/src/praisonai/praisonai/capabilities/realtime.py b/src/praisonai/praisonai/capabilities/realtime.py index 939f39b11..0b69ee582 100644 --- a/src/praisonai/praisonai/capabilities/realtime.py +++ b/src/praisonai/praisonai/capabilities/realtime.py @@ -62,7 +62,13 @@ def realtime_connect( session_id = f"realtime-{uuid.uuid4().hex[:12]}" # Build WebSocket URL - base = api_base or os.environ.get("OPENAI_API_BASE", "wss://api.openai.com") + if api_base: + base = api_base + else: + from praisonai.llm.env import resolve_llm_endpoint + ep = resolve_llm_endpoint(default_base="wss://api.openai.com") + base = ep.base_url + if base.startswith("http"): base = base.replace("https://", "wss://").replace("http://", "ws://") diff --git a/src/praisonai/praisonai/cli/features/agent_scheduler.py b/src/praisonai/praisonai/cli/features/agent_scheduler.py index bcf2db94a..638a087be 100644 --- a/src/praisonai/praisonai/cli/features/agent_scheduler.py +++ b/src/praisonai/praisonai/cli/features/agent_scheduler.py @@ -434,8 +434,8 @@ def _handle_describe(unknown_args, state_manager, daemon_manager) -> int: hours = int(uptime_delta.total_seconds() // 3600) minutes = int((uptime_delta.total_seconds() % 3600) // 60) uptime = f"{hours}h {minutes}m" - except: - pass + except (ValueError, TypeError) as e: + logging.debug("Failed to parse started_at timestamp '%s': %s", started_at, e) # Display detailed info print(f"\n{'='*60}") diff --git a/src/praisonai/praisonai/cli/features/metrics.py b/src/praisonai/praisonai/cli/features/metrics.py index 6e5297835..5bf62fe67 100644 --- a/src/praisonai/praisonai/cli/features/metrics.py +++ b/src/praisonai/praisonai/cli/features/metrics.py @@ -5,9 +5,12 @@ Usage: praisonai "prompt" --metrics """ +import logging from typing import Any, Dict, Tuple from .base import FlagHandler +logger = logging.getLogger(__name__) + class MetricsHandler(FlagHandler): """ @@ -133,33 +136,29 @@ def extract_metrics_from_agent(self, agent: Any) -> Dict[str, Any]: ctx = litellm._thread_context if hasattr(ctx, 'usage'): usage = ctx.usage - if hasattr(usage, 'prompt_tokens'): - metrics['prompt_tokens'] = usage.prompt_tokens - if hasattr(usage, 'completion_tokens'): - metrics['completion_tokens'] = usage.completion_tokens - if hasattr(usage, 'total_tokens'): - metrics['total_tokens'] = usage.total_tokens - except: - pass + metrics['prompt_tokens'] = getattr(usage, 'prompt_tokens', None) + metrics['completion_tokens'] = getattr(usage, 'completion_tokens', None) + metrics['total_tokens'] = getattr(usage, 'total_tokens', None) + except ImportError: + logger.debug("litellm not installed; skipping token metrics") + except AttributeError as e: + logger.debug("litellm internals changed (%s); skipping token metrics", e) # Try to get cost from litellm cost tracking try: - import litellm from litellm import completion_cost - # If we have token counts, estimate cost - if 'prompt_tokens' in metrics and 'completion_tokens' in metrics: + if metrics.get('prompt_tokens') and metrics.get('completion_tokens'): model = metrics.get('model', 'gpt-4o-mini') try: - cost = completion_cost( + metrics['cost'] = completion_cost( model=model, prompt_tokens=metrics['prompt_tokens'], - completion_tokens=metrics['completion_tokens'] + completion_tokens=metrics['completion_tokens'], ) - metrics['cost'] = cost - except: - pass - except: - pass + except Exception as e: + logger.debug("cost calc failed for model=%s: %s", model, e) + except ImportError: + logger.debug("litellm.completion_cost unavailable; skipping cost metrics") return metrics diff --git a/src/praisonai/praisonai/cli/main.py b/src/praisonai/praisonai/cli/main.py index b6298e1b4..e676353b9 100644 --- a/src/praisonai/praisonai/cli/main.py +++ b/src/praisonai/praisonai/cli/main.py @@ -272,29 +272,26 @@ def __init__(self, agent_file="agents.yaml", framework="", auto=False, init=Fals self.agent_yaml = agent_yaml self._interactive_mode = False # Flag for interactive TUI mode # Create config_list with AutoGen compatibility - # Support multiple environment variable patterns for better compatibility - # Priority order: MODEL_NAME > OPENAI_MODEL_NAME for model selection - model_name = os.environ.get("MODEL_NAME") or os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini") - - # Priority order for base_url: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE - # OPENAI_BASE_URL is the standard OpenAI SDK environment variable - base_url = ( - os.environ.get("OPENAI_BASE_URL") or - os.environ.get("OPENAI_API_BASE") or - os.environ.get("OLLAMA_API_BASE", "https://api.openai.com/v1") - ) + # Resolve LLM endpoint configuration from environment variables + from praisonai.llm.env import resolve_llm_endpoint + ep = resolve_llm_endpoint() - api_key = os.environ.get("OPENAI_API_KEY") self.config_list = [ { - 'model': model_name, - 'base_url': base_url, - 'api_key': api_key, + 'model': ep.model, + 'base_url': ep.base_url, + 'api_key': ep.api_key, 'api_type': 'openai' # AutoGen expects this field } ] self.agent_file = agent_file self.framework = framework + + # Validate framework availability early to fail fast + if self.framework: + from praisonai.framework_adapters.validators import assert_framework_available + assert_framework_available(self.framework) + self.auto = auto self.init = init self.tools = tools or [] # Store tool class names as a list @@ -383,6 +380,11 @@ def main(self): self.framework = args.framework or self.framework + # Validate framework availability early to fail fast + if self.framework: + from praisonai.framework_adapters.validators import assert_framework_available + assert_framework_available(self.framework) + # Update config_list model if --model flag is provided if getattr(args, 'model', None): self.config_list[0]['model'] = args.model diff --git a/src/praisonai/praisonai/deploy.py b/src/praisonai/praisonai/deploy.py index 6e4bf8cb6..61cb71144 100644 --- a/src/praisonai/praisonai/deploy.py +++ b/src/praisonai/praisonai/deploy.py @@ -92,9 +92,12 @@ def create_api_file(self): def set_environment_variables(self): """Sets environment variables with fallback to .env values or defaults.""" - os.environ["OPENAI_MODEL_NAME"] = os.getenv("OPENAI_MODEL_NAME", "gpt-4o-mini") - os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY", "Enter your API key") - os.environ["OPENAI_API_BASE"] = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1") + from praisonai.llm.env import resolve_llm_endpoint + ep = resolve_llm_endpoint() + + os.environ["OPENAI_MODEL_NAME"] = ep.model + os.environ["OPENAI_API_KEY"] = ep.api_key or "Enter your API key" + os.environ["OPENAI_API_BASE"] = ep.base_url def run_commands(self): """ @@ -129,9 +132,11 @@ def run_commands(self): return # Get environment variables - openai_model = os.environ.get('OPENAI_MODEL_NAME', 'gpt-5-nano') - openai_key = os.environ.get('OPENAI_API_KEY', 'Enter your API key') - openai_base = os.environ.get('OPENAI_API_BASE', 'https://api.openai.com/v1') + from praisonai.llm.env import resolve_llm_endpoint + ep = resolve_llm_endpoint() + openai_model = ep.model + openai_key = ep.api_key or 'Enter your API key' + openai_base = ep.base_url # Build commands with actual values commands = [ diff --git a/src/praisonai/praisonai/framework_adapters/autogen_adapter.py b/src/praisonai/praisonai/framework_adapters/autogen_adapter.py index c48323c3a..f4609e820 100644 --- a/src/praisonai/praisonai/framework_adapters/autogen_adapter.py +++ b/src/praisonai/praisonai/framework_adapters/autogen_adapter.py @@ -38,9 +38,8 @@ def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str Returns: Execution result as string """ - if not self.is_available(): - raise ImportError("AutoGen v0.2 is not available. Install with: pip install autogen") - + # Availability already validated at CLI entry + # Import AutoGen only when needed import autogen @@ -123,9 +122,8 @@ def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str Returns: Execution result as string """ - if not self.is_available(): - raise ImportError("AutoGen v0.4 is not available. Install with: pip install autogen-agentchat autogen-ext") - + # Availability already validated at CLI entry + logger.info("Starting AutoGen v0.4 execution...") # For now, return a proper error message instead of delegating # TODO: Implement full AutoGen v0.4 adapter logic @@ -162,8 +160,7 @@ def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str Returns: Execution result as string """ - if not self.is_available(): - raise ImportError("AG2 is not available. Install with: pip install ag2") + # Availability already validated at CLI entry logger.info("Starting AG2 execution...") # For now, return a proper error message instead of delegating diff --git a/src/praisonai/praisonai/framework_adapters/crewai_adapter.py b/src/praisonai/praisonai/framework_adapters/crewai_adapter.py index 5a7912e49..d2ec57da0 100644 --- a/src/praisonai/praisonai/framework_adapters/crewai_adapter.py +++ b/src/praisonai/praisonai/framework_adapters/crewai_adapter.py @@ -52,10 +52,7 @@ def run( Returns: Execution result as string """ - if not self.is_available(): - raise ImportError("CrewAI is not available. Install with: pip install crewai") - - # Import CrewAI only when needed + # Import CrewAI only when needed (availability already validated at CLI entry) from crewai import Agent, Task, Crew from crewai.telemetry import Telemetry diff --git a/src/praisonai/praisonai/framework_adapters/praisonai_adapter.py b/src/praisonai/praisonai/framework_adapters/praisonai_adapter.py index 15fa2e425..b60436ee8 100644 --- a/src/praisonai/praisonai/framework_adapters/praisonai_adapter.py +++ b/src/praisonai/praisonai/framework_adapters/praisonai_adapter.py @@ -52,9 +52,8 @@ def run( Returns: Execution result as string """ - if not self.is_available(): - raise ImportError("PraisonAI agents is not available. Install with: pip install praisonaiagents") - + # Availability already validated at CLI entry + # Import PraisonAI components only when needed from praisonaiagents import Agent as PraisonAgent, Task as PraisonTask, AgentTeam diff --git a/src/praisonai/praisonai/framework_adapters/validators.py b/src/praisonai/praisonai/framework_adapters/validators.py new file mode 100644 index 000000000..fa1368b34 --- /dev/null +++ b/src/praisonai/praisonai/framework_adapters/validators.py @@ -0,0 +1,37 @@ +""" +Framework availability validators. + +Provides early validation of framework availability to fail fast at CLI entry +rather than inside run() methods after expensive setup work. +""" + +from typing import NoReturn +from .registry import FrameworkAdapterRegistry + + +# Install hints for common frameworks +_INSTALL_HINTS = { + "crewai": "pip install 'praisonai[crewai]' # or: pip install crewai", + "autogen": "pip install 'praisonai[autogen]' # or: pip install pyautogen", + "praisonai": "pip install praisonaiagents", +} + + +def assert_framework_available(name: str) -> None: + """ + Raise ImportError immediately if the chosen framework is missing. + + Args: + name: Framework name to validate + + Raises: + ImportError: If framework is not available with actionable install hint + """ + registry = FrameworkAdapterRegistry.get_instance() + + if not registry.is_available(name): + hint = _INSTALL_HINTS.get(name, f"pip install {name}") + raise ImportError( + f"Framework '{name}' was requested but is not installed.\n" + f"Install it with:\n {hint}" + ) \ No newline at end of file diff --git a/src/praisonai/praisonai/llm/env.py b/src/praisonai/praisonai/llm/env.py new file mode 100644 index 000000000..4591aaef0 --- /dev/null +++ b/src/praisonai/praisonai/llm/env.py @@ -0,0 +1,56 @@ +""" +LLM endpoint environment variable resolver. + +Provides a single source of truth for resolving LLM endpoint configuration +from environment variables, ensuring consistent precedence across all components. +""" + +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class LLMEndpoint: + """LLM endpoint configuration resolved from environment variables.""" + model: str + base_url: str + api_key: Optional[str] + + +# Documented, single precedence list. Add new providers here only. +_MODEL_VARS = ("MODEL_NAME", "OPENAI_MODEL_NAME") +_BASE_URL_VARS = ("OPENAI_BASE_URL", "OPENAI_API_BASE", "OLLAMA_API_BASE") +_DEFAULT_MODEL = "gpt-4o-mini" +_DEFAULT_BASE = "https://api.openai.com/v1" + + +def _first_set(*names: str) -> Optional[str]: + """Return the first environment variable that is set and non-empty.""" + for name in names: + value = os.environ.get(name) + if value: + return value + return None + + +def resolve_llm_endpoint(*, default_base: str = _DEFAULT_BASE) -> LLMEndpoint: + """ + Resolve LLM endpoint configuration from environment variables. + + Precedence order: + - Model: MODEL_NAME > OPENAI_MODEL_NAME > default + - Base URL: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE > default + - API Key: OPENAI_API_KEY + + Args: + default_base: Default base URL if none found in environment variables + + Returns: + LLMEndpoint with resolved configuration + """ + return LLMEndpoint( + model=_first_set(*_MODEL_VARS) or _DEFAULT_MODEL, + base_url=_first_set(*_BASE_URL_VARS) or default_base, + api_key=os.environ.get("OPENAI_API_KEY"), + ) \ No newline at end of file diff --git a/src/praisonai/praisonai/test.py b/src/praisonai/praisonai/test.py index 7b5929531..19d9abb09 100644 --- a/src/praisonai/praisonai/test.py +++ b/src/praisonai/praisonai/test.py @@ -4,11 +4,14 @@ from dotenv import load_dotenv load_dotenv() +from praisonai.llm.env import resolve_llm_endpoint + +ep = resolve_llm_endpoint() config_list = [ { - 'model': os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini"), - 'base_url': os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), - 'api_key': os.environ.get("OPENAI_API_KEY") + 'model': ep.model, + 'base_url': ep.base_url, + 'api_key': ep.api_key } ] diff --git a/src/praisonai/praisonai/ui/components/aicoder.py b/src/praisonai/praisonai/ui/components/aicoder.py index 645428395..3bf853ca8 100644 --- a/src/praisonai/praisonai/ui/components/aicoder.py +++ b/src/praisonai/praisonai/ui/components/aicoder.py @@ -5,8 +5,11 @@ import platform from typing import Dict, Any import json +import logging import dotenv +logger = logging.getLogger(__name__) + class AICoder: def __init__(self, cwd: str = None, tavily_api_key: str = None): # Load environment variables from .env file @@ -130,7 +133,14 @@ async def read_file(self, file_path): try: with open(file_path, 'r') as file: return file.read() - except: + except FileNotFoundError: + logger.debug("aicoder: file not found: %s", file_path) + return None + except PermissionError: + logger.warning("aicoder: permission denied reading %s", file_path) + return None + except UnicodeDecodeError as e: + logger.warning("aicoder: encoding error reading %s: %s", file_path, e) return None def get_shell_command(self, command: str) -> list: From 32854417bf3d90cfb30c353347943f6c15e09b3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 08:30:35 +0000 Subject: [PATCH 2/3] fix: remove unused NoReturn import; add unit tests for env.py and validators.py - validators.py: remove unused `from typing import NoReturn` import - tests/unit/llm/test_env_resolver.py: 15 tests for resolve_llm_endpoint() covering precedence, defaults, API key, frozen dataclass - tests/unit/test_framework_validators.py: 8 tests for assert_framework_available() covering error messages, install hints, and success paths Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a147d9a2-6f9e-42f6-b2e5-0dba331316bf Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> --- .../framework_adapters/validators.py | 1 - .../tests/unit/llm/test_env_resolver.py | 128 ++++++++++++++++++ .../tests/unit/test_framework_validators.py | 102 ++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/praisonai/tests/unit/llm/test_env_resolver.py create mode 100644 src/praisonai/tests/unit/test_framework_validators.py diff --git a/src/praisonai/praisonai/framework_adapters/validators.py b/src/praisonai/praisonai/framework_adapters/validators.py index fa1368b34..07657f37b 100644 --- a/src/praisonai/praisonai/framework_adapters/validators.py +++ b/src/praisonai/praisonai/framework_adapters/validators.py @@ -5,7 +5,6 @@ rather than inside run() methods after expensive setup work. """ -from typing import NoReturn from .registry import FrameworkAdapterRegistry diff --git a/src/praisonai/tests/unit/llm/test_env_resolver.py b/src/praisonai/tests/unit/llm/test_env_resolver.py new file mode 100644 index 000000000..0bd9ef5b8 --- /dev/null +++ b/src/praisonai/tests/unit/llm/test_env_resolver.py @@ -0,0 +1,128 @@ +""" +Unit tests for praisonai.llm.env.resolve_llm_endpoint(). + +Tests cover: +- Correct precedence order: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE > default +- Model resolution: MODEL_NAME > OPENAI_MODEL_NAME > default +- API key resolution from OPENAI_API_KEY +- Default values when no env vars are set +- Frozen dataclass immutability +""" + +import os +import pytest +from unittest.mock import patch + +try: + from praisonai.llm.env import resolve_llm_endpoint, LLMEndpoint, _DEFAULT_MODEL, _DEFAULT_BASE +except ImportError as e: + pytest.skip(f"Could not import praisonai.llm.env: {e}", allow_module_level=True) + + +class TestResolveDefaults: + """Tests for default values when no env vars are set.""" + + def test_default_model(self): + with patch.dict(os.environ, {}, clear=True): + ep = resolve_llm_endpoint() + assert ep.model == _DEFAULT_MODEL + + def test_default_base_url(self): + with patch.dict(os.environ, {}, clear=True): + ep = resolve_llm_endpoint() + assert ep.base_url == _DEFAULT_BASE + + def test_default_api_key_is_none(self): + with patch.dict(os.environ, {}, clear=True): + ep = resolve_llm_endpoint() + assert ep.api_key is None + + def test_custom_default_base(self): + with patch.dict(os.environ, {}, clear=True): + ep = resolve_llm_endpoint(default_base="https://custom.default.com/v1") + assert ep.base_url == "https://custom.default.com/v1" + + +class TestBaseUrlPrecedence: + """Tests for base URL precedence: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE.""" + + def test_openai_base_url_wins_over_api_base(self): + env = { + "OPENAI_BASE_URL": "https://base-url.com/v1", + "OPENAI_API_BASE": "https://api-base.com/v1", + "OLLAMA_API_BASE": "https://ollama.com", + } + with patch.dict(os.environ, env, clear=True): + ep = resolve_llm_endpoint() + assert ep.base_url == "https://base-url.com/v1" + + def test_openai_api_base_wins_over_ollama(self): + env = { + "OPENAI_API_BASE": "https://api-base.com/v1", + "OLLAMA_API_BASE": "https://ollama.com", + } + with patch.dict(os.environ, env, clear=True): + ep = resolve_llm_endpoint() + assert ep.base_url == "https://api-base.com/v1" + + def test_ollama_api_base_used_as_last_fallback(self): + env = {"OLLAMA_API_BASE": "http://localhost:11434"} + with patch.dict(os.environ, env, clear=True): + ep = resolve_llm_endpoint() + assert ep.base_url == "http://localhost:11434" + + def test_empty_string_is_not_used(self): + env = {"OPENAI_BASE_URL": "", "OPENAI_API_BASE": "https://api-base.com/v1"} + with patch.dict(os.environ, env, clear=True): + ep = resolve_llm_endpoint() + assert ep.base_url == "https://api-base.com/v1" + + +class TestModelPrecedence: + """Tests for model precedence: MODEL_NAME > OPENAI_MODEL_NAME > default.""" + + def test_model_name_wins_over_openai_model_name(self): + env = {"MODEL_NAME": "llama3", "OPENAI_MODEL_NAME": "gpt-4o"} + with patch.dict(os.environ, env, clear=True): + ep = resolve_llm_endpoint() + assert ep.model == "llama3" + + def test_openai_model_name_fallback(self): + env = {"OPENAI_MODEL_NAME": "gpt-4o"} + with patch.dict(os.environ, env, clear=True): + ep = resolve_llm_endpoint() + assert ep.model == "gpt-4o" + + +class TestApiKey: + """Tests for API key resolution.""" + + def test_api_key_from_env(self): + env = {"OPENAI_API_KEY": "sk-test-key"} + with patch.dict(os.environ, env, clear=True): + ep = resolve_llm_endpoint() + assert ep.api_key == "sk-test-key" + + def test_api_key_none_when_not_set(self): + with patch.dict(os.environ, {}, clear=True): + ep = resolve_llm_endpoint() + assert ep.api_key is None + + +class TestLLMEndpointDataclass: + """Tests for LLMEndpoint dataclass properties.""" + + def test_returns_llmendpoint_instance(self): + ep = resolve_llm_endpoint() + assert isinstance(ep, LLMEndpoint) + + def test_is_frozen(self): + ep = resolve_llm_endpoint() + with pytest.raises(AttributeError): + ep.model = "new-model" # type: ignore[misc] + + def test_has_expected_fields(self): + ep = resolve_llm_endpoint() + assert hasattr(ep, "model") + assert hasattr(ep, "base_url") + assert hasattr(ep, "api_key") diff --git a/src/praisonai/tests/unit/test_framework_validators.py b/src/praisonai/tests/unit/test_framework_validators.py new file mode 100644 index 000000000..deaa90e5b --- /dev/null +++ b/src/praisonai/tests/unit/test_framework_validators.py @@ -0,0 +1,102 @@ +""" +Unit tests for praisonai.framework_adapters.validators.assert_framework_available(). + +Tests cover: +- Raises ImportError for unavailable frameworks with actionable install hint +- Does not raise for available frameworks +- Install hint format and content +- Unknown framework names fall back to generic pip install hint +""" + +import pytest +from unittest.mock import patch, MagicMock + +try: + from praisonai.framework_adapters.validators import assert_framework_available + from praisonai.framework_adapters.registry import FrameworkAdapterRegistry +except ImportError as e: + pytest.skip(f"Could not import framework_adapters: {e}", allow_module_level=True) + + +class TestAssertFrameworkAvailableRaises: + """Tests that ImportError is raised for unavailable frameworks.""" + + def test_raises_import_error_for_missing_framework(self): + with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get: + mock_registry = MagicMock() + mock_registry.is_available.return_value = False + mock_get.return_value = mock_registry + + with pytest.raises(ImportError): + assert_framework_available("crewai") + + def test_error_message_contains_framework_name(self): + with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get: + mock_registry = MagicMock() + mock_registry.is_available.return_value = False + mock_get.return_value = mock_registry + + with pytest.raises(ImportError, match="crewai"): + assert_framework_available("crewai") + + def test_error_message_contains_install_hint(self): + with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get: + mock_registry = MagicMock() + mock_registry.is_available.return_value = False + mock_get.return_value = mock_registry + + with pytest.raises(ImportError, match="pip install"): + assert_framework_available("crewai") + + def test_crewai_hint_mentions_praisonai_extra(self): + with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get: + mock_registry = MagicMock() + mock_registry.is_available.return_value = False + mock_get.return_value = mock_registry + + with pytest.raises(ImportError) as exc_info: + assert_framework_available("crewai") + error_msg = str(exc_info.value) + assert "praisonai[crewai]" in error_msg or "pip install crewai" in error_msg + + def test_autogen_hint_mentions_pyautogen(self): + with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get: + mock_registry = MagicMock() + mock_registry.is_available.return_value = False + mock_get.return_value = mock_registry + + with pytest.raises(ImportError) as exc_info: + assert_framework_available("autogen") + assert "autogen" in str(exc_info.value).lower() + + def test_unknown_framework_generic_hint(self): + with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get: + mock_registry = MagicMock() + mock_registry.is_available.return_value = False + mock_get.return_value = mock_registry + + with pytest.raises(ImportError) as exc_info: + assert_framework_available("some_unknown_framework_xyz") + assert "some_unknown_framework_xyz" in str(exc_info.value) + + +class TestAssertFrameworkAvailableSucceeds: + """Tests that no exception is raised for available frameworks.""" + + def test_no_error_when_framework_available(self): + with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get: + mock_registry = MagicMock() + mock_registry.is_available.return_value = True + mock_get.return_value = mock_registry + + # Should not raise + assert_framework_available("crewai") + + def test_returns_none_for_available_framework(self): + with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get: + mock_registry = MagicMock() + mock_registry.is_available.return_value = True + mock_get.return_value = mock_registry + + result = assert_framework_available("praisonai") + assert result is None From 275a985fcb59590e07258f0f68f1102b09499102 Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 08:35:29 +0000 Subject: [PATCH 3/3] fix: resolve critical issues from architecture review - Fix double /v1 URL bug in realtime WebSocket connections - Restore framework validation for non-CLI callers - Handle all OSError subclasses in file reading - Use module logger consistently Co-authored-by: Mervin Praison --- src/praisonai/praisonai/agents_generator.py | 5 ++++- src/praisonai/praisonai/capabilities/realtime.py | 7 ++++++- src/praisonai/praisonai/cli/features/agent_scheduler.py | 2 +- src/praisonai/praisonai/ui/components/aicoder.py | 3 +++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/praisonai/praisonai/agents_generator.py b/src/praisonai/praisonai/agents_generator.py index 63688e6ac..aecd2f3aa 100644 --- a/src/praisonai/praisonai/agents_generator.py +++ b/src/praisonai/praisonai/agents_generator.py @@ -595,7 +595,10 @@ def generate_crew_and_kickoff(self): self.framework = framework self.framework_adapter = self._get_framework_adapter(framework) - # Framework availability already validated at CLI entry + # Validate framework availability for non-CLI callers + from .framework_adapters.validators import assert_framework_available + assert_framework_available(framework) + self.logger.info(f"Using framework: {framework}") return self.framework_adapter.run( config, diff --git a/src/praisonai/praisonai/capabilities/realtime.py b/src/praisonai/praisonai/capabilities/realtime.py index 0b69ee582..ab8b7c34b 100644 --- a/src/praisonai/praisonai/capabilities/realtime.py +++ b/src/praisonai/praisonai/capabilities/realtime.py @@ -72,7 +72,12 @@ def realtime_connect( if base.startswith("http"): base = base.replace("https://", "wss://").replace("http://", "ws://") - url = f"{base.rstrip('/')}/v1/realtime?model={model}" + # Strip any existing /v1 path to avoid double /v1/v1/realtime + base = base.rstrip('/') + if base.endswith('/v1'): + base = base[:-3] + + url = f"{base}/v1/realtime?model={model}" return RealtimeSession( id=session_id, diff --git a/src/praisonai/praisonai/cli/features/agent_scheduler.py b/src/praisonai/praisonai/cli/features/agent_scheduler.py index 638a087be..9090a9f25 100644 --- a/src/praisonai/praisonai/cli/features/agent_scheduler.py +++ b/src/praisonai/praisonai/cli/features/agent_scheduler.py @@ -435,7 +435,7 @@ def _handle_describe(unknown_args, state_manager, daemon_manager) -> int: minutes = int((uptime_delta.total_seconds() % 3600) // 60) uptime = f"{hours}h {minutes}m" except (ValueError, TypeError) as e: - logging.debug("Failed to parse started_at timestamp '%s': %s", started_at, e) + logger.debug("Failed to parse started_at timestamp '%s': %s", started_at, e) # Display detailed info print(f"\n{'='*60}") diff --git a/src/praisonai/praisonai/ui/components/aicoder.py b/src/praisonai/praisonai/ui/components/aicoder.py index 3bf853ca8..72e32fe57 100644 --- a/src/praisonai/praisonai/ui/components/aicoder.py +++ b/src/praisonai/praisonai/ui/components/aicoder.py @@ -142,6 +142,9 @@ async def read_file(self, file_path): except UnicodeDecodeError as e: logger.warning("aicoder: encoding error reading %s: %s", file_path, e) return None + except OSError as e: + logger.warning("aicoder: OS error reading %s: %s", file_path, e) + return None def get_shell_command(self, command: str) -> list: """