diff --git a/CHANGELOG.md b/CHANGELOG.md index eb07f0cc..42d1a86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to Turbo EA are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.22.0] - 2026-02-27 + +### Added +- Support for commercial LLM providers (OpenAI, Google Gemini, Azure OpenAI, OpenRouter, Anthropic Claude) for AI description suggestions +- Encrypted API key storage for commercial LLM providers +- Provider type selector in AI admin settings with conditional form fields + +### Changed +- Simplified AI search provider — DuckDuckGo is always used automatically for web context +- AI admin UI now shows provider-specific fields (URL, API key, model placeholders) based on selected provider type + ## [0.21.1] - 2026-02-27 ### Changed diff --git a/VERSION b/VERSION index a67cebaf..21574090 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.1 +0.22.0 diff --git a/backend/app/api/v1/ai_suggest.py b/backend/app/api/v1/ai_suggest.py index 1e7946d0..c2554d68 100644 --- a/backend/app/api/v1/ai_suggest.py +++ b/backend/app/api/v1/ai_suggest.py @@ -11,6 +11,7 @@ from app.api.deps import get_current_user from app.config import settings as app_config +from app.core.encryption import decrypt_value from app.database import get_db from app.models.app_settings import AppSettings from app.models.card_type import CardType @@ -27,14 +28,15 @@ def _get_ai_config(general: dict) -> dict: """Resolve AI configuration from DB settings with env-var fallback.""" ai = general.get("ai", {}) + encrypted_key = ai.get("apiKey", "") return { "enabled": ai.get("enabled", False), + "provider_type": ai.get("providerType", "ollama"), "provider_url": ai.get("providerUrl") or app_config.AI_PROVIDER_URL, + "api_key": decrypt_value(encrypted_key) if encrypted_key else "", "model": ai.get("model") or app_config.AI_MODEL, - "search_provider": ( - ai.get("searchProvider") or app_config.AI_SEARCH_PROVIDER or "duckduckgo" - ), - "search_url": ai.get("searchUrl") or app_config.AI_SEARCH_URL, + "search_provider": "duckduckgo", + "search_url": "", "enabled_types": ai.get("enabledTypes", []), } @@ -69,6 +71,13 @@ async def suggest( detail="AI provider URL and model must be configured in Settings.", ) + # Commercial providers require an API key + if ai_cfg["provider_type"] in ("openai", "anthropic") and not ai_cfg["api_key"]: + raise HTTPException( + status_code=400, + detail="API key is required for commercial LLM providers.", + ) + # Validate that the card type is enabled for AI suggestions if ai_cfg["enabled_types"] and body.type_key not in ai_cfg["enabled_types"]: raise HTTPException( @@ -90,9 +99,9 @@ async def suggest( subtype=body.subtype, provider_url=ai_cfg["provider_url"], model=ai_cfg["model"], - search_provider=ai_cfg["search_provider"], - search_url=ai_cfg["search_url"], context=body.context, + provider_type=ai_cfg["provider_type"], + api_key=ai_cfg["api_key"], ) except httpx.HTTPError as exc: logger.warning("AI suggestion failed for '%s': %s", body.name, exc) @@ -129,16 +138,18 @@ async def ai_status( enabled = ai_cfg["enabled"] and has_perm configured = bool(ai_cfg["provider_url"] and ai_cfg["model"]) + provider_type = ai_cfg["provider_type"] - # Try to fetch the currently loaded model from Ollama + # Only fetch running models for Ollama (commercial providers have no such endpoint) running_models: list[str] = [] - if enabled and configured and ai_cfg["provider_url"]: + if enabled and configured and provider_type == "ollama" and ai_cfg["provider_url"]: models = await fetch_running_models(ai_cfg["provider_url"]) running_models = [m["name"] for m in models] return { "enabled": enabled, "configured": configured, + "provider_type": provider_type, "enabled_types": ai_cfg["enabled_types"] if ai_cfg["enabled"] else [], "running_models": running_models, "model": ai_cfg["model"] if enabled else None, diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 648ad4a0..6c84ed77 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -568,9 +568,15 @@ async def update_registration_settings( # --------------------------------------------------------------------------- +_AI_KEY_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" +_VALID_PROVIDER_TYPES = {"ollama", "openai", "anthropic"} + + class AiSettingsPayload(BaseModel): enabled: bool = False + provider_type: str = "ollama" provider_url: str = "" + api_key: str = "" model: str = "" search_provider: str = "duckduckgo" search_url: str = "" @@ -588,9 +594,12 @@ async def get_ai_settings( await db.commit() general = row.general_settings or {} ai = general.get("ai", {}) + api_key_stored = ai.get("apiKey", "") return { "enabled": ai.get("enabled", False), + "provider_type": ai.get("providerType", "ollama"), "provider_url": ai.get("providerUrl", ""), + "api_key": _AI_KEY_MASK if api_key_stored else "", "model": ai.get("model", ""), "search_provider": ai.get("searchProvider", "duckduckgo"), "search_url": ai.get("searchUrl", ""), @@ -607,14 +616,40 @@ async def update_ai_settings( """Admin endpoint — update AI suggestion configuration.""" await PermissionService.require_permission(db, user, "admin.settings") + provider_type = body.provider_type + if provider_type not in _VALID_PROVIDER_TYPES: + raise HTTPException( + 400, + f"Invalid provider_type '{provider_type}'. " + f"Must be one of: {', '.join(sorted(_VALID_PROVIDER_TYPES))}", + ) + row = await _get_or_create_row(db) general = dict(row.general_settings or {}) + prev_ai = general.get("ai", {}) + + # Encrypt API key (preserve existing if masked or empty) + new_api_key = body.api_key + if new_api_key == _AI_KEY_MASK or (not new_api_key and prev_ai.get("apiKey")): + encrypted_key = prev_ai.get("apiKey", "") + elif new_api_key: + encrypted_key = encrypt_value(new_api_key) + else: + encrypted_key = "" + + # Default Anthropic URL if not provided + provider_url = body.provider_url + if provider_type == "anthropic" and not provider_url: + provider_url = "https://api.anthropic.com" + general["ai"] = { "enabled": body.enabled, - "providerUrl": body.provider_url, + "providerType": provider_type, + "providerUrl": provider_url, + "apiKey": encrypted_key, "model": body.model, - "searchProvider": body.search_provider, - "searchUrl": body.search_url, + "searchProvider": "duckduckgo", + "searchUrl": "", "enabledTypes": body.enabled_types, } row.general_settings = general @@ -631,33 +666,43 @@ async def test_ai_connection( """Admin endpoint — test connectivity to the AI provider.""" import httpx as _httpx + from app.services.ai_service import check_provider_connection + await PermissionService.require_permission(db, user, "admin.settings") row = await _get_or_create_row(db) await db.commit() general = row.general_settings or {} ai = general.get("ai", {}) + provider_type = ai.get("providerType", "ollama") provider_url = ai.get("providerUrl", "") model = ai.get("model", "") + encrypted_key = ai.get("apiKey", "") - if not provider_url: + if not provider_url and provider_type != "anthropic": raise HTTPException(400, "AI provider URL is not configured.") + # Use default Anthropic URL if not set + if provider_type == "anthropic" and not provider_url: + provider_url = "https://api.anthropic.com" + + # Decrypt API key for the test + api_key = decrypt_value(encrypted_key) if encrypted_key else "" + + if provider_type in ("openai", "anthropic") and not api_key: + raise HTTPException(400, "API key is required for commercial LLM providers.") + try: - async with _httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(f"{provider_url.rstrip('/')}/api/tags") - resp.raise_for_status() - data = resp.json() - available_models = [m.get("name", "") for m in data.get("models", [])] - model_found = any(model in m for m in available_models) if model else False + result = await check_provider_connection( + provider_url=provider_url, + provider_type=provider_type, + api_key=api_key, + model=model, + ) except _httpx.HTTPError as exc: - raise HTTPException(502, f"Cannot reach AI provider at {provider_url}: {exc}") from exc + raise HTTPException(502, str(exc)) from exc - return { - "ok": True, - "available_models": available_models[:20], - "model_found": model_found, - } + return result SUPPORTED_LOCALES = ["en", "de", "fr", "es", "it", "pt", "zh"] diff --git a/backend/app/main.py b/backend/app/main.py index a5606ae0..30ccd8f1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -125,10 +125,12 @@ async def _auto_configure_ai() -> None: general["ai"] = { "enabled": True, + "providerType": "ollama", "providerUrl": provider_url, + "apiKey": "", "model": model, - "searchProvider": settings.AI_SEARCH_PROVIDER or "duckduckgo", - "searchUrl": settings.AI_SEARCH_URL or "", + "searchProvider": "duckduckgo", + "searchUrl": "", "enabledTypes": ai.get("enabledTypes", []), } row.general_settings = general @@ -137,14 +139,37 @@ async def _auto_configure_ai() -> None: async def _ensure_ollama_model() -> None: - """Background task: pull the configured model if Ollama doesn't have it yet.""" + """Background task: pull the configured model if Ollama doesn't have it yet. + + Only runs when providerType is 'ollama' (or unset, for backward compat). + """ import httpx + from sqlalchemy import select as _sel2 + + from app.database import async_session as _async_session2 + from app.models import app_settings as _as_mod provider_url = settings.AI_PROVIDER_URL model = settings.AI_MODEL if not provider_url or not model: return + # Check if provider type is Ollama (skip model pull for commercial providers) + try: + async with _async_session2() as db: + result = await db.execute( + _sel2(_as_mod.AppSettings).where(_as_mod.AppSettings.id == "default") + ) + row = result.scalar_one_or_none() + if row: + ai = (row.general_settings or {}).get("ai", {}) + pt = ai.get("providerType", "ollama") + if pt != "ollama": + logger.info("[ai] Provider type is '%s' — skipping Ollama model pull", pt) + return + except Exception: + pass # Proceed with pull attempt if DB check fails + tags_url = f"{provider_url.rstrip('/')}/api/tags" pull_url = f"{provider_url.rstrip('/')}/api/pull" diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 6da4ee16..3ccd7af2 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -1,8 +1,13 @@ -"""AI service — web search + local LLM description generation for cards. +"""AI service — web search + LLM description generation for cards. Two-step pipeline: - 1. Web search (DuckDuckGo HTML scrape or SearXNG) for the card name + 1. Web search (DuckDuckGo HTML scrape) for the card name 2. LLM prompt with search snippets → card description (type-aware) + +Supports multiple LLM providers: + - Ollama (self-hosted, /api/chat) + - OpenAI-compatible (OpenAI, Gemini, Azure, OpenRouter, LM Studio, vLLM) + - Anthropic Claude (/v1/messages) """ from __future__ import annotations @@ -67,7 +72,7 @@ async def fetch_running_models(provider_url: str) -> list[dict[str, Any]]: {"name": m.get("name", ""), "size": m.get("size", 0)} for m in data.get("models", []) ] except Exception as exc: - logger.debug("Could not fetch running models from %s: %s", provider_url, exc) + logger.debug("Could not fetch running models: %s", exc) return [] @@ -343,16 +348,30 @@ def build_llm_prompt( # --------------------------------------------------------------------------- -# LLM call +# LLM call — multi-provider dispatch # --------------------------------------------------------------------------- -async def call_llm( +def _parse_llm_content(content: str) -> dict[str, Any]: + """Parse JSON from LLM response content, with markdown code-block fallback.""" + try: + result: dict[str, Any] = json.loads(content) + return result + except json.JSONDecodeError: + json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", content) + if json_match: + result = json.loads(json_match.group(1)) + return result + logger.warning("LLM returned non-JSON content: %.200s", content) + return {} + + +async def _call_ollama( provider_url: str, model: str, messages: list[dict[str, str]], ) -> dict[str, Any]: - """Call the LLM API (Ollama-compatible /api/chat endpoint).""" + """Call an Ollama-compatible /api/chat endpoint.""" client = await _get_llm_client() url = f"{provider_url.rstrip('/')}/api/chat" @@ -368,23 +387,181 @@ async def call_llm( resp = await client.post(url, json=payload) resp.raise_for_status() except httpx.HTTPError as exc: - logger.warning("LLM API call failed: %s", exc) + logger.warning("Ollama API call failed: %s", exc) raise data = resp.json() content = data.get("message", {}).get("content", "{}") + return _parse_llm_content(content) + + +async def _call_openai_compatible( + provider_url: str, + api_key: str, + model: str, + messages: list[dict[str, str]], +) -> dict[str, Any]: + """Call an OpenAI-compatible /v1/chat/completions endpoint. + + Works with OpenAI, Google Gemini, Azure OpenAI, OpenRouter, LM Studio, vLLM. + """ + client = await _get_llm_client() + url = f"{provider_url.rstrip('/')}/v1/chat/completions" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload: dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": 0.1, + "response_format": {"type": "json_object"}, + } try: - parsed: dict[str, Any] = json.loads(content) - return parsed - except json.JSONDecodeError: - # Try to extract JSON from markdown code block - json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", content) - if json_match: - parsed = json.loads(json_match.group(1)) - return parsed - logger.warning("LLM returned non-JSON content: %.200s", content) - return {} + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + except httpx.HTTPError as exc: + logger.warning("OpenAI-compatible API call failed: %s", type(exc).__name__) + raise + + data = resp.json() + content = data.get("choices", [{}])[0].get("message", {}).get("content", "{}") + return _parse_llm_content(content) + + +async def _call_anthropic( + provider_url: str, + api_key: str, + model: str, + messages: list[dict[str, str]], +) -> dict[str, Any]: + """Call the Anthropic /v1/messages endpoint. + + Anthropic requires system messages as a top-level parameter, not in the messages array. + """ + client = await _get_llm_client() + url = f"{provider_url.rstrip('/')}/v1/messages" + + # Separate system message from user/assistant messages + system_text = "" + chat_messages: list[dict[str, str]] = [] + for msg in messages: + if msg["role"] == "system": + system_text = msg["content"] + else: + chat_messages.append(msg) + + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + payload: dict[str, Any] = { + "model": model, + "max_tokens": 1024, + "messages": chat_messages, + "temperature": 0.1, + } + if system_text: + payload["system"] = system_text + + try: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + except httpx.HTTPError as exc: + logger.warning("Anthropic API call failed: %s", type(exc).__name__) + raise + + data = resp.json() + content_blocks = data.get("content", []) + text = content_blocks[0].get("text", "{}") if content_blocks else "{}" + return _parse_llm_content(text) + + +async def call_llm( + provider_url: str, + model: str, + messages: list[dict[str, str]], + *, + provider_type: str = "ollama", + api_key: str = "", +) -> dict[str, Any]: + """Dispatch an LLM call to the configured provider. + + Supported provider_type values: "ollama", "openai", "anthropic". + """ + if provider_type == "openai": + return await _call_openai_compatible(provider_url, api_key, model, messages) + if provider_type == "anthropic": + return await _call_anthropic(provider_url, api_key, model, messages) + return await _call_ollama(provider_url, model, messages) + + +# --------------------------------------------------------------------------- +# Provider connection test +# --------------------------------------------------------------------------- + + +async def check_provider_connection( + provider_url: str, + provider_type: str = "ollama", + api_key: str = "", + model: str = "", +) -> dict[str, Any]: + """Test connectivity to an LLM provider. Returns available models and status.""" + client = await _get_llm_client() + + if provider_type == "openai": + url = f"{provider_url.rstrip('/')}/v1/models" + headers = {"Authorization": f"Bearer {api_key}"} + try: + resp = await client.get(url, headers=headers, timeout=10.0) + resp.raise_for_status() + data = resp.json() + available = [m.get("id", "") for m in data.get("data", [])] + model_found = any(model in m for m in available) if model else False + return {"ok": True, "available_models": available[:20], "model_found": model_found} + except httpx.HTTPError as exc: + raise httpx.HTTPError(f"Cannot reach provider: {type(exc).__name__}") from exc + + if provider_type == "anthropic": + # Anthropic has no model-list endpoint; make a minimal test call + url = f"{provider_url.rstrip('/')}/v1/messages" + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + payload = { + "model": model or "claude-haiku-4-5-20251001", + "max_tokens": 1, + "messages": [{"role": "user", "content": "Hi"}], + } + try: + resp = await client.post(url, json=payload, headers=headers, timeout=10.0) + resp.raise_for_status() + return {"ok": True, "available_models": [], "model_found": True} + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 401: + raise httpx.HTTPError("Invalid API key") from exc + # Other errors (e.g. invalid model) still mean connectivity works + raise httpx.HTTPError(f"Cannot reach Anthropic: {type(exc).__name__}") from exc + except httpx.HTTPError as exc: + raise httpx.HTTPError(f"Cannot reach Anthropic: {type(exc).__name__}") from exc + + # Default: Ollama + url = f"{provider_url.rstrip('/')}/api/tags" + try: + resp = await client.get(url, timeout=10.0) + resp.raise_for_status() + data = resp.json() + available = [m.get("name", "") for m in data.get("models", [])] + model_found = any(model in m for m in available) if model else False + return {"ok": True, "available_models": available[:20], "model_found": model_found} + except httpx.HTTPError as exc: + raise httpx.HTTPError(f"Cannot reach Ollama: {type(exc).__name__}") from exc # --------------------------------------------------------------------------- @@ -440,15 +617,18 @@ async def suggest_metadata( search_provider: str = "duckduckgo", search_url: str = "", context: str | None = None, + *, + provider_type: str = "ollama", + api_key: str = "", ) -> dict[str, Any]: """Full pipeline: web search → LLM description → validated suggestion.""" - # Step 1: Web search (type-aware query) + # Step 1: Web search (always DuckDuckGo — commercial APIs have no web access) search_suffix = _get_search_suffix(type_key, subtype) query = f"{name} {search_suffix}" if subtype: query += f" {subtype}" - logger.info("[ai] Searching for '%s' via %s", query, search_provider) - search_results = await web_search(query, search_provider, search_url) + logger.info("[ai] Searching for '%s' via duckduckgo", query) + search_results = await web_search(query, "duckduckgo") logger.info("[ai] Search returned %d results", len(search_results)) # Step 2: Build prompt and call LLM @@ -460,8 +640,10 @@ async def suggest_metadata( search_results=search_results, context=context, ) - logger.info("[ai] Calling LLM %s at %s", model, provider_url) - raw_response = await call_llm(provider_url, model, messages) + logger.info("[ai] Calling LLM") + raw_response = await call_llm( + provider_url, model, messages, provider_type=provider_type, api_key=api_key + ) logger.info("[ai] LLM returned %d raw keys", len(raw_response)) # Step 3: Validate (description only) @@ -477,5 +659,5 @@ async def suggest_metadata( "suggestions": suggestions, "sources": sources, "model": model, - "search_provider": search_provider, + "search_provider": "duckduckgo", } diff --git a/backend/tests/services/test_ai_service.py b/backend/tests/services/test_ai_service.py index b1c5f171..65a98611 100644 --- a/backend/tests/services/test_ai_service.py +++ b/backend/tests/services/test_ai_service.py @@ -11,13 +11,17 @@ import pytest from app.services.ai_service import ( + _call_anthropic, + _call_openai_compatible, _get_llm_item_description, _get_search_suffix, + _parse_llm_content, _search_duckduckgo, _search_google, _search_searxng, build_llm_prompt, call_llm, + check_provider_connection, fetch_running_models, suggest_metadata, validate_suggestions, @@ -695,3 +699,411 @@ async def test_search_query_type_aware_organization(self): query = mock_search.call_args[0][0] assert "Acme Corp" in query assert "company" in query or "organization" in query + + +# --------------------------------------------------------------------------- +# _parse_llm_content +# --------------------------------------------------------------------------- + + +class TestParseLlmContent: + def test_valid_json(self): + result = _parse_llm_content('{"description": "hello"}') + assert result == {"description": "hello"} + + def test_markdown_code_block(self): + result = _parse_llm_content('```json\n{"description": "hello"}\n```') + assert result == {"description": "hello"} + + def test_non_json_returns_empty(self): + result = _parse_llm_content("This is not JSON") + assert result == {} + + +# --------------------------------------------------------------------------- +# _call_openai_compatible +# --------------------------------------------------------------------------- + + +class TestCallOpenAICompatible: + @pytest.mark.asyncio + async def test_successful_response(self): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "choices": [{"message": {"content": '{"description": {"value": "A tool"}}'}}] + } + mock_resp.raise_for_status = MagicMock() + + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.post = AsyncMock(return_value=mock_resp) + + result = await _call_openai_compatible( + "https://api.openai.com", + "sk-test", + "gpt-4o-mini", + [{"role": "user", "content": "test"}], + ) + assert result == {"description": {"value": "A tool"}} + + @pytest.mark.asyncio + async def test_sends_bearer_auth(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {"choices": [{"message": {"content": "{}"}}]} + mock_resp.raise_for_status = MagicMock() + + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.post = AsyncMock(return_value=mock_resp) + + await _call_openai_compatible( + "https://api.openai.com", + "sk-test-key", + "gpt-4o", + [{"role": "user", "content": "test"}], + ) + + call_args = mock_client.post.call_args + assert call_args[0][0] == "https://api.openai.com/v1/chat/completions" + headers = call_args[1]["headers"] + assert headers["Authorization"] == "Bearer sk-test-key" + + @pytest.mark.asyncio + async def test_sends_response_format(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {"choices": [{"message": {"content": "{}"}}]} + mock_resp.raise_for_status = MagicMock() + + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.post = AsyncMock(return_value=mock_resp) + + await _call_openai_compatible( + "https://api.openai.com", + "sk-test", + "gpt-4o", + [{"role": "user", "content": "test"}], + ) + + payload = mock_client.post.call_args[1]["json"] + assert payload["response_format"] == {"type": "json_object"} + assert payload["temperature"] == 0.1 + + @pytest.mark.asyncio + async def test_http_error_raises(self): + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.post = AsyncMock(side_effect=httpx.HTTPError("Unauthorized")) + + with pytest.raises(httpx.HTTPError): + await _call_openai_compatible( + "https://api.openai.com", + "sk-bad", + "gpt-4o", + [{"role": "user", "content": "test"}], + ) + + +# --------------------------------------------------------------------------- +# _call_anthropic +# --------------------------------------------------------------------------- + + +class TestCallAnthropic: + @pytest.mark.asyncio + async def test_successful_response(self): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "content": [{"type": "text", "text": '{"description": {"value": "A tool"}}'}] + } + mock_resp.raise_for_status = MagicMock() + + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.post = AsyncMock(return_value=mock_resp) + + result = await _call_anthropic( + "https://api.anthropic.com", + "sk-ant-test", + "claude-sonnet-4-20250514", + [{"role": "system", "content": "sys"}, {"role": "user", "content": "test"}], + ) + assert result == {"description": {"value": "A tool"}} + + @pytest.mark.asyncio + async def test_separates_system_message(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {"content": [{"type": "text", "text": "{}"}]} + mock_resp.raise_for_status = MagicMock() + + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.post = AsyncMock(return_value=mock_resp) + + await _call_anthropic( + "https://api.anthropic.com", + "sk-ant-test", + "claude-sonnet-4-20250514", + [ + {"role": "system", "content": "You are helpful"}, + {"role": "user", "content": "Hi"}, + ], + ) + + payload = mock_client.post.call_args[1]["json"] + assert payload["system"] == "You are helpful" + assert len(payload["messages"]) == 1 + assert payload["messages"][0]["role"] == "user" + + @pytest.mark.asyncio + async def test_sends_correct_headers(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {"content": [{"type": "text", "text": "{}"}]} + mock_resp.raise_for_status = MagicMock() + + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.post = AsyncMock(return_value=mock_resp) + + await _call_anthropic( + "https://api.anthropic.com", + "sk-ant-key", + "claude-sonnet-4-20250514", + [{"role": "user", "content": "test"}], + ) + + call_args = mock_client.post.call_args + assert call_args[0][0] == "https://api.anthropic.com/v1/messages" + headers = call_args[1]["headers"] + assert headers["x-api-key"] == "sk-ant-key" + assert headers["anthropic-version"] == "2023-06-01" + + @pytest.mark.asyncio + async def test_http_error_raises(self): + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.post = AsyncMock(side_effect=httpx.HTTPError("Unauthorized")) + + with pytest.raises(httpx.HTTPError): + await _call_anthropic( + "https://api.anthropic.com", + "bad-key", + "claude-sonnet-4-20250514", + [{"role": "user", "content": "test"}], + ) + + +# --------------------------------------------------------------------------- +# call_llm dispatcher +# --------------------------------------------------------------------------- + + +class TestCallLLMDispatcher: + @pytest.mark.asyncio + async def test_defaults_to_ollama(self): + with patch("app.services.ai_service._call_ollama") as mock_ollama: + mock_ollama.return_value = {"description": "test"} + result = await call_llm( + "http://ollama:11434", + "mistral", + [{"role": "user", "content": "test"}], + ) + mock_ollama.assert_called_once() + assert result == {"description": "test"} + + @pytest.mark.asyncio + async def test_routes_to_openai(self): + with patch("app.services.ai_service._call_openai_compatible") as mock_openai: + mock_openai.return_value = {"description": "test"} + result = await call_llm( + "https://api.openai.com", + "gpt-4o", + [{"role": "user", "content": "test"}], + provider_type="openai", + api_key="sk-test", + ) + mock_openai.assert_called_once_with( + "https://api.openai.com", + "sk-test", + "gpt-4o", + [{"role": "user", "content": "test"}], + ) + assert result == {"description": "test"} + + @pytest.mark.asyncio + async def test_routes_to_anthropic(self): + with patch("app.services.ai_service._call_anthropic") as mock_anthropic: + mock_anthropic.return_value = {"description": "test"} + result = await call_llm( + "https://api.anthropic.com", + "claude-sonnet-4-20250514", + [{"role": "user", "content": "test"}], + provider_type="anthropic", + api_key="sk-ant-test", + ) + mock_anthropic.assert_called_once_with( + "https://api.anthropic.com", + "sk-ant-test", + "claude-sonnet-4-20250514", + [{"role": "user", "content": "test"}], + ) + assert result == {"description": "test"} + + +# --------------------------------------------------------------------------- +# suggest_metadata — always searches with DuckDuckGo +# --------------------------------------------------------------------------- + + +class TestSuggestMetadataAlwaysSearches: + @pytest.mark.asyncio + async def test_uses_duckduckgo_for_openai_provider(self): + with ( + patch("app.services.ai_service.web_search") as mock_search, + patch("app.services.ai_service.call_llm") as mock_llm, + ): + mock_search.return_value = [ + {"url": "https://example.com", "title": "Example", "snippet": "Info"}, + ] + mock_llm.return_value = { + "description": {"value": "A product", "confidence": 0.8}, + } + + result = await suggest_metadata( + name="Test App", + type_key="Application", + type_label="Application", + subtype=None, + provider_url="https://api.openai.com", + model="gpt-4o-mini", + provider_type="openai", + api_key="sk-test", + ) + + # Verify DuckDuckGo search was called + mock_search.assert_called_once() + search_call = mock_search.call_args + assert search_call[0][1] == "duckduckgo" + + # Verify LLM was called with provider_type and api_key + llm_call = mock_llm.call_args + assert llm_call[1]["provider_type"] == "openai" + assert llm_call[1]["api_key"] == "sk-test" + + assert result["search_provider"] == "duckduckgo" + + @pytest.mark.asyncio + async def test_uses_duckduckgo_for_anthropic_provider(self): + with ( + patch("app.services.ai_service.web_search") as mock_search, + patch("app.services.ai_service.call_llm") as mock_llm, + ): + mock_search.return_value = [] + mock_llm.return_value = { + "description": {"value": "A product", "confidence": 0.6}, + } + + await suggest_metadata( + name="Test App", + type_key="Application", + type_label="Application", + subtype=None, + provider_url="https://api.anthropic.com", + model="claude-sonnet-4-20250514", + provider_type="anthropic", + api_key="sk-ant-test", + ) + + mock_search.assert_called_once() + assert mock_search.call_args[0][1] == "duckduckgo" + + +# --------------------------------------------------------------------------- +# check_provider_connection +# --------------------------------------------------------------------------- + + +class TestProviderConnection: + @pytest.mark.asyncio + async def test_ollama_uses_api_tags(self): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "models": [{"name": "gemma3:4b"}, {"name": "mistral:latest"}] + } + mock_resp.raise_for_status = MagicMock() + + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.get = AsyncMock(return_value=mock_resp) + + result = await check_provider_connection( + "http://ollama:11434", + provider_type="ollama", + model="gemma3:4b", + ) + assert result["ok"] is True + assert result["model_found"] is True + assert "gemma3:4b" in result["available_models"] + + # Verify correct endpoint + call_url = mock_client.get.call_args[0][0] + assert "/api/tags" in call_url + + @pytest.mark.asyncio + async def test_openai_uses_v1_models(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}]} + mock_resp.raise_for_status = MagicMock() + + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.get = AsyncMock(return_value=mock_resp) + + result = await check_provider_connection( + "https://api.openai.com", + provider_type="openai", + api_key="sk-test", + model="gpt-4o-mini", + ) + assert result["ok"] is True + assert result["model_found"] is True + + # Verify Bearer auth header + call_args = mock_client.get.call_args + assert "/v1/models" in call_args[0][0] + assert call_args[1]["headers"]["Authorization"] == "Bearer sk-test" + + @pytest.mark.asyncio + async def test_anthropic_makes_test_call(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {"content": [{"type": "text", "text": "Hi"}]} + mock_resp.raise_for_status = MagicMock() + + with patch("app.services.ai_service._get_llm_client") as mock_get: + mock_client = AsyncMock() + mock_get.return_value = mock_client + mock_client.post = AsyncMock(return_value=mock_resp) + + result = await check_provider_connection( + "https://api.anthropic.com", + provider_type="anthropic", + api_key="sk-ant-test", + model="claude-sonnet-4-20250514", + ) + assert result["ok"] is True + + # Verify correct headers + call_args = mock_client.post.call_args + assert "/v1/messages" in call_args[0][0] + headers = call_args[1]["headers"] + assert headers["x-api-key"] == "sk-ant-test" diff --git a/frontend/src/features/admin/AiAdmin.tsx b/frontend/src/features/admin/AiAdmin.tsx index f34adf7e..97706750 100644 --- a/frontend/src/features/admin/AiAdmin.tsx +++ b/frontend/src/features/admin/AiAdmin.tsx @@ -18,13 +18,17 @@ import { useMetamodel } from "@/hooks/useMetamodel"; interface AiSettings { enabled: boolean; + provider_type: string; provider_url: string; + api_key: string; model: string; search_provider: string; search_url: string; enabled_types: string[]; } +const AI_KEY_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"; + export default function AiAdmin() { const { t } = useTranslation(["admin", "common"]); const [loading, setLoading] = useState(true); @@ -33,10 +37,10 @@ export default function AiAdmin() { // AI settings state const [aiEnabled, setAiEnabled] = useState(false); + const [aiProviderType, setAiProviderType] = useState("ollama"); const [aiProviderUrl, setAiProviderUrl] = useState(""); + const [aiApiKey, setAiApiKey] = useState(""); const [aiModel, setAiModel] = useState(""); - const [aiSearchProvider, setAiSearchProvider] = useState("duckduckgo"); - const [aiSearchUrl, setAiSearchUrl] = useState(""); const [aiEnabledTypes, setAiEnabledTypes] = useState([]); const [savingAi, setSavingAi] = useState(false); const [testingAi, setTestingAi] = useState(false); @@ -50,10 +54,10 @@ export default function AiAdmin() { .get("/settings/ai") .then((data) => { setAiEnabled(data.enabled); + setAiProviderType(data.provider_type || "ollama"); setAiProviderUrl(data.provider_url); + setAiApiKey(data.api_key || ""); setAiModel(data.model); - setAiSearchProvider(data.search_provider); - setAiSearchUrl(data.search_url); setAiEnabledTypes(data.enabled_types); }) .catch((e) => setError(e instanceof Error ? e.message : t("common:errors.generic"))) @@ -66,10 +70,12 @@ export default function AiAdmin() { try { await api.patch("/settings/ai", { enabled: aiEnabled, + provider_type: aiProviderType, provider_url: aiProviderUrl, + api_key: aiApiKey, model: aiModel, - search_provider: aiSearchProvider, - search_url: aiSearchUrl, + search_provider: "duckduckgo", + search_url: "", enabled_types: aiEnabledTypes, }); setSnack(t("settings.ai.savedSuccess")); @@ -95,7 +101,11 @@ export default function AiAdmin() { t("settings.ai.testNoModel", { models: res.available_models.slice(0, 5).join(", ") }), ); } else { - setSnack(t("settings.ai.testNoModels")); + if (aiProviderType === "ollama") { + setSnack(t("settings.ai.testNoModels")); + } else { + setSnack(t("settings.ai.testSuccess")); + } } } catch (e) { setError(e instanceof Error ? e.message : t("common:errors.generic")); @@ -112,6 +122,36 @@ export default function AiAdmin() { } }; + const handleProviderTypeChange = (newType: string) => { + setAiProviderType(newType); + setAiAvailableModels([]); + // Reset fields when switching providers + if (newType === "anthropic") { + setAiProviderUrl(""); + } + }; + + const showProviderUrl = aiProviderType !== "anthropic"; + const showApiKey = aiProviderType !== "ollama"; + const hasApiKeySet = aiApiKey === AI_KEY_MASK; + + const providerUrlPlaceholder = + aiProviderType === "openai" ? "https://api.openai.com" : "http://localhost:11434"; + + const modelPlaceholder = + aiProviderType === "openai" + ? "gpt-4o-mini" + : aiProviderType === "anthropic" + ? "claude-sonnet-4-20250514" + : "gemma3:4b"; + + const modelHelper = + aiProviderType === "openai" + ? t("settings.ai.modelHelperOpenai") + : aiProviderType === "anthropic" + ? t("settings.ai.modelHelperAnthropic") + : t("settings.ai.modelHelper"); + if (loading) { return ( @@ -166,16 +206,64 @@ export default function AiAdmin() { {aiEnabled && ( <> + {/* Provider Type */} setAiProviderUrl(e.target.value)} - placeholder="http://localhost:11434" - helperText={t("settings.ai.providerUrlHelper")} - sx={{ mb: 2 }} - /> - {aiAvailableModels.length > 0 ? ( + value={aiProviderType} + onChange={(e) => handleProviderTypeChange(e.target.value)} + sx={{ mb: 1 }} + > + {t("settings.ai.providerOllama")} + {t("settings.ai.providerOpenai")} + {t("settings.ai.providerAnthropic")} + + + {aiProviderType === "ollama" + ? t("settings.ai.providerOllamaDesc") + : aiProviderType === "openai" + ? t("settings.ai.providerOpenaiDesc") + : t("settings.ai.providerAnthropicDesc")} + + + {/* Provider URL (hidden for Anthropic) */} + {showProviderUrl && ( + setAiProviderUrl(e.target.value)} + placeholder={providerUrlPlaceholder} + helperText={ + aiProviderType === "openai" + ? t("settings.ai.providerUrlHelperOpenai") + : t("settings.ai.providerUrlHelper") + } + sx={{ mb: 2 }} + /> + )} + + {/* API Key (hidden for Ollama) */} + {showApiKey && ( + setAiApiKey(e.target.value)} + placeholder={hasApiKeySet ? "" : "sk-..."} + helperText={ + hasApiKeySet + ? t("settings.ai.apiKeySet") + : t("settings.ai.apiKeyHelper") + } + sx={{ mb: 2 }} + /> + )} + + {/* Model */} + {aiProviderType === "ollama" && aiAvailableModels.length > 0 ? ( ))} - ) : ( + ) : aiProviderType === "openai" && aiAvailableModels.length > 0 ? ( setAiModel(e.target.value)} - placeholder="gemma3:4b" - helperText={t("settings.ai.modelHelper")} + helperText={modelHelper} sx={{ mb: 2 }} - /> - )} - setAiSearchProvider(e.target.value)} - helperText={t("settings.ai.searchProviderHelper")} - sx={{ mb: 2 }} - > - DuckDuckGo - Google Custom Search - SearXNG - - {(aiSearchProvider === "searxng" || aiSearchProvider === "google") && ( + > + {!aiModel && ( + + {t("settings.ai.selectModel")} + + )} + {aiAvailableModels.map((m) => ( + + {m} + + ))} + + ) : ( setAiSearchUrl(e.target.value)} - placeholder={ - aiSearchProvider === "google" - ? "API_KEY:SEARCH_ENGINE_ID" - : "http://localhost:8888" - } - helperText={ - aiSearchProvider === "google" - ? t("settings.ai.googleCredentialsHelper") - : t("settings.ai.searxngUrlHelper") - } + value={aiModel} + onChange={(e) => setAiModel(e.target.value)} + placeholder={modelPlaceholder} + helperText={modelHelper} sx={{ mb: 2 }} /> )} + + {/* Search Info */} + + {t("settings.ai.searchInfo")} + + + {/* Enabled Types */} {t("settings.ai.enabledTypes")} @@ -269,7 +349,7 @@ export default function AiAdmin() { )} - {aiEnabled && aiProviderUrl && ( + {aiEnabled && (aiProviderUrl || aiProviderType === "anthropic") && (