diff --git a/backend/api/admin.py b/backend/api/admin.py index 63a1274..98bd5e2 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -152,6 +152,31 @@ class SystemAiConfigUpdate(BaseModel): return v +class TestConnectionRequest(BaseModel): + """Request body for POST /api/admin/ai-config/test-connection. + + Unsaved form values (api_key, base_url, model_name) override the DB row so + admins can verify credentials before saving. All override fields are optional; + omitting them falls back to whatever is stored in system_settings. + """ + + model_config = ConfigDict(extra="forbid") + + provider_id: str + api_key: Optional[str] = None # If non-empty, used instead of stored api_key_enc + base_url: Optional[str] = None # If non-None, overrides DB base_url + model_name: Optional[str] = None # If non-empty, overrides DB model_name + + @field_validator("provider_id") + @classmethod + def provider_must_be_known(cls, v: str) -> str: + if v not in PROVIDER_DEFAULTS: + raise ValueError( + f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}" + ) + return v + + class SystemTopicCreate(BaseModel): """Request model for admin system topic creation (D-09).""" @@ -628,32 +653,124 @@ async def create_system_topic( # ── System AI Provider Configuration (D-08, D-15) ──────────────────────────── -@router.get("/ai-config/test-connection") -async def test_ai_connection( +@router.get("/ai-config/models") +async def get_ai_config_models( provider_id: str, session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: - """Test connectivity for an AI provider configuration (D-08). + """Return the list of model IDs available from a provider's API (D-08). - Loads the system_settings row for the given provider_id (ignoring is_active - so admins can test inactive configurations), builds a provider instance, and - calls health_check(). + Calls the provider's standard GET /models endpoint using the stored + config (base_url + api_key from system_settings). Always returns 200 + with {"models": [...]} — never 5xx on provider failure (returns empty list). - Returns {"ok": true/false, "provider_id": str} — never raises 5xx for - provider-side failures (surfaces as ok=False so the UI shows a clear status). - - Security: requires get_current_admin; provider_id sourced from query param, - provider instance built only from DB row (never from request body). + Security: requires get_current_admin; provider_id from query param only; + decrypted api_key never appears in the response. """ + import httpx # noqa: PLC0415 — local import keeps admin.py startup fast + config = await load_provider_config_by_id(session, provider_id) - if config is None: - return {"ok": False, "provider_id": provider_id, "reason": "not_configured"} + + # Resolve base_url: prefer DB row, fall back to PROVIDER_DEFAULTS + if config and config.base_url: + base_url = config.base_url.rstrip("/") + else: + base_url = (PROVIDER_DEFAULTS.get(provider_id, {}).get("base_url") or "").rstrip("/") + + if not base_url: + return {"models": [], "provider_id": provider_id} + + api_key = config.api_key if config else "" + + # Build request headers — Anthropic uses x-api-key; all others use Bearer + if provider_id == "anthropic": + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + } + models_url = "https://api.anthropic.com/v1/models" + else: + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + models_url = f"{base_url}/models" try: - provider = get_provider(config) - await provider.health_check() - return {"ok": True, "provider_id": provider_id} + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.get(models_url, headers=headers) + resp.raise_for_status() + data = resp.json() + + # Standard OpenAI-compat shape: {"data": [{"id": "...", ...}, ...]} + # Anthropic shape: {"data": [{"id": "...", ...}, ...]} + # Ollama OpenAI-compat: same shape + raw_list = data.get("data") or data.get("models") or [] + model_ids: list[str] = sorted( + { + item["id"] if isinstance(item, dict) else str(item) + for item in raw_list + if item + } + ) + return {"models": model_ids, "provider_id": provider_id} + except Exception as exc: + return {"models": [], "provider_id": provider_id, "error": str(exc)[:120]} + + +@router.post("/ai-config/test-connection") +async def test_ai_connection( + body: TestConnectionRequest, + session: AsyncSession = Depends(get_db), + _admin: User = Depends(get_current_admin), +) -> dict: + """Test connectivity for an AI provider, optionally with unsaved form values (D-08). + + Loads the stored system_settings row for body.provider_id, then overlays any + non-empty values from the request body so admins can verify credentials before + saving them to the database. + + Override priority (highest → lowest): + 1. body.api_key / base_url / model_name (unsaved form values) + 2. system_settings DB row (previously saved config) + 3. PROVIDER_DEFAULTS (built-in fallback) + + Returns {"ok": true/false, "provider_id": str} — never raises 5xx for + provider-side failures; surfaces as ok=False so the UI shows a clear status. + + Security: requires get_current_admin; api_key from body is used only for the + in-flight health_check() call and is never stored or logged. + """ + provider_id = body.provider_id + stored = await load_provider_config_by_id(session, provider_id) + defaults = PROVIDER_DEFAULTS.get(provider_id, {}) + + # Resolve effective values: body overrides DB, DB overrides PROVIDER_DEFAULTS + effective_api_key = ( + body.api_key + if body.api_key + else (stored.api_key if stored else "") + ) + effective_base_url = ( + body.base_url + if body.base_url is not None + else (stored.base_url if stored else defaults.get("base_url")) + ) + effective_model = ( + body.model_name + if body.model_name + else (stored.model if stored else defaults.get("model", "")) + ) + + effective_config = ProviderConfig( + provider_id=provider_id, + api_key=effective_api_key, + base_url=effective_base_url, + model=effective_model, + ) + + try: + provider = get_provider(effective_config) + ok = await provider.health_check() + return {"ok": ok, "provider_id": provider_id} except Exception: return {"ok": False, "provider_id": provider_id, "reason": "health_check_failed"} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index d466746..25f82b2 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -304,9 +304,17 @@ export function saveAiConfig(body) { }) } -export function testAiConnection(providerId) { +export function testAiConnection(providerId, overrides = {}) { + return request('/api/admin/ai-config/test-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider_id: providerId, ...overrides }), + }) +} + +export function getAiModels(providerId) { return request( - '/api/admin/ai-config/test-connection?provider_id=' + encodeURIComponent(providerId), + '/api/admin/ai-config/models?provider_id=' + encodeURIComponent(providerId), { method: 'GET' } ) } diff --git a/frontend/src/components/admin/AdminAiConfigTab.vue b/frontend/src/components/admin/AdminAiConfigTab.vue index 334751b..e0eed21 100644 --- a/frontend/src/components/admin/AdminAiConfigTab.vue +++ b/frontend/src/components/admin/AdminAiConfigTab.vue @@ -74,14 +74,13 @@ /> - +
-
@@ -122,10 +121,14 @@ @@ -222,6 +225,7 @@ import { ref, reactive, onMounted } from 'vue' import * as api from '../../api/client.js' import { getAiConfig, saveAiConfig, testAiConnection } from '../../api/client.js' +import SearchableModelSelect from '../ui/SearchableModelSelect.vue' // ── Per-provider default lookup (mirrors backend PROVIDER_DEFAULTS) ─────────── @@ -251,6 +255,7 @@ const openProviderId = ref(null) const formByProvider = reactive({}) const testResults = reactive({}) const savingProvider = ref(null) +const testingProvider = ref(null) function toggleProvider(pid) { openProviderId.value = openProviderId.value === pid ? null : pid @@ -304,11 +309,21 @@ async function saveSystemProvider(providerId, opts = {}) { } async function runTestConnection(providerId) { + testingProvider.value = providerId + testResults[providerId] = null + const form = formByProvider[providerId] || {} + // Pass unsaved form values so admins can test credentials before saving + const overrides = {} + if (form.api_key) overrides.api_key = form.api_key + if (form.base_url) overrides.base_url = form.base_url + if (form.model_name) overrides.model_name = form.model_name try { - const r = await testAiConnection(providerId) + const r = await testAiConnection(providerId, overrides) testResults[providerId] = r.ok ? 'ok' : 'failed' } catch { testResults[providerId] = 'failed' + } finally { + testingProvider.value = null } setTimeout(() => { testResults[providerId] = null }, 3000) } diff --git a/frontend/src/components/documents/DocumentCard.vue b/frontend/src/components/documents/DocumentCard.vue index 2d46f05..0b4a4cf 100644 --- a/frontend/src/components/documents/DocumentCard.vue +++ b/frontend/src/components/documents/DocumentCard.vue @@ -16,8 +16,8 @@

{{ doc.original_name }}

{{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }}

- -
+ +
unclassified
+ +
+ + Classifying… +
+ + +
+ Queued +
+
Shared diff --git a/frontend/src/components/ui/SearchableModelSelect.vue b/frontend/src/components/ui/SearchableModelSelect.vue new file mode 100644 index 0000000..3196ece --- /dev/null +++ b/frontend/src/components/ui/SearchableModelSelect.vue @@ -0,0 +1,277 @@ + + + diff --git a/frontend/src/views/FileManagerView.vue b/frontend/src/views/FileManagerView.vue index c583965..2afe2b1 100644 --- a/frontend/src/views/FileManagerView.vue +++ b/frontend/src/views/FileManagerView.vue @@ -44,7 +44,7 @@