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 json
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Any, Optional, List
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -44,6 +44,7 @@ class ConfigManager:
logger.reason(f"Initializing ConfigManager with legacy path: {config_path}") logger.reason(f"Initializing ConfigManager with legacy path: {config_path}")
self.config_path = Path(config_path) self.config_path = Path(config_path)
self.raw_payload: dict[str, Any] = {}
self.config: AppConfig = self._load_config() self.config: AppConfig = self._load_config()
configure_logger(self.config.settings.logging) configure_logger(self.config.settings.logging)
@@ -69,6 +70,20 @@ class ConfigManager:
) )
# [/DEF:_default_config:Function] # [/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] # [DEF:_load_from_legacy_file:Function]
# @PURPOSE: Load legacy JSON configuration for migration fallback path. # @PURPOSE: Load legacy JSON configuration for migration fallback path.
# @PRE: self.config_path is initialized. # @PRE: self.config_path is initialized.
@@ -79,15 +94,18 @@ class ConfigManager:
with belief_scope("_load_from_legacy_file"): with belief_scope("_load_from_legacy_file"):
if not self.config_path.exists(): if not self.config_path.exists():
logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults") 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() return self._default_config()
try: try:
with open(self.config_path, "r", encoding="utf-8") as f: with open(self.config_path, "r", encoding="utf-8") as f:
data = json.load(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") logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded")
return AppConfig(**data) return AppConfig(**data)
except Exception as e: except Exception as e:
logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {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() return self._default_config()
# [/DEF:_load_from_legacy_file:Function] # [/DEF:_load_from_legacy_file:Function]
@@ -115,6 +133,7 @@ class ConfigManager:
record = self._get_record(session) record = self._get_record(session)
if record and record.payload: if record and record.payload:
logger.reason("Configuration found in database") logger.reason("Configuration found in database")
self.raw_payload = dict(record.payload)
config = AppConfig(**record.payload) config = AppConfig(**record.payload)
logger.reflect("Database configuration validated") logger.reflect("Database configuration validated")
return config return config
@@ -126,6 +145,7 @@ class ConfigManager:
return config return config
except Exception as e: except Exception as e:
logger.explore(f"Error loading config from DB: {e}") logger.explore(f"Error loading config from DB: {e}")
self.raw_payload = self._default_config().model_dump()
return self._default_config() return self._default_config()
finally: finally:
session.close() session.close()
@@ -146,7 +166,7 @@ class ConfigManager:
db = session or SessionLocal() db = session or SessionLocal()
try: try:
record = self._get_record(db) record = self._get_record(db)
payload = config.model_dump() payload = self._sync_raw_payload_from_config()
if record is None: if record is None:
logger.reason("Creating new global configuration record") logger.reason("Creating new global configuration record")
record = AppConfigRecord(id="global", payload=payload) record = AppConfigRecord(id="global", payload=payload)
@@ -187,6 +207,38 @@ class ConfigManager:
return self.config return self.config
# [/DEF:get_config:Function] # [/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] # [DEF:update_global_settings:Function]
# @PURPOSE: Replace global settings and persist the resulting configuration. # @PURPOSE: Replace global settings and persist the resulting configuration.
# @PRE: settings is GlobalSettings. # @PRE: settings is GlobalSettings.

View File

@@ -5,6 +5,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
BACKEND_ENV_FILE="$SCRIPT_DIR/backend/.env"
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker is not installed or not in PATH." echo "Error: docker is not installed or not in PATH."
exit 1 exit 1
@@ -19,6 +21,65 @@ else
exit 1 exit 1
fi 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..." echo "[1/2] Building project images..."
"${COMPOSE_CMD[@]}" build "${COMPOSE_CMD[@]}" build

View File

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

View File

@@ -31,6 +31,7 @@
let testStatus = $state({ type: "", message: "" }); let testStatus = $state({ type: "", message: "" });
let isTesting = $state(false); let isTesting = $state(false);
let togglingProviderIds = $state(new Set()); let togglingProviderIds = $state(new Set());
let deletingProviderIds = $state(new Set());
function isMultimodalModel(modelName) { function isMultimodalModel(modelName) {
const token = (modelName || "").toLowerCase(); const token = (modelName || "").toLowerCase();
@@ -155,6 +156,38 @@
onSave(); 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> </script>
<div class="p-4"> <div class="p-4">
@@ -345,11 +378,23 @@
> >
{$t.common.edit} {$t.common.edit}
</button> </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 <button
type="button" type="button"
class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`} class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`}
onclick={() => toggleActive(provider)} onclick={() => toggleActive(provider)}
disabled={togglingProviderIds.has(provider.id)} disabled={togglingProviderIds.has(provider.id) || deletingProviderIds.has(provider.id)}
> >
{#if togglingProviderIds.has(provider.id)} {#if togglingProviderIds.has(provider.id)}
... ...

View File

@@ -1,7 +1,7 @@
// [DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module] // [DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]
// @TIER: STANDARD // @TIER: STANDARD
// @SEMANTICS: llm, provider-config, integration-test, edit-flow // @SEMANTICS: llm, provider-config, integration-test, edit-flow, delete-flow
// @PURPOSE: Protect edit-button interaction contract in LLM provider settings UI. // @PURPOSE: Protect edit and delete interaction contracts in LLM provider settings UI.
// @LAYER: UI Tests // @LAYER: UI Tests
// @RELATION: VERIFIES -> frontend/src/components/llm/ProviderConfig.svelte // @RELATION: VERIFIES -> frontend/src/components/llm/ProviderConfig.svelte
// @INVARIANT: Edit action keeps explicit click handler and opens normalized edit form. // @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] // [DEF:provider_config_edit_contract_tests:Function]
// @TIER: STANDARD // @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. // @PRE: ProviderConfig component source exists in expected path.
// @POST: Contract checks ensure edit click cannot degrade into no-op flow. // @POST: Contract checks ensure edit click cannot degrade into no-op flow.
describe('ProviderConfig edit interaction contract', () => { describe('ProviderConfig edit interaction contract', () => {
@@ -25,9 +25,7 @@ describe('ProviderConfig edit interaction contract', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(source).toContain('type="button"'); expect(source).toContain('type="button"');
expect(source).toContain( expect(source).toContain('onclick={() => handleEdit(provider)}');
"on:click|preventDefault|stopPropagation={() => handleEdit(provider)}",
);
}); });
it('normalizes provider payload into editable form shape', () => { 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('default_model: provider?.default_model ?? "gpt-4o"');
expect(source).toContain('showForm = true;'); 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:provider_config_edit_contract_tests:Function]
// [/DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module] // [/DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]

View File

@@ -111,6 +111,9 @@
"connection_success": "Connection successful!", "connection_success": "Connection successful!",
"connection_failed": "Connection failed: {error}", "connection_failed": "Connection failed: {error}",
"no_providers": "No providers configured.", "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", "multimodal": "Multimodal",
"text_only": "Text only", "text_only": "Text only",
"doc_preview_title": "Documentation Preview", "doc_preview_title": "Documentation Preview",

View File

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