From db9db56e7a76b4ffd5e2b21e482786bf660c9082 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 4 Mar 2026 00:47:35 +0800 Subject: [PATCH] feat(agent): enhance LLM config loading with priority system Refactor model factory to support three configuration sources in priority order: 1. Direct llm_config dictionary parameter 2. Fetch from llm_model_configs system via connector_factory (requires user_id and jwt_token) 3. Environment variables as fallback for backward compatibility This improves flexibility by allowing runtime configuration while maintaining compatibility with existing environment-based setups. --- .../agent/gns3_copilot/agent/model_factory.py | 416 +++++------------- .../gns3_copilot/gns3_client/__init__.py | 2 + .../gns3_client/connector_factory.py | 57 +++ 3 files changed, 162 insertions(+), 313 deletions(-) diff --git a/gns3server/agent/gns3_copilot/agent/model_factory.py b/gns3server/agent/gns3_copilot/agent/model_factory.py index 2b8c2e91f..5ab619433 100644 --- a/gns3server/agent/gns3_copilot/agent/model_factory.py +++ b/gns3server/agent/gns3_copilot/agent/model_factory.py @@ -2,33 +2,45 @@ Model Factory for FlowNet-Lab Agent This module provides factory functions to create fresh LLM model instances. -Configuration can be loaded from: -1. Passed llm_config dictionary (from new llm_model_configs system) -2. Environment variables (fallback for backward compatibility) +Configuration is loaded from the llm_model_configs system via connector_factory. """ import logging import os from typing import Any, Optional +from uuid import UUID from langchain.chat_models import init_chat_model +from gns3_copilot.gns3_client import get_llm_config + logger = logging.getLogger(__name__) -def _load_llm_config(llm_config: Optional[dict[str, Any]] = None) -> dict[str, str]: +def _load_llm_config( + user_id: Optional[UUID] = None, + jwt_token: Optional[str] = None, + llm_config: Optional[dict[str, Any]] = None, +) -> dict[str, str]: """ - Load model configuration from llm_config dict or environment variables. + Load model configuration from llm_config dict or fetch from llm_model_configs system. + + Priority order: + 1. Provided llm_config dictionary (highest priority) + 2. Fetch from llm_model_configs system via connector_factory (requires user_id and jwt_token) + 3. Environment variables (fallback for backward compatibility) Args: - llm_config: Optional configuration dictionary from llm_model_configs system. - If not provided, will load from environment variables. + user_id: User UUID for fetching config from database + jwt_token: JWT token for API authentication + llm_config: Optional configuration dictionary to use directly Returns: Dictionary containing model configuration. """ + # Priority 1: Use provided llm_config dictionary if llm_config: - # Use provided configuration (from llm_model_configs system) + logger.info("Using provided llm_config dictionary") return { "model_name": llm_config.get("model", ""), "model_provider": llm_config.get("provider", ""), @@ -36,32 +48,66 @@ def _load_llm_config(llm_config: Optional[dict[str, Any]] = None) -> dict[str, s "base_url": llm_config.get("base_url", ""), "temperature": str(llm_config.get("temperature", "0")), } - else: - # Fallback to environment variables - return { - "model_name": os.getenv("MODEL_NAME", ""), - "model_provider": os.getenv("MODE_PROVIDER", ""), - "api_key": os.getenv("MODEL_API_KEY", ""), - "base_url": os.getenv("BASE_URL", ""), - "temperature": os.getenv("TEMPERATURE", "0"), - } + + # Priority 2: Fetch from llm_model_configs system via connector_factory + if user_id and jwt_token: + logger.info(f"Fetching LLM config from database for user {user_id}") + config = get_llm_config(user_id=user_id, jwt_token=jwt_token) + + if config: + logger.info( + f"Successfully loaded LLM config from database: " + f"provider={config.get('provider')}, model={config.get('model')}" + ) + return { + "model_name": config.get("model", ""), + "model_provider": config.get("provider", ""), + "api_key": config.get("api_key", ""), + "base_url": config.get("base_url", ""), + "temperature": str(config.get("temperature", "0")), + } + else: + logger.warning( + f"No LLM config found in database for user {user_id}, falling back to environment variables" + ) + + # Priority 3: Fallback to environment variables + logger.info("Loading LLM config from environment variables (fallback)") + return { + "model_name": os.getenv("MODEL_NAME", ""), + "model_provider": os.getenv("MODE_PROVIDER", ""), + "api_key": os.getenv("MODEL_API_KEY", ""), + "base_url": os.getenv("BASE_URL", ""), + "temperature": os.getenv("TEMPERATURE", "0"), + } -def create_base_model() -> Any: +def create_base_model( + user_id: Optional[UUID] = None, + jwt_token: Optional[str] = None, + llm_config: Optional[dict[str, Any]] = None, +) -> Any: """ - Create a fresh base LLM model instance from current environment variables. + Create a fresh base LLM model instance. - This function reads environment variables fresh every time it's called, - allowing configuration changes to take effect immediately. + Configuration priority: + 1. llm_config dictionary (if provided) + 2. Fetch from llm_model_configs system via connector_factory (if user_id and jwt_token provided) + 3. Environment variables (fallback) + + Args: + user_id: User UUID for fetching config from database + jwt_token: JWT token for API authentication + llm_config: Optional configuration dictionary to use directly Returns: - Any: A new LLM model instance configured with current env vars. + Any: A new LLM model instance configured with current settings. The actual type depends on the provider (e.g., ChatOpenAI, etc.). Raises: - ValueError: If required environment variables are missing or invalid. + ValueError: If required configuration fields are missing or invalid. """ - env_vars = _load_llm_config() + env_vars = _load_llm_config(user_id, jwt_token, llm_config) # Log the loaded configuration (mask sensitive data) logger.info( @@ -98,7 +144,11 @@ def create_base_model() -> Any: raise RuntimeError(f"Failed to create base model: {e}") from e -def create_title_model() -> Any: +def create_title_model( + user_id: Optional[UUID] = None, + jwt_token: Optional[str] = None, + llm_config: Optional[dict[str, Any]] = None, +) -> Any: """ Create a fresh title generation model instance. @@ -106,14 +156,24 @@ def create_title_model() -> Any: It uses the same configuration as the base model but with a higher temperature for more creative output. + Configuration priority: + 1. llm_config dictionary (if provided) + 2. Fetch from llm_model_configs system via connector_factory (if user_id and jwt_token provided) + 3. Environment variables (fallback) + + Args: + user_id: User UUID for fetching config from database + jwt_token: JWT token for API authentication + llm_config: Optional configuration dictionary to use directly + Returns: Any: A new LLM model instance for title generation. The actual type depends on the provider. Raises: - ValueError: If required environment variables are missing or invalid. + ValueError: If required configuration fields are missing or invalid. """ - env_vars = _load_llm_config() + env_vars = _load_llm_config(user_id, jwt_token, llm_config) logger.info( "Creating title model: name=%s, provider=%s, base_url=%s, temperature=1.0", @@ -174,305 +234,35 @@ def create_model_with_tools( raise RuntimeError(f"Failed to bind tools to model: {e}") from e -def create_note_organizer_model() -> Any: - """ - Create a fresh model instance for note organization. - - This creates a model instance suitable for organizing and formatting notes. - It uses the same configuration as the base model but with a lower temperature - for more consistent and predictable output. - - Returns: - Any: A new LLM model instance for note organization. - The actual type depends on the provider. - - Raises: - ValueError: If required environment variables are missing or invalid. - """ - env_vars = _load_llm_config() - - logger.info( - "Creating note organizer model: name=%s, provider=%s, base_url=%s, temperature=0.3", - env_vars["model_name"], - env_vars["model_provider"], - env_vars["base_url"] if env_vars["base_url"] else "default", - ) - - # Validate required fields - if not env_vars["model_name"]: - raise ValueError("MODEL_NAME environment variable is required") - - if not env_vars["model_provider"]: - raise ValueError("MODE_PROVIDER environment variable is required") - - try: - model = init_chat_model( - env_vars["model_name"], - model_provider=env_vars["model_provider"], - api_key=env_vars["api_key"], - base_url=env_vars["base_url"], - temperature="0.3", # Lower temperature for more consistent note organization - configurable_fields="any", - config_prefix="foo", - ) - - logger.info("Note organizer model created successfully") - return model - - except Exception as e: - logger.error("Failed to create note organizer model: %s", e) - raise RuntimeError(f"Failed to create note organizer model: {e}") from e - - -def create_base_model_with_tools(tools: list[Any]) -> Any: +def create_base_model_with_tools( + tools: list[Any], + user_id: Optional[UUID] = None, + jwt_token: Optional[str] = None, + llm_config: Optional[dict[str, Any]] = None, +) -> Any: """ Create a fresh base model instance with tools bound. This is a convenience function that combines creating the base model and binding tools to it. + Configuration priority: + 1. llm_config dictionary (if provided) + 2. Fetch from llm_model_configs system via connector_factory (if user_id and jwt_token provided) + 3. Environment variables (fallback) + Args: tools: List of tools to bind to the model. + user_id: User UUID for fetching config from database + jwt_token: JWT token for API authentication + llm_config: Optional configuration dictionary to use directly Returns: Any: A new model instance with tools bound (type varies by provider). Raises: - ValueError: If required environment variables are missing. + ValueError: If required configuration fields are missing. RuntimeError: If model creation or tool binding fails. """ - base_model = create_base_model() + base_model = create_base_model(user_id, jwt_token, llm_config) return create_model_with_tools(base_model, tools) - - -def create_window_agent_base_model() -> Any: - """ - Create a base model instance for Window Agent (without tools). - - This creates a model instance suitable for Window Agent (voice mode). - Uses temperature=0 for precise, deterministic responses. - - Returns: - Any: A new base LLM model instance (without tools bound). - The actual type depends on the provider. - - Raises: - ValueError: If required environment variables are missing. - RuntimeError: If model creation fails. - """ - env_vars = _load_llm_config() - - logger.info( - "Creating Window Agent base model: name=%s, provider=%s, base_url=%s, temperature=0", - env_vars["model_name"], - env_vars["model_provider"], - env_vars["base_url"] if env_vars["base_url"] else "default", - ) - - # Validate required fields - if not env_vars["model_name"]: - raise ValueError("MODEL_NAME environment variable is required") - - if not env_vars["model_provider"]: - raise ValueError("MODE_PROVIDER environment variable is required") - - try: - # Create model with temperature=0 for voice mode (precise, concise responses) - model = init_chat_model( - env_vars["model_name"], - model_provider=env_vars["model_provider"], - api_key=env_vars["api_key"], - base_url=env_vars["base_url"], - temperature="0", # Force temperature=0 for voice mode - configurable_fields="any", - config_prefix="foo", - ) - - logger.info("Window Agent base model created successfully (no tools)") - return model - - except Exception as e: - logger.error("Failed to create Window Agent base model: %s", e) - raise RuntimeError(f"Failed to create Window Agent base model: {e}") from e - - -def create_window_agent_model_with_tools(tools: list[Any]) -> Any: - """ - Create a model instance with tools for Window Agent. - - Window Agent runs in VOICE mode and needs precise, deterministic responses. - Uses temperature=0 for consistency. - - Args: - tools: List of tools to bind to the model. - - Returns: - Any: A new model instance with tools bound (type varies by provider). - - Raises: - ValueError: If required environment variables are missing. - RuntimeError: If model creation or tool binding fails. - """ - env_vars = _load_llm_config() - - logger.info( - "Creating Window Agent model: name=%s, provider=%s, base_url=%s, temperature=0", - env_vars["model_name"], - env_vars["model_provider"], - env_vars["base_url"] if env_vars["base_url"] else "default", - ) - - # Validate required fields - if not env_vars["model_name"]: - raise ValueError("MODEL_NAME environment variable is required") - - if not env_vars["model_provider"]: - raise ValueError("MODE_PROVIDER environment variable is required") - - try: - # Create model with temperature=0 for voice mode (precise, concise responses) - model = init_chat_model( - env_vars["model_name"], - model_provider=env_vars["model_provider"], - api_key=env_vars["api_key"], - base_url=env_vars["base_url"], - temperature="0", # Force temperature=0 for voice mode - configurable_fields="any", - config_prefix="foo", - ) - - # Bind tools - model_with_tools = model.bind_tools(tools) - logger.info("Window Agent model created with %d tools", len(tools)) - return model_with_tools - - except Exception as e: - logger.error("Failed to create Window Agent model: %s", e) - raise RuntimeError(f"Failed to create Window Agent model: {e}") from e - - -def create_experiment_planner_model() -> Any: - """ - Create a fresh model instance for experiment planning. - - This creates a model instance suitable for generating GNS3 lab experiment plans. - It uses the same configuration as the base model but with a moderate temperature - to balance creativity and accuracy in lab design. - - Returns: - Any: A new LLM model instance for experiment planning. - The actual type depends on the provider. - - Raises: - ValueError: If required environment variables are missing or invalid. - """ - env_vars = _load_llm_config() - - logger.info( - "Creating experiment planner model: name=%s, provider=%s, base_url=%s, temperature=0.7", - env_vars["model_name"], - env_vars["model_provider"], - env_vars["base_url"] if env_vars["base_url"] else "default", - ) - - # Validate required fields - if not env_vars["model_name"]: - raise ValueError("MODEL_NAME environment variable is required") - - if not env_vars["model_provider"]: - raise ValueError("MODE_PROVIDER environment variable is required") - - try: - model = init_chat_model( - env_vars["model_name"], - model_provider=env_vars["model_provider"], - api_key=env_vars["api_key"], - base_url=env_vars["base_url"], - temperature="0.7", # Moderate temperature for balanced creativity and accuracy - configurable_fields="any", - config_prefix="foo", - ) - - logger.info("Experiment planner model created successfully") - return model - - except Exception as e: - logger.error("Failed to create experiment planner model: %s", e) - raise RuntimeError(f"Failed to create experiment planner model: {e}") from e - - -def create_vision_model(): - """ - Create a fresh Qwen-VL vision model instance for network topology recognition. - - This creates a vision model instance using Qwen-VL through DashScope SDK - for recognizing network topology diagrams from images. - - Returns: - QwenVisionModel: A new Qwen-VL vision model instance. - - Raises: - ImportError: If dashscope package is not installed. - ValueError: If QWEN_API_KEY is not configured. - RuntimeError: If model creation fails. - """ - from gns3_copilot.agent.qwen_vision_model import create_qwen_vision_model - - logger.info("Creating Qwen-VL vision model") - - try: - model = create_qwen_vision_model() - logger.info("Qwen-VL vision model created successfully") - return model - except Exception as e: - logger.error("Failed to create Qwen-VL vision model: %s", e) - raise RuntimeError(f"Failed to create Qwen-VL vision model: {e}") from e - - -def create_presentation_eval_model() -> Any: - """ - Create a fresh model instance for presentation evaluation. - - This creates a model instance suitable for evaluating network engineer - presentations. Uses moderate temperature for balanced objective assessment. - - Returns: - Any: A new LLM model instance for presentation evaluation. - - Raises: - ValueError: If required environment variables are missing. - RuntimeError: If model creation fails. - """ - env_vars = _load_llm_config() - - logger.info( - "Creating presentation evaluator model: name=%s, provider=%s, base_url=%s, temperature=0.5", - env_vars["model_name"], - env_vars["model_provider"], - env_vars["base_url"] if env_vars["base_url"] else "default", - ) - - # Validate required fields - if not env_vars["model_name"]: - raise ValueError("MODEL_NAME environment variable is required") - - if not env_vars["model_provider"]: - raise ValueError("MODE_PROVIDER environment variable is required") - - try: - model = init_chat_model( - env_vars["model_name"], - model_provider=env_vars["model_provider"], - api_key=env_vars["api_key"], - base_url=env_vars["base_url"], - temperature="0.5", # Moderate temperature for balanced evaluation - configurable_fields="any", - config_prefix="foo", - ) - - logger.info("Presentation evaluator model created successfully") - return model - - except Exception as e: - logger.error("Failed to create presentation evaluator model: %s", e) - raise RuntimeError(f"Failed to create presentation evaluator model: {e}") from e diff --git a/gns3server/agent/gns3_copilot/gns3_client/__init__.py b/gns3server/agent/gns3_copilot/gns3_client/__init__.py index e1c6907b6..aeac912ab 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/__init__.py +++ b/gns3server/agent/gns3_copilot/gns3_client/__init__.py @@ -31,6 +31,7 @@ from .connector_factory import ( get_gns3_connector, get_gns3_connector_with_llm_config, get_gns3_server_host, + get_llm_config, ) from .custom_gns3fy import ( CONSOLE_TYPES, @@ -98,6 +99,7 @@ __all__ = [ "get_gns3_connector", "get_gns3_connector_with_llm_config", "get_gns3_server_host", + "get_llm_config", "add_file_to_index", "get_file_list", ] diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py index 19c4fd607..d817d265a 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py +++ b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py @@ -335,3 +335,60 @@ def get_gns3_server_host() -> str: except Exception as e: logger.warning("Failed to extract host from URL %s: %s, using fallback", url, e) return DEFAULT_GNS3_URL.split("://")[1].split(":")[0] + + +def get_llm_config(user_id, jwt_token: str) -> Optional[dict]: + """ + Get LLM model configuration for a user. + + This is a convenience function that retrieves the user's LLM configuration + from the llm_model_configs system. + + Args: + user_id: User UUID (can be string or UUID object) + jwt_token: JWT token for authentication + + Returns: + Dictionary with LLM configuration keys (provider, model, api_key, etc.), + or None if not found. + + Example: + from gns3_copilot.gns3_client import get_llm_config + + config = get_llm_config(user_id, jwt_token) + if config: + provider = config['provider'] + model = config['model'] + api_key = config['api_key'] + """ + import asyncio + + try: + # Convert user_id to UUID if it's a string + if isinstance(user_id, str): + user_id = UUID(user_id) + + # Detect GNS3 URL + url = _detect_url_for_api() + + # Import the async helper + from gns3_copilot.utils.llm_config_helper import get_user_llm_config + + # Run async function in sync context + loop = asyncio.get_event_loop() + if loop.is_running(): + # If loop is already running, run in a new thread + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + asyncio.run, + get_user_llm_config(user_id, jwt_token, url) + ) + return future.result(timeout=10) + else: + # No loop running, use run() directly + return asyncio.run(get_user_llm_config(user_id, jwt_token, url)) + + except Exception as e: + logger.error("Failed to get LLM config for user %s: %s", user_id, e) + return None