feat: Implement LLM provider deletion and refactor ConfigManager to preserve unknown payload sections.

This commit is contained in:
2026-03-14 09:19:08 +03:00
parent feb07bf366
commit 3928455189
9 changed files with 256 additions and 9 deletions

21
.gitattributes vendored Normal file
View File

@@ -0,0 +1,21 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.zip binary
*.gz binary
*.tar binary
*.db binary
*.sqlite binary
*.p12 binary
*.pfx binary
*.crt binary
*.pem binary

View File

@@ -0,0 +1,53 @@
# [DEF:backend.src.core.__tests__.test_config_manager_compat:Module]
# @TIER: STANDARD
# @SEMANTICS: config-manager, compatibility, payload, tests
# @PURPOSE: Verifies ConfigManager compatibility wrappers preserve legacy payload sections.
# @LAYER: Domain
# @RELATION: VERIFIES -> ConfigManager
from src.core.config_manager import ConfigManager
from src.core.config_models import AppConfig, GlobalSettings
# [DEF:test_get_payload_preserves_legacy_sections:Function]
# @PURPOSE: Ensure get_payload merges typed config into raw payload without dropping legacy sections.
def test_get_payload_preserves_legacy_sections():
manager = ConfigManager.__new__(ConfigManager)
manager.raw_payload = {"notifications": {"smtp": {"host": "mail.local"}}}
manager.config = AppConfig(environments=[], settings=GlobalSettings())
payload = manager.get_payload()
assert payload["settings"]["migration_sync_cron"] == "0 2 * * *"
assert payload["notifications"]["smtp"]["host"] == "mail.local"
# [/DEF:test_get_payload_preserves_legacy_sections:Function]
# [DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
# @PURPOSE: Ensure save_config accepts raw dict payload, refreshes typed config, and preserves extra sections.
def test_save_config_accepts_raw_payload_and_keeps_extras(monkeypatch):
manager = ConfigManager.__new__(ConfigManager)
manager.raw_payload = {}
manager.config = AppConfig(environments=[], settings=GlobalSettings())
persisted = {}
def _capture_save(config, session=None):
persisted["payload"] = manager.get_payload()
monkeypatch.setattr(manager, "_save_config_to_db", _capture_save)
manager.save_config(
{
"environments": [],
"settings": GlobalSettings().model_dump(),
"notifications": {"telegram": {"bot_token": "secret"}},
}
)
assert manager.raw_payload["notifications"]["telegram"]["bot_token"] == "secret"
assert manager.config.settings.migration_sync_cron == "0 2 * * *"
assert persisted["payload"]["notifications"]["telegram"]["bot_token"] == "secret"
# [/DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
# [/DEF:backend.src.core.__tests__.test_config_manager_compat:Module]

View File

