mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
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:
parent
dce4a71004
commit
bbd34f1f67
287
docs/llm-model-configs-api.md
Normal file
287
docs/llm-model-configs-api.md
Normal 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
|
||||
@ -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"]
|
||||
)
|
||||
|
||||
507
gns3server/api/routes/controller/llm_model_configs.py
Normal file
507
gns3server/api/routes/controller/llm_model_configs.py
Normal 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"
|
||||
)
|
||||
@ -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,
|
||||
|
||||
72
gns3server/db/models/llm_model_configs.py
Normal file
72
gns3server/db/models/llm_model_configs.py
Normal 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})>"
|
||||
393
gns3server/db/repositories/llm_model_configs.py
Normal file
393
gns3server/db/repositories/llm_model_configs.py
Normal 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
|
||||
}
|
||||
@ -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')
|
||||
@ -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
|
||||
|
||||
102
gns3server/schemas/controller/llm_model_configs.py
Normal file
102
gns3server/schemas/controller/llm_model_configs.py
Normal 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
|
||||
155
gns3server/utils/encryption.py
Normal file
155
gns3server/utils/encryption.py
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user