From 2b3d29b3a7f4320a669d8611c8c3a810de8e85f4 Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:50:48 +0000 Subject: [PATCH 1/3] feat: add model catalogue and model-selection UX (fixes #2123) - Add 'praisonai models' command with list, describe, and validate subcommands - Create ModelCatalogue class with litellm integration and graceful fallback - Add model validation to LLM resolution with 'did you mean' suggestions - Validate model strings in YAML agent configurations - Warn when tools configured for non-tool-calling models - Cache model metadata locally with TTL to reduce API calls - Support filtering by provider and search patterns Co-authored-by: MervinPraison --- src/praisonai/praisonai/agents_generator.py | 40 ++ src/praisonai/praisonai/cli/app.py | 1 + .../praisonai/cli/commands/models.py | 247 ++++++++++ src/praisonai/praisonai/llm/catalogue.py | 461 ++++++++++++++++++ src/praisonai/praisonai/llm/credentials.py | 10 +- src/praisonai/praisonai/llm/env.py | 20 +- 6 files changed, 776 insertions(+), 3 deletions(-) create mode 100644 src/praisonai/praisonai/cli/commands/models.py create mode 100644 src/praisonai/praisonai/llm/catalogue.py diff --git a/src/praisonai/praisonai/agents_generator.py b/src/praisonai/praisonai/agents_generator.py index 6c25b4e07..6c747aac4 100644 --- a/src/praisonai/praisonai/agents_generator.py +++ b/src/praisonai/praisonai/agents_generator.py @@ -523,6 +523,14 @@ def _validate_agents_config(self, config): 'planning_tools', 'planning', 'autonomy', 'guardrails', 'streaming', 'stream', 'approval', 'skills', 'cli_backend', 'runtime', 'reflection', 'handoff', 'web', 'web_fetch' } + + # Try to load model catalogue for validation + model_catalogue = None + try: + from .llm.catalogue import ModelCatalogue + model_catalogue = ModelCatalogue() + except ImportError: + pass # Catalogue not available for section_name in ('agents', 'roles'): section = config.get(section_name, {}) @@ -548,6 +556,38 @@ def _validate_agents_config(self, config): self.logger.warning( f"Unknown field '{field_name}' in {entity_name} '{name}'.{suggestion}" ) + + # Validate model/llm values if catalogue available + if model_catalogue: + for model_field in ('llm', 'function_calling_llm'): + model_value = section_config.get(model_field) + if model_value and isinstance(model_value, str): + try: + model_catalogue.validate_model(model_value) + except ValueError as e: + self.logger.warning( + f"Invalid model '{model_value}' in {entity_name} '{name}' field '{model_field}': {e}" + ) + + # Check for tools configured with non-tool-calling models + if section_config.get('tools'): + llm_value = section_config.get('llm') + if llm_value: + model_info = model_catalogue.describe_model(llm_value) + if model_info and not model_info.get('supports_tools'): + self.logger.warning( + f"{entity_name.capitalize()} '{name}' has tools configured but model '{llm_value}' does not support tool calling" + ) + + # Also validate top-level llm/model config + if model_catalogue: + for model_field in ('llm', 'model'): + model_value = config.get(model_field) + if model_value and isinstance(model_value, str): + try: + model_catalogue.validate_model(model_value) + except ValueError as e: + self.logger.warning(f"Invalid model '{model_value}' in top-level '{model_field}': {e}") diff --git a/src/praisonai/praisonai/cli/app.py b/src/praisonai/praisonai/cli/app.py index 6c292bc43..a84f53245 100644 --- a/src/praisonai/praisonai/cli/app.py +++ b/src/praisonai/praisonai/cli/app.py @@ -178,6 +178,7 @@ class OutputFormat(str, Enum): "tracker": (".commands.tracker", "app", "Autonomous agent tracking with step-by-step analysis"), "github": (".commands.github", "app", "GitHub native context tracking and Issue triage"), "managed": (".commands.managed", "app", "Managed Agents (Anthropic cloud-hosted backend)"), + "models": (".commands.models", "app", "List and describe available models"), # Moltbot-inspired commands "bot": (".commands.bot", "app", "Messaging bots with full agent capabilities"), diff --git a/src/praisonai/praisonai/cli/commands/models.py b/src/praisonai/praisonai/cli/commands/models.py new file mode 100644 index 000000000..13d8753b3 --- /dev/null +++ b/src/praisonai/praisonai/cli/commands/models.py @@ -0,0 +1,247 @@ +""" +Models command group for PraisonAI CLI. + +Provides commands to list and describe available LLM models. +""" + +from typing import Optional, List, Dict, Any +import typer +import json + +from ..output.console import get_output_controller + +app = typer.Typer(help="List and describe available models") + + +@app.command(name="list") +def list_models( + provider: Optional[str] = typer.Option(None, "--provider", "-p", help="Filter by provider name"), + json_output: bool = typer.Option(False, "--json", help="Output as JSON"), + search: Optional[str] = typer.Argument(None, help="Filter by model name pattern"), +): + """ + List available models with capabilities and limits. + + Examples: + praisonai models list + praisonai models list --provider openai + praisonai models list gpt + praisonai models list --json + """ + output = get_output_controller() + + try: + from ...llm.catalogue import ModelCatalogue + catalogue = ModelCatalogue() + models = catalogue.list_models(provider=provider, search=search) + + if json_output: + output.print(json.dumps(models, indent=2)) + return + + if not models: + output.print_info("No models found matching your criteria") + return + + # Group models by provider for better display + by_provider: Dict[str, List[Dict[str, Any]]] = {} + for model in models: + provider_name = model.get("provider", "unknown") + if provider_name not in by_provider: + by_provider[provider_name] = [] + by_provider[provider_name].append(model) + + # Display models in a table format + from rich.table import Table + from rich.console import Console + + console = Console() + + for provider_name, provider_models in sorted(by_provider.items()): + table = Table(title=f"\n{provider_name.upper()} Models", show_header=True, header_style="bold cyan") + table.add_column("Model ID", style="green") + table.add_column("Context", justify="right") + table.add_column("Output", justify="right") + table.add_column("Capabilities", style="yellow") + table.add_column("Cost (1K)", justify="right", style="dim") + + for model in sorted(provider_models, key=lambda x: x.get("id", "")): + # Format capabilities + capabilities = [] + if model.get("supports_tools"): + capabilities.append("🔧 tools") + if model.get("supports_vision"): + capabilities.append("👁️ vision") + if model.get("supports_reasoning"): + capabilities.append("🧠 reasoning") + cap_str = " ".join(capabilities) if capabilities else "-" + + # Format costs + cost_str = "-" + if model.get("input_cost") is not None and model.get("output_cost") is not None: + cost_str = f"${model['input_cost']:.4f}/${model['output_cost']:.4f}" + + # Format context/output limits + context = str(model.get("max_context", "-")) + output_limit = str(model.get("max_output", "-")) + + table.add_row( + model.get("id", "-"), + context, + output_limit, + cap_str, + cost_str + ) + + console.print(table) + + except ImportError: + output.print_warning("Model catalogue not available. Install litellm for full model listing:") + output.print(" pip install 'praisonai[litellm]'") + + # Show basic fallback models + fallback_models = [ + {"provider": "OpenAI", "models": ["gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"]}, + {"provider": "Anthropic", "models": ["claude-3-5-sonnet-latest", "claude-3-opus-latest", "claude-3-haiku-latest"]}, + {"provider": "Google", "models": ["gemini-1.5-pro", "gemini-1.5-flash"]}, + {"provider": "Groq", "models": ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"]}, + ] + + if provider: + fallback_models = [p for p in fallback_models if p["provider"].lower() == provider.lower()] + + for prov in fallback_models: + output.print_subheader(f"{prov['provider']} Models") + for model in prov['models']: + if not search or search.lower() in model.lower(): + output.print(f" • {model}") + except Exception as e: + output.print_error(f"Error listing models: {e}") + raise typer.Exit(1) + + +@app.command(name="describe") +def describe_model( + model: str = typer.Argument(..., help="Model ID to describe (e.g., gpt-4o, claude-3-5-sonnet)"), +): + """ + Show detailed information for a specific model. + + Examples: + praisonai models describe gpt-4o + praisonai models describe claude-3-5-sonnet + praisonai models describe gemini-1.5-pro + """ + output = get_output_controller() + + try: + from ...llm.catalogue import ModelCatalogue + catalogue = ModelCatalogue() + info = catalogue.describe_model(model) + + if not info: + output.print_error(f"Model '{model}' not found") + + # Try to suggest similar models + suggestions = catalogue.get_suggestions(model) + if suggestions: + output.print_info("Did you mean one of these?") + for suggestion in suggestions[:5]: + output.print(f" • {suggestion}") + raise typer.Exit(1) + + # Display model details + output.print_subheader(f"Model: {info.get('id', model)}") + + if info.get("provider"): + output.print(f"Provider: {info['provider']}") + + if info.get("description"): + output.print(f"Description: {info['description']}") + + # Capabilities + output.print("\nCapabilities:") + output.print(f" • Tool calling: {'✅' if info.get('supports_tools') else '❌'}") + output.print(f" • Vision: {'✅' if info.get('supports_vision') else '❌'}") + output.print(f" • Reasoning: {'✅' if info.get('supports_reasoning') else '❌'}") + output.print(f" • Streaming: {'✅' if info.get('supports_streaming', True) else '❌'}") + + # Limits + output.print("\nLimits:") + if info.get("max_context"): + output.print(f" • Context window: {info['max_context']:,} tokens") + if info.get("max_output"): + output.print(f" • Max output: {info['max_output']:,} tokens") + + # Costs + if info.get("input_cost") is not None: + output.print("\nCosts (per 1K tokens):") + output.print(f" • Input: ${info['input_cost']:.6f}") + if info.get("output_cost") is not None: + output.print(f" • Output: ${info['output_cost']:.6f}") + + # Notes + if info.get("notes"): + output.print(f"\nNotes: {info['notes']}") + + except ImportError: + output.print_warning("Model catalogue not available. Install litellm for detailed model info:") + output.print(" pip install 'praisonai[litellm]'") + except Exception as e: + output.print_error(f"Error describing model: {e}") + raise typer.Exit(1) + + +@app.command(name="validate") +def validate_model( + model: str = typer.Argument(..., help="Model ID to validate"), +): + """ + Validate if a model ID is valid and available. + + Examples: + praisonai models validate gpt-4o + praisonai models validate invalid-model + """ + output = get_output_controller() + + try: + from ...llm.catalogue import ModelCatalogue + catalogue = ModelCatalogue() + + if catalogue.is_valid_model(model): + output.print_success(f"✅ '{model}' is a valid model") + + # Show basic info if available + info = catalogue.describe_model(model) + if info: + caps = [] + if info.get("supports_tools"): + caps.append("tool-calling") + if info.get("supports_vision"): + caps.append("vision") + if info.get("supports_reasoning"): + caps.append("reasoning") + if caps: + output.print(f"Capabilities: {', '.join(caps)}") + else: + output.print_error(f"❌ '{model}' is not a valid model") + + # Suggest alternatives + suggestions = catalogue.get_suggestions(model) + if suggestions: + output.print_info("Did you mean one of these?") + for suggestion in suggestions[:5]: + output.print(f" • {suggestion}") + raise typer.Exit(1) + + except ImportError: + output.print_warning("Model catalogue not available. Install litellm for model validation:") + output.print(" pip install 'praisonai[litellm]'") + except Exception as e: + output.print_error(f"Error validating model: {e}") + raise typer.Exit(1) + + +if __name__ == "__main__": + app() \ No newline at end of file diff --git a/src/praisonai/praisonai/llm/catalogue.py b/src/praisonai/praisonai/llm/catalogue.py new file mode 100644 index 000000000..8f12b4764 --- /dev/null +++ b/src/praisonai/praisonai/llm/catalogue.py @@ -0,0 +1,461 @@ +""" +Model catalogue for discovering and validating LLM models. + +Provides model metadata, capabilities, and validation with litellm integration +and graceful fallback when litellm is not available. +""" + +import os +import json +import time +import difflib +from pathlib import Path +from typing import Optional, List, Dict, Any +from dataclasses import dataclass, asdict + + +@dataclass +class ModelInfo: + """Model metadata and capabilities.""" + id: str + provider: str + description: Optional[str] = None + max_context: Optional[int] = None + max_output: Optional[int] = None + input_cost: Optional[float] = None # Cost per 1K input tokens + output_cost: Optional[float] = None # Cost per 1K output tokens + supports_tools: bool = False + supports_vision: bool = False + supports_reasoning: bool = False + supports_streaming: bool = True + notes: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return asdict(self) + + +# Fallback models when litellm is not available +FALLBACK_MODELS = [ + # OpenAI + ModelInfo( + id="gpt-4o", + provider="openai", + description="Most capable GPT-4 model, multimodal", + max_context=128000, + max_output=16384, + supports_tools=True, + supports_vision=True, + supports_reasoning=True, + ), + ModelInfo( + id="gpt-4o-mini", + provider="openai", + description="Affordable small model for fast tasks", + max_context=128000, + max_output=16384, + supports_tools=True, + supports_vision=True, + ), + ModelInfo( + id="gpt-3.5-turbo", + provider="openai", + description="Fast, affordable model for simple tasks", + max_context=16385, + max_output=4096, + supports_tools=True, + ), + ModelInfo( + id="o1", + provider="openai", + description="Advanced reasoning model", + max_context=200000, + max_output=100000, + supports_reasoning=True, + supports_tools=False, + notes="No streaming, tools, or system messages", + ), + ModelInfo( + id="o1-mini", + provider="openai", + description="Faster reasoning model", + max_context=128000, + max_output=65536, + supports_reasoning=True, + supports_tools=False, + notes="No streaming, tools, or system messages", + ), + + # Anthropic + ModelInfo( + id="claude-3-5-sonnet-latest", + provider="anthropic", + description="Most intelligent Claude model", + max_context=200000, + max_output=8192, + supports_tools=True, + supports_vision=True, + supports_reasoning=True, + ), + ModelInfo( + id="claude-3-5-haiku-latest", + provider="anthropic", + description="Fast and affordable Claude model", + max_context=200000, + max_output=8192, + supports_tools=True, + supports_vision=True, + ), + ModelInfo( + id="claude-3-opus-latest", + provider="anthropic", + description="Powerful model for complex tasks", + max_context=200000, + max_output=4096, + supports_tools=True, + supports_vision=True, + supports_reasoning=True, + ), + + # Google + ModelInfo( + id="gemini-1.5-pro", + provider="google", + description="Advanced multimodal model", + max_context=2000000, + max_output=8192, + supports_tools=True, + supports_vision=True, + supports_reasoning=True, + ), + ModelInfo( + id="gemini-1.5-flash", + provider="google", + description="Fast multimodal model", + max_context=1000000, + max_output=8192, + supports_tools=True, + supports_vision=True, + ), + ModelInfo( + id="gemini-2.0-flash-exp", + provider="google", + description="Experimental next-gen model", + max_context=1000000, + max_output=8192, + supports_tools=True, + supports_vision=True, + supports_reasoning=True, + ), + + # Groq + ModelInfo( + id="llama-3.3-70b-versatile", + provider="groq", + description="Latest Llama model, very fast", + max_context=128000, + max_output=32768, + supports_tools=True, + ), + ModelInfo( + id="mixtral-8x7b-32768", + provider="groq", + description="Fast MoE model", + max_context=32768, + max_output=32768, + supports_tools=True, + ), + + # Ollama (local) + ModelInfo( + id="llama3.2", + provider="ollama", + description="Local Llama model", + max_context=128000, + supports_tools=True, + notes="Requires Ollama running locally", + ), +] + + +class ModelCatalogue: + """ + Model catalogue with litellm integration and caching. + """ + + def __init__(self, cache_dir: Optional[Path] = None, cache_ttl: int = 3600): + """ + Initialize model catalogue. + + Args: + cache_dir: Directory for caching model data + cache_ttl: Cache TTL in seconds (default: 1 hour) + """ + self.cache_dir = cache_dir or Path.home() / ".praison" / "cache" + self.cache_file = self.cache_dir / "models.json" + self.cache_ttl = cache_ttl + self._models: Optional[List[ModelInfo]] = None + + def _load_from_litellm(self) -> Optional[List[ModelInfo]]: + """ + Load model information from litellm if available. + + Returns: + List of ModelInfo objects or None if litellm not available + """ + try: + import litellm + from litellm import model_cost, model_list + + models = [] + + # Get model cost data (includes context limits and pricing) + cost_data = {} + if hasattr(litellm, 'model_cost') and litellm.model_cost: + cost_data = litellm.model_cost + + # Process known models from litellm + for model_id in model_list() if hasattr(litellm, 'model_list') else []: + # Determine provider from model ID + provider = "unknown" + if model_id.startswith(("gpt-", "o1", "text-", "davinci", "curie", "babbage", "ada")): + provider = "openai" + elif model_id.startswith(("claude-", "anthropic/")): + provider = "anthropic" + elif model_id.startswith(("gemini-", "google/", "palm-")): + provider = "google" + elif model_id.startswith("groq/"): + provider = "groq" + elif model_id.startswith("ollama/"): + provider = "ollama" + elif model_id.startswith("cohere/"): + provider = "cohere" + elif "/" in model_id: + provider = model_id.split("/")[0] + + # Get cost and context info + info = cost_data.get(model_id, {}) + + # Determine capabilities (heuristics based on model name) + supports_tools = not any(x in model_id for x in ["o1", "embedding", "whisper", "tts", "dall-e"]) + supports_vision = any(x in model_id for x in ["vision", "gpt-4o", "gemini", "claude-3"]) + supports_reasoning = any(x in model_id for x in ["o1", "gpt-4o", "claude-3-5", "gemini-2"]) + + models.append(ModelInfo( + id=model_id, + provider=provider, + max_context=info.get("max_tokens") or info.get("max_input_tokens"), + max_output=info.get("max_output_tokens"), + input_cost=info.get("input_cost_per_token") * 1000 if info.get("input_cost_per_token") else None, + output_cost=info.get("output_cost_per_token") * 1000 if info.get("output_cost_per_token") else None, + supports_tools=supports_tools, + supports_vision=supports_vision, + supports_reasoning=supports_reasoning, + )) + + # Merge with fallback models to ensure completeness + model_ids = {m.id for m in models} + for fallback in FALLBACK_MODELS: + if fallback.id not in model_ids: + models.append(fallback) + + return models + + except ImportError: + # litellm not available + return None + except Exception: + # Error loading from litellm, fall back + return None + + def _load_from_cache(self) -> Optional[List[ModelInfo]]: + """ + Load models from cache if valid. + + Returns: + List of ModelInfo objects or None if cache invalid/missing + """ + if not self.cache_file.exists(): + return None + + try: + # Check cache age + cache_age = time.time() - self.cache_file.stat().st_mtime + if cache_age > self.cache_ttl: + return None + + # Load cache + with open(self.cache_file, 'r') as f: + data = json.load(f) + + models = [] + for item in data.get("models", []): + models.append(ModelInfo(**item)) + + return models + + except Exception: + return None + + def _save_to_cache(self, models: List[ModelInfo]) -> None: + """Save models to cache.""" + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + + data = { + "timestamp": time.time(), + "models": [m.to_dict() for m in models], + } + + with open(self.cache_file, 'w') as f: + json.dump(data, f, indent=2) + + except Exception: + # Ignore cache write errors + pass + + def _get_models(self) -> List[ModelInfo]: + """ + Get models from cache, litellm, or fallback. + + Returns: + List of available models + """ + if self._models is not None: + return self._models + + # Try cache first + models = self._load_from_cache() + + # Try litellm if cache miss + if models is None: + models = self._load_from_litellm() + + # Save to cache if loaded from litellm + if models: + self._save_to_cache(models) + + # Fall back to static list + if models is None: + models = FALLBACK_MODELS + + self._models = models + return models + + def list_models( + self, + provider: Optional[str] = None, + search: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """ + List available models. + + Args: + provider: Filter by provider name + search: Filter by model ID pattern + + Returns: + List of model dictionaries + """ + models = self._get_models() + + # Apply filters + if provider: + models = [m for m in models if m.provider.lower() == provider.lower()] + + if search: + search_lower = search.lower() + models = [m for m in models if search_lower in m.id.lower()] + + return [m.to_dict() for m in models] + + def describe_model(self, model_id: str) -> Optional[Dict[str, Any]]: + """ + Get detailed information for a model. + + Args: + model_id: Model ID to describe + + Returns: + Model info dictionary or None if not found + """ + models = self._get_models() + + # Find exact match first + for model in models: + if model.id == model_id: + return model.to_dict() + + # Try case-insensitive match + model_id_lower = model_id.lower() + for model in models: + if model.id.lower() == model_id_lower: + return model.to_dict() + + return None + + def is_valid_model(self, model_id: str) -> bool: + """ + Check if a model ID is valid. + + Args: + model_id: Model ID to validate + + Returns: + True if valid, False otherwise + """ + return self.describe_model(model_id) is not None + + def get_suggestions(self, model_id: str, max_suggestions: int = 5) -> List[str]: + """ + Get model ID suggestions for a potentially misspelled ID. + + Args: + model_id: Model ID that might be misspelled + max_suggestions: Maximum number of suggestions + + Returns: + List of suggested model IDs + """ + models = self._get_models() + all_ids = [m.id for m in models] + + # Use difflib to find close matches + suggestions = difflib.get_close_matches( + model_id, + all_ids, + n=max_suggestions, + cutoff=0.6 + ) + + return suggestions + + def validate_model(self, model_id: str) -> str: + """ + Validate a model ID and return the normalized ID. + + Args: + model_id: Model ID to validate + + Returns: + Normalized model ID + + Raises: + ValueError: If model ID is invalid with suggestions + """ + # Check if valid + if self.is_valid_model(model_id): + # Return the correctly cased version + info = self.describe_model(model_id) + if info: + return info["id"] + return model_id + + # Invalid - provide suggestions + suggestions = self.get_suggestions(model_id) + + if suggestions: + suggestion_text = "Did you mean: " + ", ".join(suggestions[:3]) + raise ValueError(f"Unknown model '{model_id}'. {suggestion_text}") + else: + raise ValueError(f"Unknown model '{model_id}'") \ No newline at end of file diff --git a/src/praisonai/praisonai/llm/credentials.py b/src/praisonai/praisonai/llm/credentials.py index 4641d9116..8e6e6fe93 100644 --- a/src/praisonai/praisonai/llm/credentials.py +++ b/src/praisonai/praisonai/llm/credentials.py @@ -33,7 +33,11 @@ def _credential_lookup(provider: str) -> Optional[Dict[str, Any]]: return None -def resolve_llm_endpoint_with_credentials(*, default_base: str = "https://api.openai.com/v1") -> LLMEndpoint: +def resolve_llm_endpoint_with_credentials( + *, + default_base: str = "https://api.openai.com/v1", + validate_model: bool = True +) -> LLMEndpoint: """ Resolve LLM endpoint configuration with credential store fallback. @@ -42,13 +46,15 @@ def resolve_llm_endpoint_with_credentials(*, default_base: str = "https://api.op Args: default_base: Default base URL if none found anywhere + validate_model: Whether to validate the model ID (default: True) Returns: LLMEndpoint with resolved configuration """ return resolve_llm_endpoint( default_base=default_base, - fallback_lookup=_credential_lookup + fallback_lookup=_credential_lookup, + validate_model=validate_model ) diff --git a/src/praisonai/praisonai/llm/env.py b/src/praisonai/praisonai/llm/env.py index 441d6be4f..13c140410 100644 --- a/src/praisonai/praisonai/llm/env.py +++ b/src/praisonai/praisonai/llm/env.py @@ -61,7 +61,8 @@ def resolve_llm_endpoint( *, default_base: str = _DEFAULT_BASE, fallback_lookup: Optional[Callable[[str], Optional[dict]]] = None, - resolved_config: Optional['ResolvedConfig'] = None + resolved_config: Optional['ResolvedConfig'] = None, + validate_model: bool = True ) -> LLMEndpoint: """ Resolve LLM endpoint configuration from environment variables and config. @@ -78,6 +79,7 @@ def resolve_llm_endpoint( default_base: Default base URL if none found in environment variables fallback_lookup: Optional callable to get stored credentials (provider_name) -> dict resolved_config: Optional resolved configuration from the resolver + validate_model: Whether to validate the model ID (default: True) Returns: LLMEndpoint with resolved configuration @@ -110,6 +112,22 @@ def resolve_llm_endpoint( os.environ.get("OPENAI_API_KEY") if key_var == "OPENAI_API_KEY" else None ) + # Validate model if requested + if validate_model: + try: + from ..llm.catalogue import ModelCatalogue + catalogue = ModelCatalogue() + # This will raise ValueError with suggestions if invalid + validated_model = catalogue.validate_model(model) + # Use the normalized model ID + model = validated_model + except ImportError: + # Catalogue not available, skip validation + pass + except ValueError: + # Re-raise validation errors + raise + # If no env API key found and fallback lookup provided, try stored credentials fallback_model = None fallback_base = None From 63e303851bbcd67dfe0ddf4648cb397bc096090a Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:07:33 +0000 Subject: [PATCH 2/3] fix: resolve critical bugs in model catalogue implementation - Fix TypeError: model_list is a list attribute not callable in litellm - Change validate_model default to False to preserve backward compatibility - Move model validation before provider resolution to ensure correct key/base selection - Add validation for fallback models from stored credentials - Skip catalogue validation for local YAML model aliases - Ensure consistent JSON output in CLI when litellm unavailable Co-authored-by: Mervin Praison --- src/praisonai/praisonai/agents_generator.py | 12 +++++ .../praisonai/cli/commands/models.py | 33 ++++++++----- src/praisonai/praisonai/llm/catalogue.py | 5 +- src/praisonai/praisonai/llm/credentials.py | 4 +- src/praisonai/praisonai/llm/env.py | 47 ++++++++++++------- 5 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/praisonai/praisonai/agents_generator.py b/src/praisonai/praisonai/agents_generator.py index 6c747aac4..47544b900 100644 --- a/src/praisonai/praisonai/agents_generator.py +++ b/src/praisonai/praisonai/agents_generator.py @@ -524,6 +524,12 @@ def _validate_agents_config(self, config): 'approval', 'skills', 'cli_backend', 'runtime', 'reflection', 'handoff', 'web', 'web_fetch' } + # Collect YAML-local model aliases (valid even if not in global catalogue) + local_model_aliases = set() + models_cfg = config.get("models", {}) + if isinstance(models_cfg, dict): + local_model_aliases = {k for k in models_cfg.keys() if isinstance(k, str)} + # Try to load model catalogue for validation model_catalogue = None try: @@ -562,6 +568,9 @@ def _validate_agents_config(self, config): for model_field in ('llm', 'function_calling_llm'): model_value = section_config.get(model_field) if model_value and isinstance(model_value, str): + # Skip validation for local model aliases defined in YAML + if model_value in local_model_aliases: + continue try: model_catalogue.validate_model(model_value) except ValueError as e: @@ -584,6 +593,9 @@ def _validate_agents_config(self, config): for model_field in ('llm', 'model'): model_value = config.get(model_field) if model_value and isinstance(model_value, str): + # Skip validation for local model aliases defined in YAML + if model_value in local_model_aliases: + continue try: model_catalogue.validate_model(model_value) except ValueError as e: diff --git a/src/praisonai/praisonai/cli/commands/models.py b/src/praisonai/praisonai/cli/commands/models.py index 13d8753b3..078fd46be 100644 --- a/src/praisonai/praisonai/cli/commands/models.py +++ b/src/praisonai/praisonai/cli/commands/models.py @@ -96,9 +96,6 @@ def list_models( console.print(table) except ImportError: - output.print_warning("Model catalogue not available. Install litellm for full model listing:") - output.print(" pip install 'praisonai[litellm]'") - # Show basic fallback models fallback_models = [ {"provider": "OpenAI", "models": ["gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"]}, @@ -107,14 +104,28 @@ def list_models( {"provider": "Groq", "models": ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"]}, ] - if provider: - fallback_models = [p for p in fallback_models if p["provider"].lower() == provider.lower()] - - for prov in fallback_models: - output.print_subheader(f"{prov['provider']} Models") - for model in prov['models']: - if not search or search.lower() in model.lower(): - output.print(f" • {model}") + if json_output: + # Provide JSON output for consistency + models_list = [] + for prov in fallback_models: + if provider and prov["provider"].lower() != provider.lower(): + continue + for model_id in prov['models']: + if not search or search.lower() in model_id.lower(): + models_list.append({"id": model_id, "provider": prov["provider"]}) + output.print(json.dumps(models_list, indent=2)) + else: + output.print_warning("Model catalogue not available. Install litellm for full model listing:") + output.print(" pip install 'praisonai[litellm]'") + + if provider: + fallback_models = [p for p in fallback_models if p["provider"].lower() == provider.lower()] + + for prov in fallback_models: + output.print_subheader(f"{prov['provider']} Models") + for model in prov['models']: + if not search or search.lower() in model.lower(): + output.print(f" • {model}") except Exception as e: output.print_error(f"Error listing models: {e}") raise typer.Exit(1) diff --git a/src/praisonai/praisonai/llm/catalogue.py b/src/praisonai/praisonai/llm/catalogue.py index 8f12b4764..f06311715 100644 --- a/src/praisonai/praisonai/llm/catalogue.py +++ b/src/praisonai/praisonai/llm/catalogue.py @@ -205,7 +205,6 @@ def _load_from_litellm(self) -> Optional[List[ModelInfo]]: """ try: import litellm - from litellm import model_cost, model_list models = [] @@ -215,7 +214,9 @@ def _load_from_litellm(self) -> Optional[List[ModelInfo]]: cost_data = litellm.model_cost # Process known models from litellm - for model_id in model_list() if hasattr(litellm, 'model_list') else []: + # model_list is a list attribute, not a callable + model_ids = getattr(litellm, 'model_list', []) + for model_id in model_ids: # Determine provider from model ID provider = "unknown" if model_id.startswith(("gpt-", "o1", "text-", "davinci", "curie", "babbage", "ada")): diff --git a/src/praisonai/praisonai/llm/credentials.py b/src/praisonai/praisonai/llm/credentials.py index 8e6e6fe93..969f928fe 100644 --- a/src/praisonai/praisonai/llm/credentials.py +++ b/src/praisonai/praisonai/llm/credentials.py @@ -36,7 +36,7 @@ def _credential_lookup(provider: str) -> Optional[Dict[str, Any]]: def resolve_llm_endpoint_with_credentials( *, default_base: str = "https://api.openai.com/v1", - validate_model: bool = True + validate_model: bool = False ) -> LLMEndpoint: """ Resolve LLM endpoint configuration with credential store fallback. @@ -46,7 +46,7 @@ def resolve_llm_endpoint_with_credentials( Args: default_base: Default base URL if none found anywhere - validate_model: Whether to validate the model ID (default: True) + validate_model: Whether to validate the model ID (default: False) Returns: LLMEndpoint with resolved configuration diff --git a/src/praisonai/praisonai/llm/env.py b/src/praisonai/praisonai/llm/env.py index 13c140410..8ae5caa20 100644 --- a/src/praisonai/praisonai/llm/env.py +++ b/src/praisonai/praisonai/llm/env.py @@ -62,7 +62,7 @@ def resolve_llm_endpoint( default_base: str = _DEFAULT_BASE, fallback_lookup: Optional[Callable[[str], Optional[dict]]] = None, resolved_config: Optional['ResolvedConfig'] = None, - validate_model: bool = True + validate_model: bool = False ) -> LLMEndpoint: """ Resolve LLM endpoint configuration from environment variables and config. @@ -79,7 +79,7 @@ def resolve_llm_endpoint( default_base: Default base URL if none found in environment variables fallback_lookup: Optional callable to get stored credentials (provider_name) -> dict resolved_config: Optional resolved configuration from the resolver - validate_model: Whether to validate the model ID (default: True) + validate_model: Whether to validate the model ID (default: False) Returns: LLMEndpoint with resolved configuration @@ -95,6 +95,23 @@ def resolve_llm_endpoint( else: model = _DEFAULT_MODEL + # Validate model if requested (before provider resolution) + catalogue = None + if validate_model: + try: + from ..llm.catalogue import ModelCatalogue + catalogue = ModelCatalogue() + # This will raise ValueError with suggestions if invalid + validated_model = catalogue.validate_model(model) + # Use the normalized model ID + model = validated_model + except ImportError: + # Catalogue not available, skip validation + pass + except ValueError: + # Re-raise validation errors + raise + key_var, provider_base = _provider_from_model(model) # Check for base URL in env, then config, then provider default @@ -112,22 +129,6 @@ def resolve_llm_endpoint( os.environ.get("OPENAI_API_KEY") if key_var == "OPENAI_API_KEY" else None ) - # Validate model if requested - if validate_model: - try: - from ..llm.catalogue import ModelCatalogue - catalogue = ModelCatalogue() - # This will raise ValueError with suggestions if invalid - validated_model = catalogue.validate_model(model) - # Use the normalized model ID - model = validated_model - except ImportError: - # Catalogue not available, skip validation - pass - except ValueError: - # Re-raise validation errors - raise - # If no env API key found and fallback lookup provided, try stored credentials fallback_model = None fallback_base = None @@ -140,6 +141,16 @@ def resolve_llm_endpoint( api_key = cred["api_key"] fallback_model = cred.get("model") fallback_base = cred.get("base_url") + # Validate fallback model if validation is enabled + if validate_model and fallback_model and catalogue: + try: + fallback_model = catalogue.validate_model(fallback_model) + except ValueError: + # Skip this credential if model is invalid + api_key = None + fallback_model = None + fallback_base = None + continue break except Exception: # Ignore fallback lookup errors From 01dbc2cf27f1f6921c6e71ecee98a2e561f35ef5 Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:55:04 +0000 Subject: [PATCH 3/3] fix: prevent typer.Exit from being swallowed by generic exception handlers - Add explicit typer.Exit handlers in describe_model and validate_model commands - Re-raise typer.Exit to avoid spurious error messages when models are not found - Preserve exception context with 'from e' for better debugging --- src/praisonai/praisonai/cli/commands/models.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/praisonai/praisonai/cli/commands/models.py b/src/praisonai/praisonai/cli/commands/models.py index 078fd46be..11ab45d11 100644 --- a/src/praisonai/praisonai/cli/commands/models.py +++ b/src/praisonai/praisonai/cli/commands/models.py @@ -198,9 +198,12 @@ def describe_model( except ImportError: output.print_warning("Model catalogue not available. Install litellm for detailed model info:") output.print(" pip install 'praisonai[litellm]'") + except typer.Exit: + # Re-raise typer.Exit without catching it + raise except Exception as e: output.print_error(f"Error describing model: {e}") - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command(name="validate") @@ -249,9 +252,12 @@ def validate_model( except ImportError: output.print_warning("Model catalogue not available. Install litellm for model validation:") output.print(" pip install 'praisonai[litellm]'") + except typer.Exit: + # Re-raise typer.Exit without catching it + raise except Exception as e: output.print_error(f"Error validating model: {e}") - raise typer.Exit(1) + raise typer.Exit(1) from e if __name__ == "__main__":