feat(api): add LLM model configurations endpoint and schemas

- Introduce new API route `/access` for managing LLM model configurations
- Add LLMModelConfig model to database models
- Include comprehensive schemas for LLM model config CRUD operations
- Register new router with tags for LLM Model Configurations
This commit is contained in:
YueGuobin 2026-03-03 16:34:48 +08:00
parent dce4a71004
commit bbd34f1f67
10 changed files with 1595 additions and 0 deletions

View File

@ -0,0 +1,287 @@
# LLM Model Configurations API
## Overview
This API provides LLM model configuration management for users and user groups with inheritance support.
### Key Features
- **User-level configurations**: Each user can have their own LLM model configurations
- **Group-level configurations**: User groups can share LLM model configurations
- **Inheritance**: Users automatically inherit configurations from their groups (when they have no own configs)
- **Default configuration**: Both users and groups can set a default configuration
- **API Key Encryption**: API keys are automatically encrypted in the database
### Inheritance Logic
```
User requests configs:
├─ If user has own configs → return user's configs
└─ If user has NO configs → return inherited group configs
```
### Configuration Priority
```
User's own config > User's group config
```
---
## Database Schema
### Table: `llm_model_configs`
| Column | Type | Description |
|--------|------|-------------|
| `config_id` | UUID | Primary key |
| `config` | JSONB | Configuration data (name, provider, model, etc.) |
| `user_id` | UUID (nullable) | Foreign key to users table |
| `group_id` | UUID (nullable) | Foreign key to user_groups table |
| `is_default` | BOOLEAN | Default configuration flag |
| `created_at` | TIMESTAMP | Creation timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
### Constraints
- Each config belongs to **either** a user **or** a group (not both)
- Each user can have **at most one** default configuration
- Each group can have **at most one** default configuration
---
## API Endpoints
### User Configuration Endpoints
| Method | Path | Description | Privilege |
|--------|------|-------------|-----------|
| GET | `/v3/access/users/{user_id}/llm-model-configs` | Get user's effective configs (own + inherited) | User.Audit |
| GET | `/v3/access/users/{user_id}/llm-model-configs/own` | Get user's own configs only | User.Audit |
| POST | `/v3/access/users/{user_id}/llm-model-configs` | Create a new configuration | User.Modify |
| PUT | `/v3/access/users/{user_id}/llm-model-configs/{config_id}` | Update a configuration | User.Modify |
| DELETE | `/v3/access/users/{user_id}/llm-model-configs/{config_id}` | Delete a configuration | User.Modify |
| PUT | `/v3/access/users/{user_id}/llm-model-configs/default/{config_id}` | Set default configuration | User.Modify |
### Group Configuration Endpoints
| Method | Path | Description | Privilege |
|--------|------|-------------|-----------|
| GET | `/v3/access/groups/{group_id}/llm-model-configs` | Get all group configurations | Group.Audit |
| POST | `/v3/access/groups/{group_id}/llm-model-configs` | Create a new configuration | Group.Modify |
| PUT | `/v3/access/groups/{group_id}/llm-model-configs/{config_id}` | Update a configuration | Group.Modify |
| DELETE | `/v3/access/groups/{group_id}/llm-model-configs/{config_id}` | Delete a configuration | Group.Modify |
| PUT | `/v3/access/groups/{group_id}/llm-model-configs/default/{config_id}` | Set default configuration | Group.Modify |
---
## Request/Response Schemas
### LLMModelConfigCreate
**Required Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Configuration name (1-100 chars) |
| `provider` | string | LLM provider (e.g., "openai", "anthropic", "ollama") |
| `base_url` | string | API base URL |
| `model` | string | Model name |
| `temperature` | float | Temperature (0.0-2.0, default: 0.7) |
**Optional Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `api_key` | string | API key (auto-encrypted) |
| `max_tokens` | integer | Max tokens for generation |
| `is_default` | boolean | Set as default (default: false) |
**Extra Fields:** Any custom fields are supported for future extensibility.
### LLMModelConfigUpdate
All fields are optional. Only provided fields will be updated.
### LLMModelConfigResponse
| Field | Type | Description |
|-------|------|-------------|
| `config_id` | UUID | Configuration ID |
| `config` | LLMModelConfigData | Configuration data |
| `user_id` | UUID (nullable) | Owner user ID |
| `group_id` | UUID (nullable) | Owner group ID |
| `is_default` | boolean | Default flag |
| `created_at` | TIMESTAMP | Creation time |
| `updated_at` | TIMESTAMP | Last update time |
### LLMModelConfigInheritedResponse
| Field | Type | Description |
|-------|------|-------------|
| `configs` | list[LLMModelConfigWithSource] | Effective configurations |
| `default_config` | LLMModelConfigWithSource (nullable) | Default configuration |
| `total` | integer | Total count |
---
## Usage Examples
### 1. Create a user configuration
```bash
curl -X POST http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "GPT-4",
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"model": "gpt-4",
"temperature": 0.7,
"api_key": "sk-xxx",
"is_default": true
}'
```
### 2. Create a group configuration
```bash
curl -X POST http://localhost:3080/v3/access/groups/{group_id}/llm-model-configs \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "Claude-3",
"provider": "anthropic",
"base_url": "https://api.anthropic.com",
"model": "claude-3-opus-20240229",
"temperature": 0.7,
"api_key": "sk-ant-xxx",
"is_default": true
}'
```
### 3. Get user's effective configurations (with inheritance)
```bash
curl -X GET http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \
-H "Authorization: Bearer <token>"
```
**Response (user has own configs):**
```json
{
"configs": [
{
"config_id": "uuid-1",
"source": "user",
"group_name": null,
"is_default": true,
"name": "GPT-4",
"provider": "openai",
"model": "gpt-4",
"base_url": "https://api.openai.com/v1",
"temperature": 0.7,
"api_key": "sk-xxx"
}
],
"default_config": {
"config_id": "uuid-1",
"source": "user",
"group_name": null,
"is_default": true,
"name": "GPT-4",
...
},
"total": 1
}
```
**Response (user inherits from group):**
```json
{
"configs": [
{
"config_id": "uuid-2",
"source": "group",
"group_name": "Developers",
"is_default": true,
"name": "Claude-3",
"provider": "anthropic",
"model": "claude-3-opus-20240229",
"base_url": "https://api.anthropic.com",
"temperature": 0.7,
"api_key": "sk-ant-xxx"
}
],
"default_config": {
"config_id": "uuid-2",
"source": "group",
"group_name": "Developers",
"is_default": true,
...
},
"total": 1
}
```
### 4. Update a configuration
```bash
curl -X PUT http://localhost:3080/v3/access/users/{user_id}/llm-model-configs/{config_id} \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"temperature": 0.9,
"max_tokens": 4000
}'
```
### 5. Set default configuration
```bash
curl -X PUT http://localhost:3080/v3/access/users/{user_id}/llm-model-configs/default/{config_id} \
-H "Authorization: Bearer <token>"
```
### 6. Delete a configuration
```bash
curl -X DELETE http://localhost:3080/v3/access/users/{user_id}/llm-model-configs/{config_id} \
-H "Authorization: Bearer <token>"
```
---
## Error Codes
| Status | Description |
|--------|-------------|
| 200 | Success |
| 201 | Created |
| 204 | Deleted (no content) |
| 400 | Bad request |
| 401 | Unauthorized |
| 404 | Not found |
| 500 | Server error |
---
## Security Notes
1. **API Key Encryption**: All API keys are encrypted using Fernet symmetric encryption (AES-128-CBC)
2. **Access Control**: All endpoints require appropriate privileges (User.Audit, User.Modify, Group.Audit, Group.Modify)
3. **User Isolation**: Users can only access their own configurations
4. **Group Access**: Group configurations can only be modified by users with Group.Modify privilege
---
## Migration from Old User Settings API
The old user settings API (`/v3/access/users/{user_id}/profiles`) stored configurations in the `users.model_configs` JSON column. This new API uses a dedicated table with better inheritance support.
**Migration strategy:**
1. Run the database migration to create the `llm_model_configs` table
2. Optionally migrate existing data from `users.model_configs` to the new table
3. Update clients to use the new API endpoints
4. Deprecate the old `/profiles` endpoints

