Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.21.1
0.22.0
27 changes: 19 additions & 8 deletions backend/app/api/v1/ai_suggest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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", []),
}

Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
77 changes: 61 additions & 16 deletions backend/app/api/v1/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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", ""),
Expand All @@ -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
Expand All @@ -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"]
Expand Down
31 changes: 28 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down
Loading
Loading