feat: Implement LLM provider deletion and refactor ConfigManager to preserve unknown payload sections.
This commit is contained in:
21
.gitattributes
vendored
Normal file
21
.gitattributes
vendored
Normal 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
|
||||
53
backend/src/core/__tests__/test_config_manager_compat.py
Normal file
53
backend/src/core/__tests__/test_config_manager_compat.py
Normal 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]
|
||||
@@ -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.
|
||||
|
||||
61
build.sh
61
build.sh
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
...
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Предпросмотр документации",
|
||||
|
||||
Reference in New Issue
Block a user