diff --git a/docs/llm-model-configs-api.md b/docs/llm-model-configs-api.md new file mode 100644 index 000000000..bc01dd3e1 --- /dev/null +++ b/docs/llm-model-configs-api.md @@ -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 " \ + -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 " \ + -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 " +``` + +**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 " \ + -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 " +``` + +### 6. Delete a configuration + +```bash +curl -X DELETE http://localhost:3080/v3/access/users/{user_id}/llm-model-configs/{config_id} \ + -H "Authorization: Bearer " +``` + +--- + +## 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 diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index e5d095f82..922d6ba25 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -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"] +) diff --git a/gns3server/api/routes/controller/llm_model_configs.py b/gns3server/api/routes/controller/llm_model_configs.py new file mode 100644 index 000000000..3e8352589 --- /dev/null +++ b/gns3server/api/routes/controller/llm_model_configs.py @@ -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 . + +""" +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" + ) diff --git a/gns3server/db/models/__init__.py b/gns3server/db/models/__init__.py index ed7c01425..5e7afb07b 100644 --- a/gns3server/db/models/__init__.py +++ b/gns3server/db/models/__init__.py @@ -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, diff --git a/gns3server/db/models/llm_model_configs.py b/gns3server/db/models/llm_model_configs.py new file mode 100644 index 000000000..5b54831d2 --- /dev/null +++ b/gns3server/db/models/llm_model_configs.py @@ -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 . + +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"" diff --git a/gns3server/db/repositories/llm_model_configs.py b/gns3server/db/repositories/llm_model_configs.py new file mode 100644 index 000000000..bb60f72b8 --- /dev/null +++ b/gns3server/db/repositories/llm_model_configs.py @@ -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 . + +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 + } diff --git a/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py b/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py new file mode 100644 index 000000000..45a19ab9a --- /dev/null +++ b/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py @@ -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') diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index a23d61d96..d04218f9b 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -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 diff --git a/gns3server/schemas/controller/llm_model_configs.py b/gns3server/schemas/controller/llm_model_configs.py new file mode 100644 index 000000000..ade23f6e1 --- /dev/null +++ b/gns3server/schemas/controller/llm_model_configs.py @@ -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 . + +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 diff --git a/gns3server/utils/encryption.py b/gns3server/utils/encryption.py new file mode 100644 index 000000000..f36b39b58 --- /dev/null +++ b/gns3server/utils/encryption.py @@ -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 . + +""" +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")