mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-13 05:25:37 +03:00
Updated documentation and implementation to clearly define the priority order for selecting default LLM model configurations. The logic now explicitly states: 1. User's config marked with `is_default: true` (highest priority) 2. Group's config marked with `is_default: true` 3. First config in the list (user configs come before group configs) This ensures consistent behavior between the API documentation and the actual implementation in the repository code.
526 lines
20 KiB
Python
526 lines
20 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Copyright (C) 2026 GNS3 Technologies Inc.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
from uuid import UUID
|
|
from typing import Optional, List, Dict, Any
|
|
from sqlalchemy import select, update, delete, func, and_, or_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
from datetime import datetime
|
|
|
|
import json
|
|
import logging
|
|
|
|
from .base import BaseRepository
|
|
import gns3server.db.models as models
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class LLMModelConfigsRepository(BaseRepository):
|
|
"""Repository for LLM model configurations with inheritance support."""
|
|
|
|
# User configuration methods
|
|
|
|
async def get_user_config(self, config_id: UUID) -> Optional[models.LLMModelConfig]:
|
|
"""Get a user's LLM model configuration by ID."""
|
|
query = select(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.config_id == config_id,
|
|
models.LLMModelConfig.user_id.isnot(None)
|
|
)
|
|
)
|
|
result = await self._db_session.execute(query)
|
|
return result.scalars().first()
|
|
|
|
async def get_user_configs(self, user_id: UUID) -> List[models.LLMModelConfig]:
|
|
"""Get all LLM model configurations for a user."""
|
|
query = select(models.LLMModelConfig).where(
|
|
models.LLMModelConfig.user_id == user_id
|
|
).order_by(models.LLMModelConfig.created_at)
|
|
result = await self._db_session.execute(query)
|
|
return result.scalars().all()
|
|
|
|
async def get_user_default_config(self, user_id: UUID) -> Optional[models.LLMModelConfig]:
|
|
"""Get a user's default LLM model configuration."""
|
|
query = select(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.user_id == user_id,
|
|
models.LLMModelConfig.is_default == True
|
|
)
|
|
)
|
|
result = await self._db_session.execute(query)
|
|
return result.scalars().first()
|
|
|
|
async def create_user_config(
|
|
self,
|
|
user_id: UUID,
|
|
name: str,
|
|
model_type: str,
|
|
config_data: Dict[str, Any],
|
|
is_default: bool = False
|
|
) -> models.LLMModelConfig:
|
|
"""Create a new LLM model configuration for a user."""
|
|
# Encrypt API key if present
|
|
from gns3server.utils.encryption import encrypt
|
|
config_to_store = config_data.copy()
|
|
if "api_key" in config_to_store and config_to_store["api_key"]:
|
|
try:
|
|
config_to_store["api_key"] = encrypt(config_to_store["api_key"])
|
|
except Exception as e:
|
|
log.error(f"Failed to encrypt API key: {e}")
|
|
raise
|
|
|
|
# Set timestamps manually for SQLite compatibility
|
|
now = datetime.utcnow()
|
|
db_config = models.LLMModelConfig(
|
|
name=name,
|
|
model_type=model_type,
|
|
config=config_to_store,
|
|
user_id=user_id,
|
|
is_default=is_default,
|
|
created_at=now,
|
|
updated_at=now
|
|
)
|
|
self._db_session.add(db_config)
|
|
await self._db_session.commit()
|
|
await self._db_session.refresh(db_config)
|
|
return db_config
|
|
|
|
async def update_user_config(
|
|
self,
|
|
config_id: UUID,
|
|
user_id: UUID,
|
|
updates: Dict[str, Any],
|
|
expected_version: Optional[int] = None
|
|
) -> Optional[models.LLMModelConfig]:
|
|
"""
|
|
Update a user's LLM model configuration.
|
|
Uses optimistic locking to prevent concurrent modifications.
|
|
|
|
:param config_id: Configuration ID
|
|
:param user_id: User ID
|
|
:param updates: Dictionary of fields to update
|
|
:param expected_version: Expected version for optimistic locking (raises error if mismatch)
|
|
:raises ValueError: If version mismatch (concurrent modification)
|
|
"""
|
|
query = select(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.config_id == config_id,
|
|
models.LLMModelConfig.user_id == user_id
|
|
)
|
|
)
|
|
result = await self._db_session.execute(query)
|
|
db_config = result.scalars().first()
|
|
|
|
if not db_config:
|
|
return None
|
|
|
|
# Check optimistic lock if expected_version is provided
|
|
if expected_version is not None and db_config.version != expected_version:
|
|
raise ValueError(
|
|
f"Concurrent modification detected. Expected version {expected_version}, "
|
|
f"but current version is {db_config.version}. Please retry."
|
|
)
|
|
|
|
# Encrypt API key if present in updates
|
|
from gns3server.utils.encryption import encrypt
|
|
|
|
# Table-level fields
|
|
if "name" in updates and updates["name"] is not None:
|
|
db_config.name = updates["name"]
|
|
if "model_type" in updates and updates["model_type"] is not None:
|
|
db_config.model_type = updates["model_type"]
|
|
if "is_default" in updates and updates["is_default"] is not None:
|
|
db_config.is_default = updates["is_default"]
|
|
|
|
# Config JSONB fields
|
|
config_fields = ["provider", "base_url", "model", "temperature", "api_key", "max_tokens"]
|
|
current_config = db_config.config.copy()
|
|
|
|
for field in config_fields:
|
|
if field in updates and updates[field] is not None:
|
|
if field == "api_key":
|
|
# Encrypt API key
|
|
try:
|
|
current_config[field] = encrypt(updates[field])
|
|
except Exception as e:
|
|
log.error(f"Failed to encrypt API key: {e}")
|
|
raise
|
|
else:
|
|
current_config[field] = updates[field]
|
|
|
|
# Handle extra config fields
|
|
for key, value in updates.items():
|
|
if key not in ["name", "model_type", "is_default", "expected_version"] + config_fields:
|
|
if value is not None:
|
|
current_config[key] = value
|
|
|
|
db_config.config = current_config
|
|
# Increment version for optimistic locking
|
|
db_config.version = db_config.version + 1
|
|
# Update timestamp manually for SQLite compatibility
|
|
db_config.updated_at = datetime.utcnow()
|
|
await self._db_session.commit()
|
|
await self._db_session.refresh(db_config)
|
|
return db_config
|
|
|
|
async def delete_user_config(self, config_id: UUID, user_id: UUID) -> bool:
|
|
"""Delete a user's LLM model configuration."""
|
|
query = delete(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.config_id == config_id,
|
|
models.LLMModelConfig.user_id == user_id
|
|
)
|
|
)
|
|
result = await self._db_session.execute(query)
|
|
await self._db_session.commit()
|
|
return result.rowcount > 0
|
|
|
|
async def set_user_default_config(self, user_id: UUID, config_id: UUID) -> bool:
|
|
"""Set a user's default LLM model configuration."""
|
|
now = datetime.utcnow()
|
|
# First, unset current default
|
|
await self._db_session.execute(
|
|
update(models.LLMModelConfig)
|
|
.where(
|
|
and_(
|
|
models.LLMModelConfig.user_id == user_id,
|
|
models.LLMModelConfig.is_default == True
|
|
)
|
|
)
|
|
.values(is_default=False, updated_at=now)
|
|
)
|
|
|
|
# Set new default
|
|
query = update(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.config_id == config_id,
|
|
models.LLMModelConfig.user_id == user_id
|
|
)
|
|
).values(is_default=True, updated_at=now)
|
|
result = await self._db_session.execute(query)
|
|
await self._db_session.commit()
|
|
return result.rowcount > 0
|
|
|
|
# Group configuration methods
|
|
|
|
async def get_group_config(self, config_id: UUID) -> Optional[models.LLMModelConfig]:
|
|
"""Get a group's LLM model configuration by ID."""
|
|
query = select(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.config_id == config_id,
|
|
models.LLMModelConfig.group_id.isnot(None)
|
|
)
|
|
)
|
|
result = await self._db_session.execute(query)
|
|
return result.scalars().first()
|
|
|
|
async def get_group_configs(self, group_id: UUID) -> List[models.LLMModelConfig]:
|
|
"""Get all LLM model configurations for a group."""
|
|
query = select(models.LLMModelConfig).where(
|
|
models.LLMModelConfig.group_id == group_id
|
|
).order_by(models.LLMModelConfig.created_at)
|
|
result = await self._db_session.execute(query)
|
|
return result.scalars().all()
|
|
|
|
async def get_group_default_config(self, group_id: UUID) -> Optional[models.LLMModelConfig]:
|
|
"""Get a group's default LLM model configuration."""
|
|
query = select(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.group_id == group_id,
|
|
models.LLMModelConfig.is_default == True
|
|
)
|
|
)
|
|
result = await self._db_session.execute(query)
|
|
return result.scalars().first()
|
|
|
|
async def create_group_config(
|
|
self,
|
|
group_id: UUID,
|
|
name: str,
|
|
model_type: str,
|
|
config_data: Dict[str, Any],
|
|
is_default: bool = False
|
|
) -> models.LLMModelConfig:
|
|
"""Create a new LLM model configuration for a group."""
|
|
# Encrypt API key if present
|
|
from gns3server.utils.encryption import encrypt
|
|
config_to_store = config_data.copy()
|
|
if "api_key" in config_to_store and config_to_store["api_key"]:
|
|
try:
|
|
config_to_store["api_key"] = encrypt(config_to_store["api_key"])
|
|
except Exception as e:
|
|
log.error(f"Failed to encrypt API key: {e}")
|
|
raise
|
|
|
|
# Set timestamps manually for SQLite compatibility
|
|
now = datetime.utcnow()
|
|
db_config = models.LLMModelConfig(
|
|
name=name,
|
|
model_type=model_type,
|
|
config=config_to_store,
|
|
group_id=group_id,
|
|
is_default=is_default,
|
|
created_at=now,
|
|
updated_at=now
|
|
)
|
|
self._db_session.add(db_config)
|
|
await self._db_session.commit()
|
|
await self._db_session.refresh(db_config)
|
|
return db_config
|
|
|
|
async def update_group_config(
|
|
self,
|
|
config_id: UUID,
|
|
group_id: UUID,
|
|
updates: Dict[str, Any],
|
|
expected_version: Optional[int] = None
|
|
) -> Optional[models.LLMModelConfig]:
|
|
"""
|
|
Update a group's LLM model configuration.
|
|
Uses optimistic locking to prevent concurrent modifications.
|
|
|
|
:param config_id: Configuration ID
|
|
:param group_id: Group ID
|
|
:param updates: Dictionary of fields to update
|
|
:param expected_version: Expected version for optimistic locking (raises error if mismatch)
|
|
:raises ValueError: If version mismatch (concurrent modification)
|
|
"""
|
|
query = select(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.config_id == config_id,
|
|
models.LLMModelConfig.group_id == group_id
|
|
)
|
|
)
|
|
result = await self._db_session.execute(query)
|
|
db_config = result.scalars().first()
|
|
|
|
if not db_config:
|
|
return None
|
|
|
|
# Check optimistic lock if expected_version is provided
|
|
if expected_version is not None and db_config.version != expected_version:
|
|
raise ValueError(
|
|
f"Concurrent modification detected. Expected version {expected_version}, "
|
|
f"but current version is {db_config.version}. Please retry."
|
|
)
|
|
|
|
# Encrypt API key if present in updates
|
|
from gns3server.utils.encryption import encrypt
|
|
|
|
# Table-level fields
|
|
if "name" in updates and updates["name"] is not None:
|
|
db_config.name = updates["name"]
|
|
if "model_type" in updates and updates["model_type"] is not None:
|
|
db_config.model_type = updates["model_type"]
|
|
if "is_default" in updates and updates["is_default"] is not None:
|
|
db_config.is_default = updates["is_default"]
|
|
|
|
# Config JSONB fields
|
|
config_fields = ["provider", "base_url", "model", "temperature", "api_key", "max_tokens"]
|
|
current_config = db_config.config.copy()
|
|
|
|
for field in config_fields:
|
|
if field in updates and updates[field] is not None:
|
|
if field == "api_key":
|
|
# Encrypt API key
|
|
try:
|
|
current_config[field] = encrypt(updates[field])
|
|
except Exception as e:
|
|
log.error(f"Failed to encrypt API key: {e}")
|
|
raise
|
|
else:
|
|
current_config[field] = updates[field]
|
|
|
|
# Handle extra config fields
|
|
for key, value in updates.items():
|
|
if key not in ["name", "model_type", "is_default", "expected_version"] + config_fields:
|
|
if value is not None:
|
|
current_config[key] = value
|
|
|
|
db_config.config = current_config
|
|
# Increment version for optimistic locking
|
|
db_config.version = db_config.version + 1
|
|
# Update timestamp manually for SQLite compatibility
|
|
db_config.updated_at = datetime.utcnow()
|
|
await self._db_session.commit()
|
|
await self._db_session.refresh(db_config)
|
|
return db_config
|
|
|
|
async def delete_group_config(self, config_id: UUID, group_id: UUID) -> bool:
|
|
"""Delete a group's LLM model configuration."""
|
|
query = delete(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.config_id == config_id,
|
|
models.LLMModelConfig.group_id == group_id
|
|
)
|
|
)
|
|
result = await self._db_session.execute(query)
|
|
await self._db_session.commit()
|
|
return result.rowcount > 0
|
|
|
|
async def set_group_default_config(self, group_id: UUID, config_id: UUID) -> bool:
|
|
"""Set a group's default LLM model configuration."""
|
|
now = datetime.utcnow()
|
|
# First, unset current default
|
|
await self._db_session.execute(
|
|
update(models.LLMModelConfig)
|
|
.where(
|
|
and_(
|
|
models.LLMModelConfig.group_id == group_id,
|
|
models.LLMModelConfig.is_default == True
|
|
)
|
|
)
|
|
.values(is_default=False, updated_at=now)
|
|
)
|
|
|
|
# Set new default
|
|
query = update(models.LLMModelConfig).where(
|
|
and_(
|
|
models.LLMModelConfig.config_id == config_id,
|
|
models.LLMModelConfig.group_id == group_id
|
|
)
|
|
).values(is_default=True, updated_at=now)
|
|
result = await self._db_session.execute(query)
|
|
await self._db_session.commit()
|
|
return result.rowcount > 0
|
|
|
|
# Inheritance methods
|
|
|
|
async def get_user_effective_configs(
|
|
self,
|
|
user_id: UUID,
|
|
current_user_id: Optional[UUID] = None,
|
|
current_user_is_superadmin: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get user's effective configurations (own + inherited from groups).
|
|
Returns a dict with 'configs' list and 'default_config'.
|
|
|
|
API key visibility rules:
|
|
- Users viewing own configs: CAN see API keys in user configs, CANNOT see API keys in group configs
|
|
- Admins viewing other users' configs: CANNOT see ANY API keys
|
|
"""
|
|
from gns3server.utils.encryption import decrypt, is_encrypted
|
|
|
|
# Determine if current user is viewing their own configs
|
|
is_viewing_own = current_user_id == user_id if current_user_id else False
|
|
|
|
# Get user's own configs
|
|
user_configs = await self.get_user_configs(user_id)
|
|
|
|
# Get user's groups
|
|
query = select(models.UserGroup).\
|
|
join(models.UserGroup.users).\
|
|
filter(models.User.user_id == user_id)
|
|
result = await self._db_session.execute(query)
|
|
user_groups = result.scalars().all()
|
|
|
|
# Get group configs
|
|
group_configs_map = {} # group_id -> [configs]
|
|
group_names_map = {} # group_id -> group_name
|
|
for group in user_groups:
|
|
configs = await self.get_group_configs(group.user_group_id)
|
|
if configs:
|
|
group_configs_map[group.user_group_id] = configs
|
|
group_names_map[group.user_group_id] = group.name
|
|
|
|
# Decrypt API keys and build result
|
|
configs_with_source = []
|
|
default_config = None
|
|
|
|
# Add user's configs
|
|
for config in user_configs:
|
|
config_dict = config.config.copy()
|
|
|
|
# API key visibility: only show if viewing own configs
|
|
if "api_key" in config_dict and config_dict["api_key"]:
|
|
if is_viewing_own:
|
|
try:
|
|
if is_encrypted(config_dict["api_key"]):
|
|
config_dict["api_key"] = decrypt(config_dict["api_key"])
|
|
except Exception as e:
|
|
log.warning(f"Failed to decrypt API key for config {config.config_id}: {e}")
|
|
config_dict["api_key"] = None
|
|
else:
|
|
# Hide API key for admins viewing other users' configs
|
|
config_dict["api_key"] = None
|
|
|
|
configs_with_source.append({
|
|
"config_id": config.config_id,
|
|
"name": config.name,
|
|
"model_type": config.model_type,
|
|
"config": config_dict,
|
|
"user_id": config.user_id,
|
|
"group_id": config.group_id,
|
|
"is_default": config.is_default,
|
|
"version": config.version,
|
|
"created_at": config.created_at,
|
|
"updated_at": config.updated_at,
|
|
"source": "user",
|
|
"group_name": None
|
|
})
|
|
|
|
# Add inherited group configs (always shown, regardless of user configs)
|
|
for group_id, configs in group_configs_map.items():
|
|
for config in configs:
|
|
config_dict = config.config.copy()
|
|
|
|
# API key visibility: NEVER show API keys from inherited group configs
|
|
if "api_key" in config_dict and config_dict["api_key"]:
|
|
config_dict["api_key"] = None
|
|
|
|
configs_with_source.append({
|
|
"config_id": config.config_id,
|
|
"name": config.name,
|
|
"model_type": config.model_type,
|
|
"config": config_dict,
|
|
"user_id": config.user_id,
|
|
"group_id": config.group_id,
|
|
"is_default": config.is_default,
|
|
"version": config.version,
|
|
"created_at": config.created_at,
|
|
"updated_at": config.updated_at,
|
|
"source": "group",
|
|
"group_name": group_names_map[group_id]
|
|
})
|
|
|
|
# Select default_config with proper priority:
|
|
# 1. User's config marked with is_default: true
|
|
# 2. Group's config marked with is_default: true
|
|
# 3. First config in the list (user configs come first)
|
|
for config in configs_with_source:
|
|
if config["is_default"] and config["source"] == "user":
|
|
default_config = config
|
|
break
|
|
|
|
if default_config is None:
|
|
for config in configs_with_source:
|
|
if config["is_default"] and config["source"] == "group":
|
|
default_config = config
|
|
break
|
|
|
|
# Fallback to first config if no default is marked
|
|
if default_config is None and configs_with_source:
|
|
default_config = configs_with_source[0]
|
|
|
|
return {
|
|
"configs": configs_with_source,
|
|
"default_config": default_config
|
|
}
|