--- phase: 07-redo-and-optimize-llm-integration plan: 05 type: execute wave: 5 depends_on: - 07-04 files_modified: - backend/api/admin.py - backend/api/__init__.py - backend/services/ai_config.py # Task 1 adds load_provider_config_by_id - frontend/src/api/client.js - frontend/src/components/admin/AdminAiConfigTab.vue - frontend/src/components/documents/DocumentCard.vue - backend/tests/test_admin_ai_config.py - frontend/tests/api.spec.js # NEW Vitest tests for getAiConfig/saveAiConfig/testAiConnection (D-11 frontend coverage) - frontend/tests/DocumentCard.spec.js # NEW Vitest test for reanalyze() (D-11 frontend coverage) autonomous: false requirements: - D-05 - D-08 - D-11 - D-15 must_haves: truths: - "GET /api/admin/ai-config returns a list of provider configs with provider_id, base_url, model_name, context_chars, is_active, updated_at — never api_key_enc" - "PUT /api/admin/ai-config accepts {provider_id, api_key?, base_url?, model?, context_chars?, is_active?}; when api_key is provided it is HKDF-encrypted via encrypt_api_key and stored; when omitted the existing api_key_enc is left untouched" - "PUT /api/admin/ai-config atomically flips is_active via a single UPDATE: SET is_active = (provider_id = $target) when is_active=true is requested" - "GET /api/admin/ai-config/test-connection?provider_id=X loads the row, builds ProviderConfig, get_provider(config).health_check(), returns {\"ok\": bool}" - "load_provider_config_by_id(session, provider_id) is added to backend/services/ai_config.py — filters by provider_id, ignores is_active so test-connection works on inactive rows" - "AdminAiConfigTab.vue adds a Global AI Providers section ABOVE the existing per-user assignment table; per-user table remains untouched" - "DocumentCard.vue shows a red 'Classification failed' badge with a 'Re-analyze' button when doc.status === 'classification_failed'" - "Re-analyze button posts to /api/documents/{id}/classify via the existing classifyDocument client function; spinner displayed while in-flight" - "All admin AI-config endpoints require get_current_admin and write audit_log entries with metadata containing only provider_id (never api_key)" - "frontend/tests/api.spec.js Vitest tests verify getAiConfig/saveAiConfig/testAiConnection call request() with the expected method, path, and JSON body shape (D-11 frontend coverage; CLAUDE.md mandate that every new function has at least one test)" - "frontend/tests/DocumentCard.spec.js Vitest test verifies reanalyze() calls classifyDocument with props.doc.id and emits 'reclassified' (D-11 frontend coverage)" artifacts: - path: "backend/api/admin.py" provides: "GET/PUT /api/admin/ai-config + test-connection endpoint + _ai_config_to_dict whitelist" contains: "_ai_config_to_dict" - path: "backend/services/ai_config.py" provides: "load_provider_config_by_id (added in Task 1)" contains: "load_provider_config_by_id" - path: "frontend/src/api/client.js" provides: "getAiConfig/saveAiConfig/testAiConnection helpers" contains: "getAiConfig" - path: "frontend/src/components/admin/AdminAiConfigTab.vue" provides: "Global system provider config section + per-provider accordion + test-connection button" contains: "System AI Providers" - path: "frontend/src/components/documents/DocumentCard.vue" provides: "Classification failed badge + Re-analyze button" contains: "classification_failed" - path: "frontend/tests/api.spec.js" provides: "Vitest unit tests for getAiConfig/saveAiConfig/testAiConnection" contains: "getAiConfig" - path: "frontend/tests/DocumentCard.spec.js" provides: "Vitest unit test for DocumentCard.reanalyze() (D-11 frontend half)" contains: "reanalyze" key_links: - from: "backend/api/admin.py PUT /api/admin/ai-config" to: "backend/services/ai_config.py::encrypt_api_key" via: "HKDF encryption before DB write" pattern: "encrypt_api_key" - from: "backend/api/admin.py GET /api/admin/ai-config/test-connection" to: "backend/services/ai_config.py::load_provider_config_by_id" via: "fetch row by provider_id (ignoring is_active)" pattern: "load_provider_config_by_id" - from: "backend/api/admin.py PUT /api/admin/ai-config" to: "PostgreSQL atomic is_active flip" via: "single UPDATE SET is_active = (provider_id = $target)" pattern: "is_active = \\(provider_id =" - from: "frontend AdminAiConfigTab.vue saveSystemConfig" to: "PUT /api/admin/ai-config" via: "fetch wrapper request()" pattern: "saveAiConfig" - from: "frontend DocumentCard.vue reanalyze()" to: "POST /api/documents/{id}/classify" via: "existing classifyDocument client function" pattern: "classifyDocument" --- Surface the new AI provider stack in the UI: an admin "AI Providers" configuration panel that drives the `system_settings` table (read/write/test) and a Document card "Classification failed" badge with a re-analyze button that triggers the Plan 04 re-queue endpoint. Purpose: Close D-08 (admin AI Providers panel with test-connection), D-11 frontend half (failed badge + re-analyze button), and D-15 (LMStudio base URL configurable through the panel). Enforce the admin-endpoint invariant that `api_key_enc` is never serialised in any response (Threat T-07-01 mitigation). Add Vitest coverage for the three new frontend API helpers and the reanalyze() handler so the D-11 frontend half satisfies CLAUDE.md's "every new function / component must have at least one test" rule. Output: Backend admin endpoints (GET / PUT / test-connection), `load_provider_config_by_id` added to backend/services/ai_config.py, frontend API client helpers, AdminAiConfigTab.vue extended with a global System AI Providers section above the existing per-user table, DocumentCard.vue badge + button, Vitest test files for the new client helpers and DocumentCard.reanalyze, and the matching admin endpoint security tests promoted from xfail. Plan ends on a human checkpoint that exercises the panel and badge end-to-end. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/07-redo-and-optimize-llm-integration/07-CONTEXT.md @.planning/phases/07-redo-and-optimize-llm-integration/07-RESEARCH.md @.planning/phases/07-redo-and-optimize-llm-integration/07-PATTERNS.md @.planning/phases/07-redo-and-optimize-llm-integration/07-04-SUMMARY.md @backend/api/admin.py @backend/services/ai_config.py @backend/ai/__init__.py @backend/ai/provider_config.py @backend/services/storage.py @backend/deps/utils.py @frontend/src/api/client.js @frontend/src/components/admin/AdminAiConfigTab.vue @frontend/src/components/documents/DocumentCard.vue From backend/api/admin.py (existing patterns): - Router prefix is "/api/admin" (or similar — verify); endpoints take Depends(get_db), Depends(get_current_admin) - `_user_to_dict(user) -> dict` is the whitelist helper pattern (see 07-PATTERNS.md "Admin Endpoint Whitelist Helper") - `await write_audit_log(session, event_type=..., user_id=..., actor_id=..., resource_id=..., ip_address=get_client_ip(request), metadata_={...})` is used for admin actions - get_client_ip lives in backend/deps/utils.py — import from there From backend/services/ai_config.py (introduced in Plan 01 + Plan 03): - `encrypt_api_key(master_key: bytes, provider_id: str, api_key: str) -> str` - `decrypt_api_key(master_key: bytes, provider_id: str, ciphertext: str) -> str` - `load_provider_config(session) -> ProviderConfig | None` (per-row, active=True) - For PUT we ALSO need to load by provider_id explicitly (not just is_active) — Task 1 adds `async def load_provider_config_by_id(session, provider_id) -> ProviderConfig | None` to this file From backend/ai/__init__.py and backend/ai/provider_config.py (Plan 02): - `get_provider(config: ProviderConfig) -> AIProvider` - `PROVIDER_DEFAULTS` dict — the 10 known provider_ids; PUT validates incoming provider_id against this list From frontend/src/api/client.js (existing pattern, line ~277): - `request(path, options)` wrapper; `adminUpdateAiConfig(id, provider, model)` is the existing per-user PATCH pattern - `classifyDocument(docId)` is the existing client function that POSTs /api/documents/{id}/classify (used by DocumentCard.reanalyze) From frontend/src/components/admin/AdminAiConfigTab.vue (existing — per Pitfall 6): - Existing per-user table powered by `users` ref + `configs` reactive + `saveConfig(userId)` — KEEP UNTOUCHED - This plan adds a SECOND top section before/above the per-user table From frontend/src/components/documents/DocumentCard.vue (existing): - `doc` prop has status, is_shared fields; existing pattern at lines 31-34 shows the Shared badge Frontend Vitest framework (existing project setup): - Vitest is the unit-test runner (matching the project's vue-test-utils / Vitest convention used elsewhere) - Run command: `cd frontend && npm run test -- --run` (one-shot, no watch) - If frontend/tests/ does not yet exist, create it. Configure vite.config.js / vitest.config.js to pick up `tests/**/*.spec.js` — if a config already exists, follow whatever globs are defined; otherwise add `test: { include: ['tests/**/*.spec.js'] }` to the existing vite config. Task 1: Admin AI-config backend (GET, PUT, test-connection) + audit logging + load_provider_config_by_id backend/api/admin.py backend/services/ai_config.py backend/ai/__init__.py backend/ai/provider_config.py backend/deps/utils.py backend/db/models.py backend/tests/test_admin_ai_config.py - `GET /api/admin/ai-config` returns 200 with body `{"providers": [...]}` where each entry is the whitelisted dict (provider_id, base_url, model_name, context_chars, is_active, has_api_key (bool), updated_at) - `api_key_enc` and `api_key` are never present in any response body - `PUT /api/admin/ai-config` accepts body `{provider_id: str, api_key?: str, base_url?: str|null, model?: str, context_chars?: int, is_active?: bool}` validated by a Pydantic model with extra="forbid" - When body.api_key is a non-empty string → encrypt with encrypt_api_key(master_key=cloud_creds_key, provider_id, api_key) and store in api_key_enc - When body.api_key is omitted (None) → leave existing api_key_enc untouched (do not overwrite to NULL) - When body.api_key == "" → set api_key_enc to NULL (explicit clear) - When body.is_active is True → single UPDATE: `UPDATE system_settings SET is_active = (provider_id = :target_id)` — atomically flips all rows - If no row exists for provider_id, PUT inserts one (upsert behaviour) with the supplied fields, defaults from PROVIDER_DEFAULTS for omitted fields - PUT writes an audit log entry: event_type="admin.ai_config_changed", actor_id=admin.id, user_id=None, resource_id=None, metadata_={"provider_id": body.provider_id, "fields_changed": [list of fields the body explicitly set, NEVER api_key value]} - `GET /api/admin/ai-config/test-connection?provider_id=X` returns `{"ok": bool, "provider_id": str}`; loads the row, calls load_provider_config_by_id, builds via get_provider, awaits health_check(); returns ok=False if any exception or no row found (does not return 5xx for provider failures — surface as ok=False so the UI shows a clear status) - All three endpoints depend on get_current_admin (admin-only) - backend/services/ai_config.py gains `load_provider_config_by_id(session, provider_id)` — same shape as load_provider_config but filters by provider_id and does NOT require is_active=True - Promote test_admin_ai_config.py::test_get_never_returns_key (asserts api_key_enc not in JSON body) and test_put_writes_active_provider (asserts atomic is_active flip) Open backend/api/admin.py. Add imports: `from services.ai_config import encrypt_api_key, decrypt_api_key, load_provider_config, load_provider_config_by_id`, `from ai import get_provider`, `from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS`, `from db.models import SystemSettings`, `from sqlalchemy import select, update`. (Verify exact import paths against the existing admin.py style.) Add new Pydantic request model alongside existing ones: `class AiConfigUpdate(BaseModel): model_config = ConfigDict(extra="forbid"); provider_id: str; api_key: Optional[str] = None; base_url: Optional[str] = None; model_name: Optional[str] = None; context_chars: Optional[int] = None; is_active: Optional[bool] = None` — and a `@field_validator("provider_id")` that asserts the value is in PROVIDER_DEFAULTS.keys() (rejects unknown providers with 422). Add `_ai_config_to_dict(row: SystemSettings) -> dict` whitelist helper (matches `_user_to_dict` pattern from lines 52-68): returns `{"provider_id": row.provider_id, "base_url": row.base_url, "model_name": row.model_name, "context_chars": row.context_chars, "is_active": row.is_active, "has_api_key": row.api_key_enc is not None, "updated_at": row.updated_at.isoformat() if row.updated_at else None}` — explicitly excludes api_key_enc. Add GET endpoint: `@router.get("/ai-config") async def get_ai_config(session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin)) -> dict:` — selects all SystemSettings rows, returns `{"providers": [_ai_config_to_dict(r) for r in rows]}`. Also include rows from PROVIDER_DEFAULTS that have no DB record (synthesized as `{"provider_id": key, "base_url": defaults["base_url"], "model_name": defaults["model"], "context_chars": defaults["context_chars"], "is_active": False, "has_api_key": False, "updated_at": None}`) so the admin UI can show all 10 providers even before any have been saved. Add PUT endpoint: `@router.put("/ai-config") async def update_ai_config(body: AiConfigUpdate, request: Request, session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin)) -> dict:` — load existing row via select(SystemSettings).where(provider_id == body.provider_id); if absent create new SystemSettings instance with defaults from PROVIDER_DEFAULTS[body.provider_id]; apply provided fields. For api_key: if body.api_key is None leave api_key_enc unchanged; if body.api_key == "" set api_key_enc = None; if body.api_key is a non-empty string set api_key_enc = encrypt_api_key(settings.cloud_creds_key.encode() if isinstance(settings.cloud_creds_key, str) else settings.cloud_creds_key, body.provider_id, body.api_key). session.add(row) if newly created. If body.is_active is True: execute `update(SystemSettings).values(is_active=(SystemSettings.provider_id == body.provider_id))` — atomic flip (no read-then-write). Then await write_audit_log with metadata_={"provider_id": body.provider_id, "fields_changed": [k for k in ("api_key","base_url","model_name","context_chars","is_active") if getattr(body, k, None) is not None]}. Commit. Return `_ai_config_to_dict(row)`. Add test-connection endpoint: `@router.get("/ai-config/test-connection") async def test_ai_connection(provider_id: str, session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin)) -> dict:` — load config via load_provider_config_by_id; if None return {"ok": False, "provider_id": provider_id, "reason": "not_configured"}; build provider via get_provider(config); try: await provider.health_check(); return {"ok": True, "provider_id": provider_id}. except Exception as exc: return {"ok": False, "provider_id": provider_id, "reason": "health_check_failed"}. Add `async def load_provider_config_by_id(session, provider_id: str) -> ProviderConfig | None` to backend/services/ai_config.py — mirrors load_provider_config but filters by provider_id and ignores is_active (so test-connection works on inactive rows too). (THIS FILE IS LISTED IN files_modified — Task 1 modifies it.) Promote backend/tests/test_admin_ai_config.py: test_get_never_returns_key — call GET as admin, assert response status 200, assert "api_key_enc" not in response.text and "api_key" not in response.json()["providers"][0]. test_put_writes_active_provider — PUT with provider_id="openai", api_key="sk-test", is_active=True; assert response 200 and has_api_key=True; PUT another provider with is_active=True; query DB and assert exactly one row has is_active=True. Add a third test (recommended) test_put_admin_only — call PUT as a regular (non-admin) user, assert 403. Remove xfail decorators. cd backend && pytest tests/test_admin_ai_config.py -x -v - Source assertion: backend/api/admin.py contains `_ai_config_to_dict`, `class AiConfigUpdate(BaseModel)`, `@router.get("/ai-config")`, `@router.put("/ai-config")`, `@router.get("/ai-config/test-connection")` - Source assertion: backend/api/admin.py PUT body uses `update(SystemSettings).values(is_active=(SystemSettings.provider_id == body.provider_id))` (atomic flip) - Source assertion: `_ai_config_to_dict` body contains no reference to `api_key_enc` (no leak) - Source assertion: backend/services/ai_config.py contains `async def load_provider_config_by_id` - Behavior: pytest backend/tests/test_admin_ai_config.py exits 0 (all promoted tests pass) - Behavior: GET /api/admin/ai-config response body never contains the string "api_key_enc" or "api_key" value (assertion in test_get_never_returns_key) - Behavior: After two PUTs with is_active=True on different provider_ids, SELECT COUNT(*) WHERE is_active = TRUE returns 1 (asserted in test_put_writes_active_provider) - Behavior: PUT by non-admin user returns 403 (admin-only enforcement) Three admin AI-config endpoints land; atomic is_active flip enforced; api_key_enc never serialised; audit log records changes without leaking secrets; load_provider_config_by_id added to backend/services/ai_config.py; three Wave-5 admin tests promoted. Task 2: Frontend API client helpers + AdminAiConfigTab.vue global System AI Providers section + Vitest coverage frontend/src/api/client.js frontend/src/components/admin/AdminAiConfigTab.vue frontend/src/components/admin/AdminUsersTab.vue frontend/src/components/admin/AdminQuotasTab.vue frontend/vite.config.js frontend/package.json - frontend/src/api/client.js exports getAiConfig(), saveAiConfig(body), testAiConnection(providerId) - getAiConfig calls request("/api/admin/ai-config", { method: "GET" }) - saveAiConfig(body) calls request("/api/admin/ai-config", { method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body) }) - testAiConnection(providerId) calls request(`/api/admin/ai-config/test-connection?provider_id=${encodeURIComponent(providerId)}`, { method: "GET" }) - AdminAiConfigTab.vue gains a new top-of-template section titled "System AI Providers" placed ABOVE the existing per-user table; existing template, script, and saveConfig logic for per-user assignments is unchanged - New section: active-provider selector (radio or styled buttons over the 10 PROVIDER_DEFAULTS keys), per-provider accordion (open one at a time) with masked API key field (input type="password", placeholder shows "(unchanged)" when has_api_key is true, otherwise "(not set)"), base URL text input, model name text input, context_chars number input, Save button per provider, Test Connection button per provider - On mount: call getAiConfig() and populate a reactive systemProviders array; if API call fails, show a non-blocking error banner above the new section only - Saving a provider: build body containing only the fields the admin actually modified (api_key only if user typed something), call saveAiConfig, refresh systemProviders from response - Test Connection: call testAiConnection(providerId), show inline "OK" badge (green) or "Failed" badge (red) for 3 seconds - Per-user assignment block (existing) remains untouched (Pitfall 6 mitigation) - frontend/tests/api.spec.js Vitest test asserts getAiConfig/saveAiConfig/testAiConnection construct the correct fetch/request payloads (path, method, body, JSON serialization, encodeURIComponent for provider_id) - `npm run test -- --run` exits 0 with the new api.spec.js tests passing Edit frontend/src/api/client.js: append at the bottom (or near other admin endpoints around line 277): `export function getAiConfig() { return request('/api/admin/ai-config', { method: 'GET' }) }`; `export function saveAiConfig(body) { return request('/api/admin/ai-config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }) }`; `export function testAiConnection(providerId) { return request('/api/admin/ai-config/test-connection?provider_id=' + encodeURIComponent(providerId), { method: 'GET' }) }`. Edit frontend/src/components/admin/AdminAiConfigTab.vue. Do NOT alter the existing per-user table block (Pitfall 6). Add a new
at the top of the