feat(07-05): admin AI-config endpoints + load_provider_config_by_id + atomic is_active flip

- Add GET/PUT /api/admin/ai-config + GET /api/admin/ai-config/test-connection
- _ai_config_to_dict whitelist excludes api_key_enc (T-07-01)
- PUT: HKDF-encrypt api_key, atomic UPDATE SET is_active = (provider_id = target) (T-07-03)
- PUT: upsert with PROVIDER_DEFAULTS for omitted fields; audit log records fields_changed only (T-07-14)
- All 3 endpoints require get_current_admin (T-07-15)
- Add load_provider_config_by_id to services/ai_config.py (ignores is_active)
- Promote 3 xfail tests in test_admin_ai_config.py — all 3 pass
This commit is contained in:
curo1305
2026-06-04 23:19:40 +02:00
parent fb4ce293ae
commit e678930b8d
3 changed files with 405 additions and 20 deletions
+45
View File
@@ -151,6 +151,51 @@ async def load_provider_config(session: AsyncSession) -> Optional[ProviderConfig
)
# ── Provider config loader by ID ─────────────────────────────────────────────
async def load_provider_config_by_id(session: AsyncSession, provider_id: str) -> Optional[ProviderConfig]:
"""Load an AI provider config from system_settings by provider_id.
Unlike load_provider_config(), this function does NOT require is_active=True.
Used by the admin test-connection endpoint so admins can test inactive rows.
Args:
session: An open AsyncSession.
provider_id: The provider slug to load (e.g. "openai", "lmstudio").
Returns:
A ProviderConfig if a row with the given provider_id exists; None otherwise.
"""
from db.models import SystemSettings # local import to avoid circular deps
stmt = select(SystemSettings).where(SystemSettings.provider_id == provider_id)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
if row is None:
return None
# Decrypt API key if present
api_key = ""
if row.api_key_enc:
master_key = settings.cloud_creds_key.encode("utf-8")
try:
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
except Exception:
logger.warning(
"ai_config.load_provider_config_by_id: failed to decrypt api_key_enc",
provider_id=row.provider_id,
)
return ProviderConfig(
provider_id=row.provider_id,
api_key=api_key,
base_url=row.base_url,
model=row.model_name,
context_chars=row.context_chars,
)
# ── Startup seed ──────────────────────────────────────────────────────────────
async def seed_system_settings_from_env(session: AsyncSession) -> None: