mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
feat(agent): refactor LLM configuration handling to use centralized config
- Replace separate user_id and jwt_token parameters with unified llm_config dict - Simplify model factory to accept llm_config directly instead of fetching from API - Update llm_call and generate_title nodes to extract llm_config from LangGraph config - Remove deprecated API fetching logic from model factory - Maintain backward compatibility for existing tool usage patterns This change centralizes LLM configuration management, reducing API calls and improving performance by passing configuration directly from the API layer rather than fetching it repeatedly.
This commit is contained in:
parent
4765e54d7a
commit
b780bfaf53
@ -128,10 +128,10 @@ class MessagesState(TypedDict):
|
||||
def llm_call(state: dict, config: RunnableConfig | None = None):
|
||||
"""LLM decides whether to call a tool or not"""
|
||||
|
||||
# Extract user authentication info from LangGraph config
|
||||
# Extract config from LangGraph config
|
||||
configurable = config.get("configurable", {}) if config else {}
|
||||
user_id = configurable.get("user_id")
|
||||
jwt_token = configurable.get("jwt_token")
|
||||
llm_config = configurable.get("llm_config")
|
||||
|
||||
# Defensive check: skip LLM call if no user messages
|
||||
messages = state.get("messages", [])
|
||||
@ -209,12 +209,9 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
|
||||
# print(full_messages)
|
||||
|
||||
# Create fresh model with tools for each LLM call
|
||||
# This ensures configuration changes take effect immediately
|
||||
# Pass user_id and jwt_token for per-user LLM config and API authentication
|
||||
model_with_tools = create_base_model_with_tools(
|
||||
tools,
|
||||
user_id=user_id,
|
||||
jwt_token=jwt_token
|
||||
llm_config=llm_config
|
||||
)
|
||||
|
||||
# Store jwt_token in state for Tools to use when calling GNS3 API
|
||||
@ -233,10 +230,9 @@ def generate_title(state: MessagesState, config: RunnableConfig | None = None) -
|
||||
This node is only executed when no title has been set yet (first round only).
|
||||
"""
|
||||
|
||||
# Extract user authentication info from LangGraph config
|
||||
# Extract config from LangGraph config
|
||||
configurable = config.get("configurable", {}) if config else {}
|
||||
user_id = configurable.get("user_id")
|
||||
jwt_token = configurable.get("jwt_token")
|
||||
llm_config = configurable.get("llm_config")
|
||||
|
||||
# Only generate a title if it hasn't been set yet
|
||||
current_title = state.get("conversation_title")
|
||||
@ -253,9 +249,7 @@ def generate_title(state: MessagesState, config: RunnableConfig | None = None) -
|
||||
|
||||
# Call the title generation model (create fresh instance for each call)
|
||||
try:
|
||||
# Create fresh title model instance from current env configuration
|
||||
# Pass user_id and jwt_token for per-user LLM config
|
||||
title_model = create_title_model(user_id=user_id, jwt_token=jwt_token)
|
||||
title_model = create_title_model(llm_config=llm_config)
|
||||
response = title_model.invoke(
|
||||
title_prompt_messages, config={"configurable": {"foo_temperature": 1.0}}
|
||||
)
|
||||
|
||||
@ -2,98 +2,58 @@
|
||||
Model Factory for GNS3-Copilot Agent
|
||||
|
||||
This module provides factory functions to create fresh LLM model instances.
|
||||
Configuration is loaded from the llm_model_configs system via connector_factory.
|
||||
Configuration is passed directly from the API layer.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from langchain.chat_models import init_chat_model
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_llm_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 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)
|
||||
Convert llm_config dict to model factory format.
|
||||
|
||||
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
|
||||
llm_config: Configuration dictionary from database
|
||||
|
||||
Returns:
|
||||
Dictionary containing model configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If no configuration can be found.
|
||||
ValueError: If configuration is missing or invalid.
|
||||
"""
|
||||
# Priority 1: Use provided llm_config dictionary
|
||||
if llm_config:
|
||||
logger.info("Using provided llm_config dictionary")
|
||||
return {
|
||||
"model_name": llm_config.get("model", ""),
|
||||
"model_provider": llm_config.get("provider", ""),
|
||||
"api_key": llm_config.get("api_key", ""),
|
||||
"base_url": llm_config.get("base_url", ""),
|
||||
"temperature": str(llm_config.get("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")),
|
||||
}
|
||||
|
||||
# No configuration found
|
||||
error_msg = "LLM configuration not found"
|
||||
if not llm_config:
|
||||
if not user_id or not jwt_token:
|
||||
error_msg += ": user_id and jwt_token are required for fetching LLM configuration"
|
||||
else:
|
||||
error_msg += f": no LLM configuration found for user {user_id}"
|
||||
raise ValueError(error_msg)
|
||||
raise ValueError("LLM configuration is required")
|
||||
|
||||
logger.info(
|
||||
"Using LLM config: provider=%s, model=%s",
|
||||
llm_config.get("provider"),
|
||||
llm_config.get("model")
|
||||
)
|
||||
|
||||
return {
|
||||
"model_name": llm_config.get("model", ""),
|
||||
"model_provider": llm_config.get("provider", ""),
|
||||
"api_key": llm_config.get("api_key", ""),
|
||||
"base_url": llm_config.get("base_url", ""),
|
||||
"temperature": str(llm_config.get("temperature", "0")),
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Configuration priority:
|
||||
1. llm_config dictionary (if provided)
|
||||
2. Fetch from llm_model_configs system via connector_factory (requires user_id and jwt_token)
|
||||
|
||||
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
|
||||
llm_config: Configuration dictionary from database
|
||||
|
||||
Returns:
|
||||
Any: A new LLM model instance configured with current settings.
|
||||
@ -103,7 +63,7 @@ def create_base_model(
|
||||
ValueError: If required configuration fields are missing or invalid.
|
||||
RuntimeError: If model creation fails.
|
||||
"""
|
||||
config_vars = _load_llm_config(user_id, jwt_token, llm_config)
|
||||
config_vars = _load_llm_config(llm_config)
|
||||
|
||||
# Log the loaded configuration (mask sensitive data)
|
||||
logger.info(
|
||||
@ -141,8 +101,6 @@ def create_base_model(
|
||||
|
||||
|
||||
def create_title_model(
|
||||
user_id: Optional[UUID] = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
llm_config: Optional[dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
@ -152,14 +110,8 @@ def create_title_model(
|
||||
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 (requires user_id and jwt_token)
|
||||
|
||||
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
|
||||
llm_config: Configuration dictionary from database
|
||||
|
||||
Returns:
|
||||
Any: A new LLM model instance for title generation.
|
||||
@ -169,7 +121,7 @@ def create_title_model(
|
||||
ValueError: If required configuration fields are missing or invalid.
|
||||
RuntimeError: If model creation fails.
|
||||
"""
|
||||
config_vars = _load_llm_config(user_id, jwt_token, llm_config)
|
||||
config_vars = _load_llm_config(llm_config)
|
||||
|
||||
logger.info(
|
||||
"Creating title model: name=%s, provider=%s, base_url=%s, temperature=1.0",
|
||||
@ -232,8 +184,6 @@ def create_model_with_tools(
|
||||
|
||||
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:
|
||||
"""
|
||||
@ -242,15 +192,9 @@ def create_base_model_with_tools(
|
||||
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 (requires user_id and jwt_token)
|
||||
|
||||
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
|
||||
llm_config: Configuration dictionary from database
|
||||
|
||||
Returns:
|
||||
Any: A new model instance with tools bound (type varies by provider).
|
||||
@ -259,5 +203,5 @@ def create_base_model_with_tools(
|
||||
ValueError: If required configuration fields are missing.
|
||||
RuntimeError: If model creation or tool binding fails.
|
||||
"""
|
||||
base_model = create_base_model(user_id, jwt_token, llm_config)
|
||||
base_model = create_base_model(llm_config)
|
||||
return create_model_with_tools(base_model, tools)
|
||||
|
||||
@ -103,9 +103,9 @@ class AgentService:
|
||||
message: str,
|
||||
session_id: str,
|
||||
project_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
mode: str = "text"
|
||||
mode: str = "text",
|
||||
llm_config: Optional[Dict[str, Any]] = None
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
Stream chat responses from the agent.
|
||||
@ -114,19 +114,19 @@ class AgentService:
|
||||
message: User message
|
||||
session_id: Session/thread ID for conversation continuity
|
||||
project_id: GNS3 project ID (optional, for context)
|
||||
user_id: User ID for LLM config lookup (optional)
|
||||
jwt_token: JWT token for API authentication (optional)
|
||||
mode: Interaction mode (default: "text")
|
||||
llm_config: LLM configuration dict (provider, model, api_key, etc.)
|
||||
|
||||
Yields:
|
||||
Dict containing SSE-compatible response chunks
|
||||
"""
|
||||
# Build config with user authentication info
|
||||
# Build config with LLM configuration
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": session_id,
|
||||
"user_id": user_id,
|
||||
"jwt_token": jwt_token,
|
||||
"llm_config": llm_config,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -338,16 +338,18 @@ def get_gns3_server_host() -> str:
|
||||
return DEFAULT_GNS3_URL.split("://")[1].split(":")[0]
|
||||
|
||||
|
||||
def get_llm_config(user_id, jwt_token: str) -> Optional[dict]:
|
||||
def get_llm_config(user_id, jwt_token: str, app=None) -> 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.
|
||||
This function retrieves the user's LLM configuration from the database.
|
||||
When `app` is provided, it directly accesses the database (bypassing API restrictions).
|
||||
Otherwise, it falls back to API call (which may mask group config API keys).
|
||||
|
||||
Args:
|
||||
user_id: User UUID (can be string or UUID object)
|
||||
jwt_token: JWT token for authentication
|
||||
app: FastAPI application instance (optional, for direct database access)
|
||||
|
||||
Returns:
|
||||
Dictionary with LLM configuration keys (provider, model, api_key, etc.),
|
||||
@ -370,55 +372,53 @@ def get_llm_config(user_id, jwt_token: str) -> Optional[dict]:
|
||||
if isinstance(user_id, str):
|
||||
user_id = UUID(user_id)
|
||||
|
||||
# Detect GNS3 URL
|
||||
url = _detect_url_for_api()
|
||||
# If app is provided, use direct database access (preferred)
|
||||
if app is not None:
|
||||
from gns3server.agent.gns3_copilot.utils.llm_config_helper import get_user_llm_config_with_app
|
||||
|
||||
# Import the async helper
|
||||
from gns3server.agent.gns3_copilot.utils.llm_config_helper import get_user_llm_config
|
||||
|
||||
# Check if we're in the main thread with a running event loop
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're in an async context with a running loop
|
||||
# This shouldn't happen since this is a sync function,
|
||||
# but if it does, we need to run in a separate 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)
|
||||
except RuntimeError:
|
||||
# No running event loop - we're in a sync context (possibly a thread pool)
|
||||
# This is the expected path for our use case
|
||||
pass
|
||||
|
||||
# Try to get an existing event loop, or create a new one
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Loop is running but not the running loop (edge case)
|
||||
# Create a new loop in a thread
|
||||
# Run the async function in sync context
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're in an async context with a running loop
|
||||
# This shouldn't happen since this is a sync function
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(
|
||||
asyncio.run,
|
||||
get_user_llm_config(user_id, jwt_token, url)
|
||||
get_user_llm_config_with_app(user_id, app)
|
||||
)
|
||||
return future.result(timeout=10)
|
||||
else:
|
||||
# Loop exists but not running - use it
|
||||
return loop.run_until_complete(get_user_llm_config(user_id, jwt_token, url))
|
||||
except RuntimeError:
|
||||
# No event loop exists - create a new one
|
||||
# This happens when called from a thread pool executor
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
except RuntimeError:
|
||||
# No running event loop - we're in a sync context
|
||||
pass
|
||||
|
||||
# Try to get an existing event loop, or create a new one
|
||||
try:
|
||||
return loop.run_until_complete(get_user_llm_config(user_id, jwt_token, url))
|
||||
finally:
|
||||
loop.close()
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Loop is running but not the running loop (edge case)
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(
|
||||
asyncio.run,
|
||||
get_user_llm_config_with_app(user_id, app)
|
||||
)
|
||||
return future.result(timeout=10)
|
||||
else:
|
||||
# Loop exists but not running - use it
|
||||
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
|
||||
except RuntimeError:
|
||||
# No event loop exists - create a new one
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# Fallback: No app provided, try API call (will mask group config API keys)
|
||||
logger.warning("No app provided for get_llm_config, group config API keys may be masked")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get LLM config for user %s: %s", user_id, e)
|
||||
|
||||
@ -2,19 +2,13 @@
|
||||
LLM Model Configuration Helper for GNS3 Copilot
|
||||
|
||||
This module provides utility functions to retrieve LLM model configurations
|
||||
using a hybrid approach:
|
||||
1. Get config_id via API (respects priority logic)
|
||||
2. Get full config via Repository (includes API key)
|
||||
3. Decrypt API key for LLM service calls
|
||||
|
||||
This allows the copilot module to access API keys that are hidden from API responses
|
||||
while leveraging the API's default_config selection logic.
|
||||
with decrypted API keys by directly accessing the database.
|
||||
|
||||
Usage:
|
||||
from gns3server.agent.gns3_copilot.utils.llm_config_helper import get_user_llm_config
|
||||
from gns3server.agent.gns3_copilot.utils.llm_config_helper import get_user_llm_config_with_app
|
||||
|
||||
# Get user's default LLM config (with API key)
|
||||
config = get_user_llm_config(user_id, jwt_token, gns3_url)
|
||||
config = await get_user_llm_config_with_app(user_id, app)
|
||||
if config:
|
||||
provider = config['provider']
|
||||
api_key = config['api_key']
|
||||
@ -25,276 +19,53 @@ import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_user_llm_config(
|
||||
async def get_user_llm_config_with_app(
|
||||
user_id: UUID,
|
||||
jwt_token: str,
|
||||
gns3_url: Optional[str] = None,
|
||||
timeout: float = 5.0
|
||||
app: FastAPI
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get user's default LLM model configuration with API key using hybrid approach.
|
||||
Get user's default LLM model configuration with decrypted API key.
|
||||
|
||||
Step 1: Call API to get default_config's config_id (respects priority logic)
|
||||
Step 2: Query Repository to get full config (includes encrypted API key)
|
||||
Step 3: Decrypt API key for LLM service usage
|
||||
This function directly accesses the database through the app reference,
|
||||
bypassing API security restrictions to get the complete configuration including
|
||||
decrypted API keys, even for inherited group configurations.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
jwt_token: JWT token for API authentication
|
||||
gns3_url: GNS3 server URL (optional, will auto-detect if not provided)
|
||||
timeout: HTTP request timeout in seconds
|
||||
app: FastAPI application instance
|
||||
|
||||
Returns:
|
||||
Configuration dict with provider, api_key, model, etc., or None if not found
|
||||
|
||||
Example:
|
||||
config = await get_user_llm_config(user_id, jwt_token)
|
||||
config = await get_user_llm_config_with_app(user_id, app)
|
||||
if config:
|
||||
print(f"Provider: {config['provider']}")
|
||||
print(f"Model: {config['model']}")
|
||||
print(f"API Key: {config['api_key']}")
|
||||
print(f"Source: {config['source']}")
|
||||
"""
|
||||
from gns3server.db.tasks import get_user_llm_config_full
|
||||
|
||||
try:
|
||||
# Step 1: Get GNS3 URL if not provided
|
||||
if gns3_url is None:
|
||||
gns3_url = _detect_gns3_url()
|
||||
if not gns3_url:
|
||||
logger.error("Failed to detect GNS3 server URL")
|
||||
return None
|
||||
user_id_str = str(user_id)
|
||||
config = await get_user_llm_config_full(user_id_str, app)
|
||||
|
||||
# Step 2: Call API to get default_config
|
||||
logger.debug(f"Fetching default config for user {user_id} from API...")
|
||||
api_response = await _call_llm_configs_api(gns3_url, user_id, jwt_token, timeout)
|
||||
|
||||
if not api_response:
|
||||
logger.warning(f"No LLM model configurations found for user {user_id}")
|
||||
return None
|
||||
|
||||
default_config = api_response.get("default_config")
|
||||
if not default_config:
|
||||
logger.warning(f"No default LLM model configuration found for user {user_id}")
|
||||
return None
|
||||
|
||||
config_id = default_config.get("config_id")
|
||||
source = default_config.get("source") # "user" or "group"
|
||||
|
||||
logger.debug(
|
||||
f"API returned default_config: config_id={config_id}, source={source}"
|
||||
)
|
||||
|
||||
# Step 3: Query Repository for full config
|
||||
full_config = await _get_full_config_from_db(config_id, source)
|
||||
if not full_config:
|
||||
logger.error(f"Failed to retrieve full config from database: config_id={config_id}")
|
||||
return None
|
||||
|
||||
# Step 4: Decrypt API key
|
||||
from gns3server.utils.encryption import decrypt, is_encrypted
|
||||
|
||||
config_data = full_config.config.copy()
|
||||
if "api_key" in config_data and config_data["api_key"]:
|
||||
try:
|
||||
if is_encrypted(config_data["api_key"]):
|
||||
config_data["api_key"] = decrypt(config_data["api_key"])
|
||||
logger.debug("Successfully decrypted API key")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt API key: {e}")
|
||||
config_data["api_key"] = None
|
||||
if config:
|
||||
logger.info(
|
||||
f"Successfully retrieved LLM config for user {user_id}: "
|
||||
f"provider={config.get('provider')}, model={config.get('model')}"
|
||||
)
|
||||
else:
|
||||
logger.warning("No API key found in configuration")
|
||||
logger.warning(f"No LLM configuration found for user {user_id}")
|
||||
|
||||
# Step 5: Build simplified config dict
|
||||
llm_config = {
|
||||
"config_id": full_config.config_id,
|
||||
"name": full_config.name,
|
||||
"model_type": full_config.model_type,
|
||||
"source": source,
|
||||
"group_name": default_config.get("group_name"),
|
||||
"user_id": full_config.user_id,
|
||||
"group_id": full_config.group_id,
|
||||
**config_data # provider, api_key, model, temperature, etc.
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Successfully retrieved LLM config for user {user_id}: "
|
||||
f"provider={llm_config.get('provider')}, "
|
||||
f"model={llm_config.get('model')}, "
|
||||
f"source={llm_config.get('source')}"
|
||||
)
|
||||
|
||||
return llm_config
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _call_llm_configs_api(
|
||||
gns3_url: str,
|
||||
user_id: UUID,
|
||||
jwt_token: str,
|
||||
timeout: float
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Call GNS3 API to get user's LLM model configurations.
|
||||
|
||||
Args:
|
||||
gns3_url: GNS3 server URL
|
||||
user_id: User UUID
|
||||
jwt_token: JWT token for authentication
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
API response dict, or None if failed
|
||||
"""
|
||||
try:
|
||||
url = f"{gns3_url}/v3/access/users/{user_id}/llm-model-configs"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {jwt_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
logger.debug(f"Calling API: GET {url}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 404:
|
||||
logger.warning(f"LLM configs endpoint returned 404 for user {user_id}")
|
||||
return None
|
||||
else:
|
||||
logger.error(
|
||||
f"API returned error {response.status_code}: {response.text}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout while calling LLM configs API")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to call LLM configs API: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_full_config_from_db(config_id: UUID, source: str) -> Optional[Any]:
|
||||
"""
|
||||
Get full configuration from database by config_id.
|
||||
|
||||
Args:
|
||||
config_id: Configuration UUID
|
||||
source: Configuration source ("user" or "group")
|
||||
|
||||
Returns:
|
||||
Full config object from database, or None if not found
|
||||
"""
|
||||
try:
|
||||
from gns3server.db.repositories.llm_model_configs import LLMModelConfigsRepository
|
||||
from gns3server.db import get_session
|
||||
|
||||
with get_session() as db_session:
|
||||
repo = LLMModelConfigsRepository(db_session)
|
||||
|
||||
if source == "user":
|
||||
logger.debug(f"Fetching user config from DB: config_id={config_id}")
|
||||
config = await repo.get_user_config(config_id)
|
||||
else:
|
||||
logger.debug(f"Fetching group config from DB: config_id={config_id}")
|
||||
config = await repo.get_group_config(config_id)
|
||||
|
||||
if config:
|
||||
logger.debug(f"Successfully retrieved config from DB: config_id={config_id}")
|
||||
else:
|
||||
logger.warning(f"Config not found in DB: config_id={config_id}, source={source}")
|
||||
|
||||
return config
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import required modules: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve config from DB: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_gns3_url() -> Optional[str]:
|
||||
"""
|
||||
Auto-detect GNS3 server URL using existing connector_factory logic.
|
||||
|
||||
Returns:
|
||||
GNS3 server URL, or None if detection failed
|
||||
"""
|
||||
try:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector_factory import (
|
||||
_get_url_from_controller,
|
||||
_get_url_from_config,
|
||||
DEFAULT_GNS3_URL
|
||||
)
|
||||
|
||||
# Try Controller first
|
||||
url = _get_url_from_controller()
|
||||
if url:
|
||||
logger.debug(f"Auto-detected GNS3 URL from Controller: {url}")
|
||||
return url
|
||||
|
||||
# Try Config
|
||||
url = _get_url_from_config()
|
||||
if url:
|
||||
logger.debug(f"Auto-detected GNS3 URL from Config: {url}")
|
||||
return url
|
||||
|
||||
# Fallback
|
||||
logger.warning(f"Using fallback GNS3 URL: {DEFAULT_GNS3_URL}")
|
||||
return DEFAULT_GNS3_URL
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import connector_factory: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-detect GNS3 URL: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# Synchronous wrapper for backward compatibility
|
||||
def get_user_llm_config_sync(user_id: UUID, jwt_token: str, gns3_url: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Synchronous wrapper for get_user_llm_config.
|
||||
|
||||
This is a convenience function for code that cannot use async/await.
|
||||
It runs the async function in a new event loop.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
jwt_token: JWT token for API authentication
|
||||
gns3_url: GNS3 server URL (optional)
|
||||
|
||||
Returns:
|
||||
Configuration dict, or None if not found
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
# Try to get running event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# If loop is already running, we need to run in a separate thread
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(
|
||||
asyncio.run,
|
||||
get_user_llm_config(user_id, jwt_token, gns3_url)
|
||||
)
|
||||
return future.result(timeout=10)
|
||||
else:
|
||||
# No loop running, use run() directly
|
||||
return asyncio.run(get_user_llm_config(user_id, jwt_token, gns3_url))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run async get_user_llm_config: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@ -91,6 +91,18 @@ async def stream_chat(
|
||||
auth_header = http_request.headers.get("Authorization", "")
|
||||
jwt_token = auth_header.replace("Bearer ", "") if auth_header else None
|
||||
|
||||
# Get FastAPI app reference (for database access)
|
||||
app = http_request.app
|
||||
|
||||
# Get user's LLM config (with decrypted API key)
|
||||
from gns3server.db.tasks import get_user_llm_config_full
|
||||
llm_config = await get_user_llm_config_full(user_id, app)
|
||||
if not llm_config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="LLM configuration not found. Please configure your LLM settings first."
|
||||
)
|
||||
|
||||
# Get or create AgentService for this project
|
||||
agent_manager = await get_project_agent_manager()
|
||||
agent_service = await agent_manager.get_agent(str(project.id), project.path)
|
||||
@ -105,9 +117,9 @@ async def stream_chat(
|
||||
message=request.message,
|
||||
session_id=session_id,
|
||||
project_id=str(project.id),
|
||||
user_id=user_id,
|
||||
jwt_token=jwt_token,
|
||||
mode=request.mode
|
||||
mode=request.mode,
|
||||
llm_config=llm_config
|
||||
):
|
||||
try:
|
||||
# Validate and serialize chunk
|
||||
|
||||
@ -21,7 +21,7 @@ import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import ValidationError
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@ -309,3 +309,99 @@ async def monitor_images_on_filesystem(app: FastAPI):
|
||||
log.info(f"Discovered image '{image_path}' has been added to the database")
|
||||
except SQLAlchemyError as e:
|
||||
log.warning(f"Error while adding image '{image_path}' to the database: {e}")
|
||||
|
||||
|
||||
async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict]:
|
||||
"""
|
||||
Get user's full LLM configuration with decrypted API key for Copilot.
|
||||
|
||||
This is a system-level function that bypasses API security restrictions.
|
||||
It retrieves the complete configuration including decrypted API keys,
|
||||
even for inherited group configurations.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
app: FastAPI application instance
|
||||
|
||||
Returns:
|
||||
Dictionary with LLM configuration (provider, model, api_key, etc.)
|
||||
or None if not found.
|
||||
"""
|
||||
from uuid import UUID
|
||||
from gns3server.db.repositories.llm_model_configs import LLMModelConfigsRepository
|
||||
from gns3server.utils.encryption import decrypt, is_encrypted
|
||||
|
||||
try:
|
||||
user_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
|
||||
|
||||
async with AsyncSession(app.state._db_engine, expire_on_commit=False) as session:
|
||||
repo = LLMModelConfigsRepository(session)
|
||||
|
||||
# Get effective configs (own + inherited from groups)
|
||||
result = await repo.get_user_effective_configs(
|
||||
user_uuid,
|
||||
current_user_id=user_uuid, # Viewing own config
|
||||
current_user_is_superadmin=False
|
||||
)
|
||||
|
||||
if not result or not result.get("default_config"):
|
||||
log.warning(f"No default LLM configuration found for user {user_id}")
|
||||
return None
|
||||
|
||||
default_config = result["default_config"]
|
||||
config_id = default_config["config_id"]
|
||||
source = default_config["source"] # "user" or "group"
|
||||
|
||||
# Get full config from database
|
||||
if source == "user":
|
||||
full_config = await repo.get_user_config(config_id)
|
||||
else:
|
||||
full_config = await repo.get_group_config(config_id)
|
||||
|
||||
if not full_config:
|
||||
log.error(f"Failed to retrieve full config from database: config_id={config_id}")
|
||||
return None
|
||||
|
||||
# Decrypt API key
|
||||
config_data = full_config.config.copy()
|
||||
if "api_key" in config_data and config_data["api_key"]:
|
||||
try:
|
||||
if is_encrypted(config_data["api_key"]):
|
||||
config_data["api_key"] = decrypt(config_data["api_key"])
|
||||
log.debug(f"Successfully decrypted API key for user {user_id}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to decrypt API key: {e}")
|
||||
config_data["api_key"] = None
|
||||
|
||||
# Build configuration dict
|
||||
llm_config = {
|
||||
"config_id": str(full_config.config_id),
|
||||
"name": full_config.name,
|
||||
"model_type": str(full_config.model_type),
|
||||
"source": source,
|
||||
"group_name": default_config.get("group_name"),
|
||||
"user_id": str(full_config.user_id) if full_config.user_id else None,
|
||||
"group_id": str(full_config.group_id) if full_config.group_id else None,
|
||||
**config_data # provider, api_key, model, temperature, etc.
|
||||
}
|
||||
|
||||
# Validate required fields
|
||||
if not llm_config.get("provider"):
|
||||
log.error(f"LLM config missing 'provider' field: {config_id}")
|
||||
return None
|
||||
|
||||
if not llm_config.get("model"):
|
||||
log.error(f"LLM config missing 'model' field: {config_id}")
|
||||
return None
|
||||
|
||||
log.info(
|
||||
f"Retrieved LLM config for user {user_id}: "
|
||||
f"provider={llm_config.get('provider')}, model={llm_config.get('model')}, source={source}"
|
||||
)
|
||||
|
||||
return llm_config
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user