"""OpenRouter model registry for managing model configurations and aliases.""" import logging import os from pathlib import Path from typing import Optional from utils.file_utils import read_json_file from .base import ( ModelCapabilities, ProviderType, create_temperature_constraint, ) class OpenRouterModelRegistry: """Registry for managing OpenRouter model configurations and aliases.""" def __init__(self, config_path: Optional[str] = None): """Initialize the registry. Args: config_path: Path to config file. If None, uses default locations. """ self.alias_map: dict[str, str] = {} # alias -> model_name self.model_map: dict[str, ModelCapabilities] = {} # model_name -> config # Determine config path if config_path: # Direct config_path parameter self.config_path = Path(config_path) else: # Check environment variable first env_path = os.getenv("CUSTOM_MODELS_CONFIG_PATH") if env_path: # Environment variable path self.config_path = Path(env_path) else: # Try multiple potential locations for the config file potential_paths = [ Path(__file__).parent.parent / "conf" / "custom_models.json", # Development Path("conf/custom_models.json"), # uvx working directory Path.cwd() / "conf" / "custom_models.json", # Current working directory ] # Find first existing path self.config_path = None for path in potential_paths: if path.exists(): self.config_path = path break # If none found, default to the first one for error reporting if self.config_path is None: self.config_path = potential_paths[0] # Load configuration self.reload() def reload(self) -> None: """Reload configuration from disk.""" try: configs = self._read_config() self._build_maps(configs) caller_info = "" try: import inspect caller_frame = inspect.currentframe().f_back if caller_frame: caller_name = caller_frame.f_code.co_name caller_file = ( caller_frame.f_code.co_filename.split("/")[-1] if caller_frame.f_code.co_filename else "unknown" ) # Look for tool context while caller_frame: frame_locals = caller_frame.f_locals if "self" in frame_locals and hasattr(frame_locals["self"], "get_name"): tool_name = frame_locals["self"].get_name() caller_info = f" (called from {tool_name} tool)" break caller_frame = caller_frame.f_back if not caller_info: caller_info = f" (called from {caller_name} in {caller_file})" except Exception: # If frame inspection fails, just continue without caller info pass logging.debug( f"Loaded {len(self.model_map)} OpenRouter models with {len(self.alias_map)} aliases{caller_info}" ) except ValueError as e: # Re-raise ValueError only for duplicate aliases (critical config errors) logging.error(f"Failed to load OpenRouter model configuration: {e}") # Initialize with empty maps on failure self.alias_map = {} self.model_map = {} if "Duplicate alias" in str(e): raise except Exception as e: logging.error(f"Failed to load OpenRouter model configuration: {e}") # Initialize with empty maps on failure self.alias_map = {} self.model_map = {} def _read_config(self) -> list[ModelCapabilities]: """Read configuration from file. Returns: List of model configurations """ if not self.config_path.exists(): logging.warning(f"OpenRouter model config not found at {self.config_path}") return [] try: # Use centralized JSON reading utility data = read_json_file(str(self.config_path)) if data is None: raise ValueError(f"Could not read or parse JSON from {self.config_path}") # Parse models configs = [] for model_data in data.get("models", []): # Create ModelCapabilities directly from JSON data # Handle temperature_constraint conversion temp_constraint_str = model_data.get("temperature_constraint") temp_constraint = create_temperature_constraint(temp_constraint_str or "range") # Set provider-specific defaults based on is_custom flag is_custom = model_data.get("is_custom", False) if is_custom: model_data.setdefault("provider", ProviderType.CUSTOM) model_data.setdefault("friendly_name", f"Custom ({model_data.get('model_name', 'Unknown')})") else: model_data.setdefault("provider", ProviderType.OPENROUTER) model_data.setdefault("friendly_name", f"OpenRouter ({model_data.get('model_name', 'Unknown')})") model_data["temperature_constraint"] = temp_constraint # Remove the string version of temperature_constraint before creating ModelCapabilities if "temperature_constraint" in model_data and isinstance(model_data["temperature_constraint"], str): del model_data["temperature_constraint"] model_data["temperature_constraint"] = temp_constraint config = ModelCapabilities(**model_data) configs.append(config) return configs except ValueError: # Re-raise ValueError for specific config errors raise except Exception as e: raise ValueError(f"Error reading config from {self.config_path}: {e}") def _build_maps(self, configs: list[ModelCapabilities]) -> None: """Build alias and model maps from configurations. Args: configs: List of model configurations """ alias_map = {} model_map = {} for config in configs: # Add to model map model_map[config.model_name] = config # Add the model_name itself as an alias for case-insensitive lookup # But only if it's not already in the aliases list model_name_lower = config.model_name.lower() aliases_lower = [alias.lower() for alias in config.aliases] if model_name_lower not in aliases_lower: if model_name_lower in alias_map: existing_model = alias_map[model_name_lower] if existing_model != config.model_name: raise ValueError( f"Duplicate model name '{config.model_name}' (case-insensitive) found for models " f"'{existing_model}' and '{config.model_name}'" ) else: alias_map[model_name_lower] = config.model_name # Add aliases for alias in config.aliases: alias_lower = alias.lower() if alias_lower in alias_map: existing_model = alias_map[alias_lower] raise ValueError( f"Duplicate alias '{alias}' found for models '{existing_model}' and '{config.model_name}'" ) alias_map[alias_lower] = config.model_name # Atomic update self.alias_map = alias_map self.model_map = model_map def resolve(self, name_or_alias: str) -> Optional[ModelCapabilities]: """Resolve a model name or alias to configuration. Args: name_or_alias: Model name or alias to resolve Returns: Model configuration if found, None otherwise """ # Try alias lookup (case-insensitive) - this now includes model names too alias_lower = name_or_alias.lower() if alias_lower in self.alias_map: model_name = self.alias_map[alias_lower] return self.model_map.get(model_name) return None def get_capabilities(self, name_or_alias: str) -> Optional[ModelCapabilities]: """Get model capabilities for a name or alias. Args: name_or_alias: Model name or alias Returns: ModelCapabilities if found, None otherwise """ # Registry now returns ModelCapabilities directly return self.resolve(name_or_alias) def list_models(self) -> list[str]: """List all available model names.""" return list(self.model_map.keys()) def list_aliases(self) -> list[str]: """List all available aliases.""" return list(self.alias_map.keys())