---
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 placed above the existing block. The new section uses Tailwind classes matching the existing rounded-xl bordered card pattern. Structure: card header "System AI Providers (Global)"; explanatory paragraph "These settings apply to all classification jobs. Per-user overrides below take precedence when set."; loading spinner (reuse pattern from lines 2-9); error banner; main body — a list of accordion rows, one per provider_id from systemProviders. Each accordion row shows: provider_id (e.g., "OpenAI"), is_active badge ("Active" green when true), expand chevron. Expanded body has: API key input (type=password, placeholder switches between "(unchanged)" and "(not set)"), Base URL input (placeholder is the PROVIDER_DEFAULTS base_url), Model input (placeholder is the default model), Context chars number input (placeholder is default context_chars), three buttons in a row: "Set Active" (calls saveAiConfig with is_active=true), "Save" (calls saveAiConfig with only the modified fields), "Test Connection" (calls testAiConnection, shows ok/failed badge for 3s).
In