View File

@ -22,6 +22,7 @@ from . import computes
from . import drawings
from . import gns3vm
from . import links
from . import llm_model_configs
from . import nodes
from . import projects
from . import snapshots
@ -145,3 +146,9 @@ router.include_router(
prefix="/gns3vm",
tags=["GNS3 VM"]
)
router.include_router(
llm_model_configs.router,
prefix="/access",
tags=["LLM Model Configurations"]
)

View File

@ -0,0 +1,507 @@
#!/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/>.
"""
API routes for LLM model configurations.
"""
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from uuid import UUID
from typing import List
from gns3server import schemas
from gns3server.controller.controller_error import (
ControllerError,
ControllerBadRequestError,
ControllerNotFoundError,
)
from gns3server.db.repositories.llm_model_configs import LLMModelConfigsRepository
from gns3server.db.repositories.users import UsersRepository
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
import logging
log = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# User LLM Model Configuration Endpoints
# ============================================================================
@router.get(
"/users/{user_id}/llm-model-configs",
response_model=schemas.LLMModelConfigInheritedResponse,
dependencies=[Depends(has_privilege("User.Audit"))]
)
async def get_user_llm_model_configs(
user_id: UUID,
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> schemas.LLMModelConfigInheritedResponse:
"""
Get user's effective LLM model configurations (own + inherited from groups).
Required privilege: User.Audit
"""
try:
result = await llm_repo.get_user_effective_configs(user_id)
return schemas.LLMModelConfigInheritedResponse(
configs=result["configs"],
default_config=result.get("default_config"),
total=len(result["configs"])
)
except Exception as e:
log.error(f"Failed to retrieve user LLM model configs: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve LLM model configurations"
)
@router.get(
"/users/{user_id}/llm-model-configs/own",
response_model=List[schemas.LLMModelConfigResponse],
dependencies=[Depends(has_privilege("User.Audit"))]
)
async def get_user_own_llm_model_configs(
user_id: UUID,
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> List[schemas.LLMModelConfigResponse]:
"""
Get user's own LLM model configurations (excluding inherited ones).
Required privilege: User.Audit
"""
try:
configs = await llm_repo.get_user_configs(user_id)
return [
schemas.LLMModelConfigResponse(
config_id=config.config_id,
config=config.config,
user_id=config.user_id,
group_id=config.group_id,
is_default=config.is_default,
created_at=config.created_at,
updated_at=config.updated_at
)
for config in configs
]
except Exception as e:
log.error(f"Failed to retrieve user's own LLM model configs: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve LLM model configurations"
)
@router.post(
"/users/{user_id}/llm-model-configs",
response_model=schemas.LLMModelConfigResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("User.Modify"))]
)
async def create_user_llm_model_config(
user_id: UUID,
config_create: schemas.LLMModelConfigCreate,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> schemas.LLMModelConfigResponse:
"""
Create a new LLM model configuration for a user.
Required privilege: User.Modify
"""
# Verify user exists
user = await users_repo.get_user(user_id)
if not user:
raise ControllerNotFoundError(f"User '{user_id}' not found")
try:
config_data = config_create.model_dump(exclude={"is_default"})
new_config = await llm_repo.create_user_config(
user_id,
config_data,
is_default=config_create.is_default
)
return schemas.LLMModelConfigResponse(
config_id=new_config.config_id,
config=new_config.config,
user_id=new_config.user_id,
group_id=new_config.group_id,
is_default=new_config.is_default,
created_at=new_config.created_at,
updated_at=new_config.updated_at
)
except ValueError as e:
raise ControllerBadRequestError(str(e))
except Exception as e:
log.error(f"Failed to create LLM model config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create LLM model configuration"
)
@router.put(
"/users/{user_id}/llm-model-configs/{config_id}",
response_model=schemas.LLMModelConfigResponse,
dependencies=[Depends(has_privilege("User.Modify"))]
)
async def update_user_llm_model_config(
user_id: UUID,
config_id: UUID,
config_update: schemas.LLMModelConfigUpdate,
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> schemas.LLMModelConfigResponse:
"""
Update a user's LLM model configuration.
Required privilege: User.Modify
"""
try:
# Build updates dict with only non-None values
updates = {k: v for k, v in config_update.model_dump().items() if v is not None}
updated_config = await llm_repo.update_user_config(config_id, user_id, updates)
if not updated_config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"LLM model configuration '{config_id}' not found"
)
return schemas.LLMModelConfigResponse(
config_id=updated_config.config_id,
config=updated_config.config,
user_id=updated_config.user_id,
group_id=updated_config.group_id,
is_default=updated_config.is_default,
created_at=updated_config.created_at,
updated_at=updated_config.updated_at
)
except HTTPException:
raise
except Exception as e:
log.error(f"Failed to update LLM model config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update LLM model configuration"
)
@router.delete(
"/users/{user_id}/llm-model-configs/{config_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("User.Modify"))]
)
async def delete_user_llm_model_config(
user_id: UUID,
config_id: UUID,
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> None:
"""
Delete a user's LLM model configuration.
Required privilege: User.Modify
"""
try:
success = await llm_repo.delete_user_config(config_id, user_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"LLM model configuration '{config_id}' not found"
)
except HTTPException:
raise
except Exception as e:
log.error(f"Failed to delete LLM model config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete LLM model configuration"
)
@router.put(
"/users/{user_id}/llm-model-configs/default/{config_id}",
response_model=schemas.LLMModelConfigResponse,
dependencies=[Depends(has_privilege("User.Modify"))]
)
async def set_user_default_llm_model_config(
user_id: UUID,
config_id: UUID,
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> schemas.LLMModelConfigResponse:
"""
Set a user's default LLM model configuration.
Required privilege: User.Modify
"""
try:
success = await llm_repo.set_user_default_config(user_id, config_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"LLM model configuration '{config_id}' not found"
)
# Get the updated config
config = await llm_repo.get_user_config(config_id)
return schemas.LLMModelConfigResponse(
config_id=config.config_id,
config=config.config,
user_id=config.user_id,
group_id=config.group_id,
is_default=config.is_default,
created_at=config.created_at,
updated_at=config.updated_at
)
except HTTPException:
raise
except Exception as e:
log.error(f"Failed to set default LLM model config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to set default LLM model configuration"
)
# ============================================================================
# Group LLM Model Configuration Endpoints
# ============================================================================
@router.get(
"/groups/{group_id}/llm-model-configs",
response_model=List[schemas.LLMModelConfigResponse],
dependencies=[Depends(has_privilege("Group.Audit"))]
)
async def get_group_llm_model_configs(
group_id: UUID,
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> List[schemas.LLMModelConfigResponse]:
"""
Get all LLM model configurations for a user group.
Required privilege: Group.Audit
"""
try:
configs = await llm_repo.get_group_configs(group_id)
return [
schemas.LLMModelConfigResponse(
config_id=config.config_id,
config=config.config,
user_id=config.user_id,
group_id=config.group_id,
is_default=config.is_default,
created_at=config.created_at,
updated_at=config.updated_at
)
for config in configs
]
except Exception as e:
log.error(f"Failed to retrieve group LLM model configs: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve LLM model configurations"
)
@router.post(
"/groups/{group_id}/llm-model-configs",
response_model=schemas.LLMModelConfigResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Group.Modify"))]
)
async def create_group_llm_model_config(
group_id: UUID,
config_create: schemas.LLMModelConfigCreate,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> schemas.LLMModelConfigResponse:
"""
Create a new LLM model configuration for a user group.
Required privilege: Group.Modify
"""
# Verify group exists
group = await users_repo.get_user_group(group_id)
if not group:
raise ControllerNotFoundError(f"User group '{group_id}' not found")
try:
config_data = config_create.model_dump(exclude={"is_default"})
new_config = await llm_repo.create_group_config(
group_id,
config_data,
is_default=config_create.is_default
)
return schemas.LLMModelConfigResponse(
config_id=new_config.config_id,
config=new_config.config,
user_id=new_config.user_id,
group_id=new_config.group_id,
is_default=new_config.is_default,
created_at=new_config.created_at,
updated_at=new_config.updated_at
)
except ValueError as e:
raise ControllerBadRequestError(str(e))
except Exception as e:
log.error(f"Failed to create LLM model config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create LLM model configuration"
)
@router.put(
"/groups/{group_id}/llm-model-configs/{config_id}",
response_model=schemas.LLMModelConfigResponse,
dependencies=[Depends(has_privilege("Group.Modify"))]
)
async def update_group_llm_model_config(
group_id: UUID,
config_id: UUID,
config_update: schemas.LLMModelConfigUpdate,
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> schemas.LLMModelConfigResponse:
"""
Update a group's LLM model configuration.
Required privilege: Group.Modify
"""
try:
# Build updates dict with only non-None values
updates = {k: v for k, v in config_update.model_dump().items() if v is not None}
updated_config = await llm_repo.update_group_config(config_id, group_id, updates)
if not updated_config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"LLM model configuration '{config_id}' not found"
)
return schemas.LLMModelConfigResponse(
config_id=updated_config.config_id,
config=updated_config.config,
user_id=updated_config.user_id,
group_id=updated_config.group_id,
is_default=updated_config.is_default,
created_at=updated_config.created_at,
updated_at=updated_config.updated_at
)
except HTTPException:
raise
except Exception as e:
log.error(f"Failed to update LLM model config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update LLM model configuration"
)
@router.delete(
"/groups/{group_id}/llm-model-configs/{config_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Group.Modify"))]
)
async def delete_group_llm_model_config(
group_id: UUID,
config_id: UUID,
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> None:
"""
Delete a group's LLM model configuration.
Required privilege: Group.Modify
"""
try:
success = await llm_repo.delete_group_config(config_id, group_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"LLM model configuration '{config_id}' not found"
)
except HTTPException:
raise
except Exception as e:
log.error(f"Failed to delete LLM model config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete LLM model configuration"
)
@router.put(
"/groups/{group_id}/llm-model-configs/default/{config_id}",
response_model=schemas.LLMModelConfigResponse,
dependencies=[Depends(has_privilege("Group.Modify"))]
)
async def set_group_default_llm_model_config(
group_id: UUID,
config_id: UUID,
llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository))
) -> schemas.LLMModelConfigResponse:
"""
Set a group's default LLM model configuration.
Required privilege: Group.Modify
"""
try:
success = await llm_repo.set_group_default_config(group_id, config_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"LLM model configuration '{config_id}' not found"
)
# Get the updated config
config = await llm_repo.get_group_config(config_id)
return schemas.LLMModelConfigResponse(
config_id=config.config_id,
config=config.config,
user_id=config.user_id,
group_id=config.group_id,
is_default=config.is_default,
created_at=config.created_at,
updated_at=config.updated_at
)
except HTTPException:
raise
except Exception as e:
log.error(f"Failed to set default LLM model config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to set default LLM model configuration"
)

View File

@ -23,6 +23,7 @@ from .privileges import Privilege
from .computes import Compute
from .images import Image
from .pools import Resource, ResourcePool
from .llm_model_configs import LLMModelConfig
from .templates import (
Template,
CloudTemplate,

View File

@ -0,0 +1,72 @@
#!/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 sqlalchemy import Column, Boolean, ForeignKey, CheckConstraint, UniqueConstraint, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from .base import BaseTable, generate_uuid, GUID
import logging
log = logging.getLogger(__name__)
class LLMModelConfig(BaseTable):
"""
LLM model configuration for users and user groups.
Supports inheritance: users can inherit configs from their groups.
"""
__tablename__ = "llm_model_configs"
config_id = Column(GUID, primary_key=True, default=generate_uuid)
config = Column(JSONB, nullable=False) # All config fields including name, provider, etc.
user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=True)
group_id = Column(GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"), nullable=True)
is_default = Column(Boolean, default=False, nullable=False)
# Relationships
user = relationship("User", backref="llm_model_configs")
group = relationship("UserGroup", backref="llm_model_configs")
# Constraints
__table_args__ = (
# Ensure a config belongs to either a user or a group, not both
CheckConstraint(
"(user_id IS NOT NULL AND group_id IS NULL) OR "
"(user_id IS NULL AND group_id IS NOT NULL)",
name="single_owner_check"
),
# Each user can have at most one default config
UniqueConstraint("user_id", "is_default", name="unique_user_default",
deferrable=True, initially="deferred",
postgresql_where="is_default = TRUE AND user_id IS NOT NULL"),
# Each group can have at most one default config
UniqueConstraint("group_id", "is_default", name="unique_group_default",
deferrable=True, initially="deferred",
postgresql_where="is_default = TRUE AND group_id IS NOT NULL"),
# Indexes for efficient queries
Index("idx_llm_model_configs_user_id", "user_id"),
Index("idx_llm_model_configs_group_id", "group_id"),
Index("idx_llm_model_configs_config", "config", postgresql_using="gin"),
)
def __repr__(self):
config_name = self.config.get("name", "unnamed") if self.config else "unnamed"
owner = f"user_{self.user_id}" if self.user_id else f"group_{self.group_id}"
return f"<LLMModelConfig {config_name} for {owner} (default={self.is_default})>"

View File

@ -0,0 +1,393 @@
#!/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
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,
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
db_config = models.LLMModelConfig(
config=config_to_store,
user_id=user_id,
is_default=is_default
)
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]
) -> Optional[models.LLMModelConfig]:
"""Update a user's LLM model configuration."""
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
# Encrypt API key if present in updates
from gns3server.utils.encryption import encrypt
updates_copy = updates.copy()
if "api_key" in updates_copy and updates_copy["api_key"]:
try:
updates_copy["api_key"] = encrypt(updates_copy["api_key"])
except Exception as e:
log.error(f"Failed to encrypt API key: {e}")
raise
# Update config JSONB fields
current_config = db_config.config.copy()
for key, value in updates_copy.items():
if key == "is_default":
db_config.is_default = value
elif value is not None:
current_config[key] = value
db_config.config = current_config
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."""
# 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)
)
# 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)
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,
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
db_config = models.LLMModelConfig(
config=config_to_store,
group_id=group_id,
is_default=is_default
)
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]
) -> Optional[models.LLMModelConfig]:
"""Update a group's LLM model configuration."""
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
# Encrypt API key if present in updates
from gns3server.utils.encryption import encrypt
updates_copy = updates.copy()
if "api_key" in updates_copy and updates_copy["api_key"]:
try:
updates_copy["api_key"] = encrypt(updates_copy["api_key"])
except Exception as e:
log.error(f"Failed to encrypt API key: {e}")
raise
# Update config JSONB fields
current_config = db_config.config.copy()
for key, value in updates_copy.items():
if key == "is_default":
db_config.is_default = value
elif value is not None:
current_config[key] = value
db_config.config = current_config
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."""
# 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)
)
# 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)
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) -> Dict[str, Any]:
"""
Get user's effective configurations (own + inherited from groups).
Returns a dict with 'configs' list and 'default_config'.
"""
from gns3server.utils.encryption import decrypt, is_encrypted
# 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()
if "api_key" in config_dict and config_dict["api_key"]:
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
configs_with_source.append({
"config_id": config.config_id,
"source": "user",
"group_name": None,
"is_default": config.is_default,
**config_dict
})
if config.is_default and default_config is None:
default_config = configs_with_source[-1]
# Add inherited group configs (only if user has no configs)
if not user_configs:
for group_id, configs in group_configs_map.items():
for config in configs:
config_dict = config.config.copy()
if "api_key" in config_dict and config_dict["api_key"]:
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
configs_with_source.append({
"config_id": config.config_id,
"source": "group",
"group_name": group_names_map[group_id],
"is_default": config.is_default,
**config_dict
})
if config.is_default and default_config is None:
default_config = configs_with_source[-1]
return {
"configs": configs_with_source,
"default_config": default_config
}

View File

@ -0,0 +1,62 @@
"""create llm_model_configs table
Revision ID: 20260303_create_llm_model_configs
Revises: 7ceeddd9c9a8
Create Date: 2026-03-03
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '20260303_create_llm_model_configs'
down_revision = '7ceeddd9c9a8'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create llm_model_configs table
op.create_table(
'llm_model_configs',
sa.Column('config_id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('config', postgresql.JSONB(), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.user_id', ondelete='CASCADE'), nullable=True),
sa.Column('group_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('user_groups.user_group_id', ondelete='CASCADE'), nullable=True),
sa.Column('is_default', sa.Boolean(), default=False, nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.CheckConstraint(
"(user_id IS NOT NULL AND group_id IS NULL) OR "
"(user_id IS NULL AND group_id IS NOT NULL)",
name='single_owner_check'
),
sa.UniqueConstraint(
'user_id', 'is_default',
name='unique_user_default',
deferrable=True, initially='deferred',
postgresql_where=sa.text("is_default = TRUE AND user_id IS NOT NULL")
),
sa.UniqueConstraint(
'group_id', 'is_default',
name='unique_group_default',
deferrable=True, initially='deferred',
postgresql_where=sa.text("is_default = TRUE AND group_id IS NOT NULL")
),
)
# Create indexes for efficient queries
op.create_index('idx_llm_model_configs_user_id', 'llm_model_configs', ['user_id'])
op.create_index('idx_llm_model_configs_group_id', 'llm_model_configs', ['group_id'])
op.create_index('idx_llm_model_configs_config', 'llm_model_configs', ['config'], postgresql_using='gin')
def downgrade() -> None:
# Drop indexes
op.drop_index('idx_llm_model_configs_config', table_name='llm_model_configs')
op.drop_index('idx_llm_model_configs_group_id', table_name='llm_model_configs')
op.drop_index('idx_llm_model_configs_user_id', table_name='llm_model_configs')
# Drop table
op.drop_table('llm_model_configs')

View File

@ -30,6 +30,15 @@ from .controller.gns3vm import GNS3VM
from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node
from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile, ProjectCompression
from .controller.users import UserCreate, UserUpdate, LoggedInUserUpdate, User, Credentials, UserGroupCreate, UserGroupUpdate, UserGroup
from .controller.llm_model_configs import (
LLMModelConfigData,
LLMModelConfigCreate,
LLMModelConfigUpdate,
LLMModelConfigResponse,
LLMModelConfigListResponse,
LLMModelConfigWithSource,
LLMModelConfigInheritedResponse
)
from .controller.rbac import RoleCreate, RoleUpdate, Role, Privilege, ACECreate, ACEUpdate, ACE
from .controller.pools import Resource, ResourceCreate, ResourcePoolCreate, ResourcePoolUpdate, ResourcePool
from .controller.tokens import Token

View File

@ -0,0 +1,102 @@
#
# 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 typing import Optional, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
from uuid import UUID
from .base import DateTimeModelMixin
# Core model config schema (stored in config JSONB field)
class LLMModelConfigData(BaseModel):
"""
LLM model configuration data.
All fields are stored in the config JSONB column.
"""
name: str = Field(..., min_length=1, max_length=100, description="Configuration name")
provider: str = Field(..., description="LLM provider (e.g., 'openai', 'anthropic', 'ollama')")
base_url: str = Field(..., description="API base URL")
model: str = Field(..., description="Model name")
temperature: float = Field(default=0.7, ge=0.0, le=2.0, description="Temperature parameter")
api_key: Optional[str] = Field(None, description="API key (will be encrypted)")
max_tokens: Optional[int] = Field(None, gt=0, description="Max tokens for generation")
# Allow extra fields for extensibility
model_config = ConfigDict(extra="allow")
# Request schemas
class LLMModelConfigCreate(LLMModelConfigData):
"""Request to create a new LLM model configuration."""
is_default: Optional[bool] = Field(False, description="Set as default configuration")
class LLMModelConfigUpdate(BaseModel):
"""Request to update an existing LLM model configuration."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
provider: Optional[str] = None
base_url: Optional[str] = None
model: Optional[str] = None
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
api_key: Optional[str] = None
max_tokens: Optional[int] = Field(None, gt=0)
is_default: Optional[bool] = None
# Allow extra fields for extensibility
model_config = ConfigDict(extra="allow")
# Response schemas
class LLMModelConfigResponse(DateTimeModelMixin):
"""LLM model configuration response."""
config_id: UUID
config: LLMModelConfigData
user_id: Optional[UUID] = None
group_id: Optional[UUID] = None
is_default: bool
model_config = ConfigDict(from_attributes=True)
class LLMModelConfigListResponse(BaseModel):
"""Response containing list of LLM model configurations."""
configs: list[LLMModelConfigData]
default_config_id: Optional[UUID] = None
total: int
# Inheritance response (user configs + inherited group configs)
class LLMModelConfigWithSource(LLMModelConfigData):
"""Model configuration with source information."""
config_id: UUID
source: str = Field(..., description="Source: 'user' or 'group'")
group_name: Optional[str] = Field(None, description="Group name if source is 'group'")
is_default: bool
class LLMModelConfigInheritedResponse(BaseModel):
"""Response containing user's effective configs (own + inherited from groups)."""
configs: list[LLMModelConfigWithSource]
default_config: Optional[LLMModelConfigWithSource] = None
total: int

View File

@ -0,0 +1,155 @@
#
# 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/>.
"""
Encryption utilities for sensitive data like API keys.
Uses Fernet symmetric encryption.
"""
import os
import secrets
import logging
from typing import Optional
from cryptography.fernet import Fernet
log = logging.getLogger(__name__)
# Global encryption key - will be loaded from config
_fernet: Optional[Fernet] = None
def init_encryption(secrets_dir: str) -> None:
"""
Initialize encryption by loading or generating the encryption key.
:param secrets_dir: Directory to store the encryption key
"""
global _fernet
encryption_key_path = os.path.join(secrets_dir, "gns3_encryption_key")
if not os.path.exists(encryption_key_path):
log.info(f"No encryption key found, generating one in '{encryption_key_path}'...")
try:
key = Fernet.generate_key()
os.makedirs(secrets_dir, exist_ok=True)
with open(encryption_key_path, "w", encoding="utf-8") as f:
# Use Fernet's base64-encoded key format
f.write(key.decode() if isinstance(key, bytes) else key)
# Set restrictive permissions (owner read/write only)
os.chmod(encryption_key_path, 0o600)
except OSError as e:
log.error(f"Could not create encryption key file '{encryption_key_path}': {e}")
raise
try:
with open(encryption_key_path, encoding="utf-8") as f:
key_content = f.read().strip()
_fernet = Fernet(key_content.encode() if isinstance(key_content, str) else key_content)
log.debug("Encryption initialized successfully")
except OSError as e:
log.error(f"Could not read encryption key file '{encryption_key_path}': {e}")
raise
def encrypt(plaintext: str) -> str:
"""
Encrypt a plaintext string.
:param plaintext: The plaintext to encrypt
:returns: Base64-encoded encrypted string
:raises RuntimeError: If encryption is not initialized
"""
if _fernet is None:
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
if not plaintext:
return ""
encrypted = _fernet.encrypt(plaintext.encode())
return encrypted.decode()
def decrypt(ciphertext: str) -> str:
"""
Decrypt a ciphertext string.
:param ciphertext: The base64-encoded encrypted string
:returns: Decrypted plaintext string
:raises RuntimeError: If encryption is not initialized
:raises ValueError: If decryption fails (invalid data or wrong key)
"""
if _fernet is None:
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
if not ciphertext:
return ""
try:
decrypted = _fernet.decrypt(ciphertext.encode())
return decrypted.decode()
except Exception as e:
raise ValueError(f"Decryption failed: {e}") from e
def is_encrypted(value: str) -> bool:
"""
Check if a value appears to be encrypted (heuristic).
:param value: The value to check
:returns: True if the value appears to be encrypted
"""
if not value:
return False
# Fernet encrypted data is URL-safe base64 and has a specific structure
# Check for Fernet prefix and valid format
try:
# Fernet tokens always start with 'gAAAAA' in base64
if not value.startswith('gAAAAA'):
return False
# Attempt to decode as URL-safe base64
import base64
decoded = base64.urlsafe_b64decode(value)
# Fernet tokens have a specific format (minimum 32 bytes)
return len(decoded) >= 32
except Exception:
return False
def re_encrypt(old_key_path: str, new_key_path: str) -> None:
"""
Re-encrypt data with a new key (for key rotation).
:param old_key_path: Path to the old encryption key
:param new_key_path: Path to the new encryption key
"""
global _fernet
# Load old key and decrypt all data
with open(old_key_path, encoding="utf-8") as f:
old_key = f.read().strip().encode()
old_fernet = Fernet(old_key)
# This would be used during a key rotation migration
# Implementation depends on how data is stored
log.warning("Key rotation not yet implemented")