@@ -15,7 +15,7 @@
import json
import os
from pathlib import Path
from typing import Optional, List
from typing import Any, Optional, List
from sqlalchemy.orm import Session
@@ -44,6 +44,7 @@ class ConfigManager:
logger.reason(f"Initializing ConfigManager with legacy path: {config_path}")
self.config_path = Path(config_path)
self.raw_payload: dict[str, Any] = {}
self.config: AppConfig = self._load_config()
configure_logger(self.config.settings.logging)
@@ -69,6 +70,20 @@ class ConfigManager:
)
# [/DEF:_default_config:Function]
# [DEF:_sync_raw_payload_from_config:Function]
# @PURPOSE: Merge typed AppConfig state into raw payload while preserving unsupported legacy sections.
# @PRE: self.config is initialized as AppConfig.
# @POST: self.raw_payload contains AppConfig fields refreshed from self.config.
# @SIDE_EFFECT: Mutates self.raw_payload in memory.
# @DATA_CONTRACT: Input(None) -> Output(dict[str, Any])
def _sync_raw_payload_from_config(self) -> dict[str, Any]:
with belief_scope("ConfigManager._sync_raw_payload_from_config"):
payload = dict(self.raw_payload) if isinstance(self.raw_payload, dict) else {}
payload.update(self.config.model_dump())
self.raw_payload = payload
return payload
# [/DEF:_sync_raw_payload_from_config:Function]
# [DEF:_load_from_legacy_file:Function]
# @PURPOSE: Load legacy JSON configuration for migration fallback path.
# @PRE: self.config_path is initialized.
@@ -79,15 +94,18 @@ class ConfigManager:
with belief_scope("_load_from_legacy_file"):
if not self.config_path.exists():
logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults")
self.raw_payload = self._default_config().model_dump()
return self._default_config()
try:
with open(self.config_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.raw_payload = dict(data) if isinstance(data, dict) else {}
logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded")
return AppConfig(**data)
except Exception as e:
logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {e}")
self.raw_payload = self._default_config().model_dump()
return self._default_config()
# [/DEF:_load_from_legacy_file:Function]
@@ -115,6 +133,7 @@ class ConfigManager:
record = self._get_record(session)
if record and record.payload:
logger.reason("Configuration found in database")
self.raw_payload = dict(record.payload)
config = AppConfig(**record.payload)
logger.reflect("Database configuration validated")
return config
@@ -126,6 +145,7 @@ class ConfigManager:
return config
except Exception as e:
logger.explore(f"Error loading config from DB: {e}")
self.raw_payload = self._default_config().model_dump()
return self._default_config()
finally:
session.close()
@@ -146,7 +166,7 @@ class ConfigManager:
db = session or SessionLocal()
try:
record = self._get_record(db)
payload = config.model_dump()
payload = self._sync_raw_payload_from_config()
if record is None:
logger.reason("Creating new global configuration record")
record = AppConfigRecord(id="global", payload=payload)
@@ -187,6 +207,38 @@ class ConfigManager:
return self.config
# [/DEF:get_config:Function]
# [DEF:get_payload:Function]
# @PURPOSE: Return full persisted payload including sections outside typed AppConfig schema.
# @PRE: Manager state is initialized.
# @POST: Returns dict payload with current AppConfig fields synchronized.
# @SIDE_EFFECT: Refreshes self.raw_payload from self.config before returning.
# @DATA_CONTRACT: Input(None) -> Output(dict[str, Any])
def get_payload(self) -> dict[str, Any]:
with belief_scope("ConfigManager.get_payload"):
return dict(self._sync_raw_payload_from_config())
# [/DEF:get_payload:Function]
# [DEF:save_config:Function]
# @PURPOSE: Persist configuration provided either as typed AppConfig or raw payload dict.
# @PRE: config is AppConfig or dict compatible with AppConfig core schema.
# @POST: self.config and self.raw_payload are synchronized and persisted to DB.
# @SIDE_EFFECT: Mutates in-memory config state, writes database record, may reconfigure logger.
# @DATA_CONTRACT: Input(AppConfig|dict[str, Any]) -> Output(None)
def save_config(self, config: AppConfig | dict[str, Any]):
with belief_scope("ConfigManager.save_config"):
if isinstance(config, AppConfig):
self.config = config
self.raw_payload = dict(config.model_dump())
elif isinstance(config, dict):
self.raw_payload = dict(config)
self.config = AppConfig(**config)
else:
raise TypeError("config must be an AppConfig instance or dict payload")
self._save_config_to_db(self.config)
configure_logger(self.config.settings.logging)
# [/DEF:save_config:Function]
# [DEF:update_global_settings:Function]
# @PURPOSE: Replace global settings and persist the resulting configuration.
# @PRE: settings is GlobalSettings.

View File

@@ -5,6 +5,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
BACKEND_ENV_FILE="$SCRIPT_DIR/backend/.env"
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker is not installed or not in PATH."
exit 1
@@ -19,6 +21,65 @@ else
exit 1
fi
ensure_backend_encryption_key() {
if command -v python3 >/dev/null 2>&1; then
python3 - "$BACKEND_ENV_FILE" <<'PY'
import base64
import os
import sys
from pathlib import Path
def is_valid_fernet_key(raw_value: str) -> bool:
value = raw_value.strip()
if not value:
return False
try:
decoded = base64.urlsafe_b64decode(value.encode())
except Exception:
return False
return len(decoded) == 32
def generate_fernet_key() -> str:
return base64.urlsafe_b64encode(os.urandom(32)).decode()
env_path = Path(sys.argv[1])
env_path.parent.mkdir(parents=True, exist_ok=True)
existing_lines: list[str] = []
existing_key: str | None = None
if env_path.exists():
existing_lines = env_path.read_text(encoding="utf-8").splitlines()
for line in existing_lines:
if line.startswith("ENCRYPTION_KEY="):
candidate = line.partition("=")[2].strip()
if is_valid_fernet_key(candidate):
existing_key = candidate
break
if existing_key is None:
generated_key = generate_fernet_key()
filtered_lines = [line for line in existing_lines if not line.startswith("ENCRYPTION_KEY=")]
filtered_lines.append(f"ENCRYPTION_KEY={generated_key}")
env_path.write_text("\n".join(filtered_lines) + "\n", encoding="utf-8")
print(f"[build] ENCRYPTION_KEY ensured in {env_path}")
else:
print(f"[build] Existing ENCRYPTION_KEY reused from {env_path}")
PY
return
fi
echo "Error: python3 is required to generate backend/.env with ENCRYPTION_KEY."
exit 1
}
ensure_backend_encryption_key
echo "[1/2] Building project images..."
"${COMPOSE_CMD[@]}" build

View File

@@ -23,6 +23,8 @@ services:
dockerfile: docker/backend.Dockerfile
container_name: ss_tools_backend
restart: unless-stopped
env_file:
- ./backend/.env
depends_on:
db:
condition: service_healthy

View File

@@ -31,6 +31,7 @@
let testStatus = $state({ type: "", message: "" });
let isTesting = $state(false);
let togglingProviderIds = $state(new Set());
let deletingProviderIds = $state(new Set());
function isMultimodalModel(modelName) {
const token = (modelName || "").toLowerCase();
@@ -155,6 +156,38 @@
onSave();
}
}
async function handleDelete(provider) {
if (deletingProviderIds.has(provider.id)) return;
if (
!confirm(
$t.llm.delete_confirm.replace("{name}", provider.name || provider.id),
)
) {
return;
}
deletingProviderIds.add(provider.id);
deletingProviderIds = new Set(deletingProviderIds);
try {
await requestApi(`/llm/providers/${provider.id}`, "DELETE");
providers = providers.filter(({ id }) => id !== provider.id);
addToast($t.llm.delete_success.replace("{name}", provider.name), "success");
onSave();
} catch (err) {
addToast(
$t.llm.delete_failed.replace(
"{error}",
err?.message || $t.common.error,
),
"error",
);
} finally {
deletingProviderIds.delete(provider.id);
deletingProviderIds = new Set(deletingProviderIds);
}
}
</script>
<div class="p-4">
@@ -345,11 +378,23 @@
>
{$t.common.edit}
</button>
<button
type="button"
class="text-sm text-red-600 hover:underline"
onclick={() => handleDelete(provider)}
disabled={deletingProviderIds.has(provider.id)}
>
{#if deletingProviderIds.has(provider.id)}
...
{:else}
{$t.common.delete}
{/if}
</button>
<button
type="button"
class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`}
onclick={() => toggleActive(provider)}
disabled={togglingProviderIds.has(provider.id)}
disabled={togglingProviderIds.has(provider.id) || deletingProviderIds.has(provider.id)}
>
{#if togglingProviderIds.has(provider.id)}
...

View File

@@ -1,7 +1,7 @@
// [DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]
// @TIER: STANDARD
// @SEMANTICS: llm, provider-config, integration-test, edit-flow
// @PURPOSE: Protect edit-button interaction contract in LLM provider settings UI.
// @SEMANTICS: llm, provider-config, integration-test, edit-flow, delete-flow
// @PURPOSE: Protect edit and delete interaction contracts in LLM provider settings UI.
// @LAYER: UI Tests
// @RELATION: VERIFIES -> frontend/src/components/llm/ProviderConfig.svelte
// @INVARIANT: Edit action keeps explicit click handler and opens normalized edit form.
@@ -17,7 +17,7 @@ const COMPONENT_PATH = path.resolve(
// [DEF:provider_config_edit_contract_tests:Function]
// @TIER: STANDARD
// @PURPOSE: Validate edit button handler wiring and normalized edit form state mapping.
// @PURPOSE: Validate edit and delete handler wiring plus normalized edit form state mapping.
// @PRE: ProviderConfig component source exists in expected path.
// @POST: Contract checks ensure edit click cannot degrade into no-op flow.
describe('ProviderConfig edit interaction contract', () => {
@@ -25,9 +25,7 @@ describe('ProviderConfig edit interaction contract', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(source).toContain('type="button"');
expect(source).toContain(
"on:click|preventDefault|stopPropagation={() => handleEdit(provider)}",
);
expect(source).toContain('onclick={() => handleEdit(provider)}');
});
it('normalizes provider payload into editable form shape', () => {
@@ -39,6 +37,15 @@ describe('ProviderConfig edit interaction contract', () => {
expect(source).toContain('default_model: provider?.default_model ?? "gpt-4o"');
expect(source).toContain('showForm = true;');
});
it('keeps explicit delete flow with confirmation and delete request', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(source).toContain('async function handleDelete(provider)');
expect(source).toContain('$t.llm.delete_confirm.replace("{name}", provider.name || provider.id)');
expect(source).toContain('await requestApi(`/llm/providers/${provider.id}`, "DELETE")');
expect(source).toContain('onclick={() => handleDelete(provider)}');
});
});
// [/DEF:provider_config_edit_contract_tests:Function]
// [/DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]

View File

@@ -111,6 +111,9 @@
"connection_success": "Connection successful!",
"connection_failed": "Connection failed: {error}",
"no_providers": "No providers configured.",
"delete_confirm": "Delete LLM provider \"{name}\"?",
"delete_success": "Provider \"{name}\" deleted.",
"delete_failed": "Failed to delete provider: {error}",
"multimodal": "Multimodal",
"text_only": "Text only",
"doc_preview_title": "Documentation Preview",

View File

@@ -111,6 +111,9 @@
"connection_success": "Подключение успешно!",
"connection_failed": "Ошибка подключения: {error}",
"no_providers": "Провайдеры не настроены.",
"delete_confirm": "Удалить LLM-провайдера \"{name}\"?",
"delete_success": "Провайдер \"{name}\" удалён.",
"delete_failed": "Не удалось удалить провайдера: {error}",
"multimodal": "Мультимодальная",
"text_only": "Только текст",
"doc_preview_title": "Предпросмотр документации",