ppt-tool/backend/api/v1/admin/settings_router.py
Vadym Samoilenko 69a8829750 Phase 3: Bug fixes, feature enhancements, and polish
P0 Critical: presentation isolation (client scoping), storage super_admin fix,
template selection in worker, IMAGE_PROVIDERS list fix.

P1 High: template layout management UI (delete/filter/bulk), slide-based parsing
mode, LLM model listing & connection test, settings persistence to DB (Fernet
encryption), logout button.

P2 Polish: storage improvements (master deck files, per-client breakdown, bulk
delete, hard purge, client selector), image generation error visibility
(__image_error__ badge), hamster wheel loading animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:58:52 +00:00

281 lines
9.7 KiB
Python

"""Admin router for system settings — LLM and image provider configuration."""
import asyncio
import os
import time
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from models.sql.user import UserModel
from services.database import get_async_session
from utils.auth_dependencies import require_client_admin
SETTINGS_ROUTER = APIRouter(tags=["Admin - Settings"])
class SystemSettings(BaseModel):
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
image_provider: Optional[str] = None
anthropic_api_key_set: bool = False
openai_api_key_set: bool = False
google_api_key_set: bool = False
class SettingsUpdate(BaseModel):
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
image_provider: Optional[str] = None
anthropic_api_key: Optional[str] = None
openai_api_key: Optional[str] = None
google_api_key: Optional[str] = None
LLM_PROVIDERS = ["anthropic", "openai", "google", "ollama", "custom"]
IMAGE_PROVIDERS = ["nanobanana_pro", "gemini_flash", "dall-e-3", "gpt-image-1.5", "pexels", "pixabay", "comfyui"]
@SETTINGS_ROUTER.get("/settings")
async def get_settings(
admin: UserModel = Depends(require_client_admin),
_session: AsyncSession = Depends(get_async_session),
):
"""Return current system settings (env-based config)."""
if admin.role != "super_admin":
raise HTTPException(status_code=403, detail="Super admin only")
return {
"llm_provider": os.getenv("LLM", "anthropic"),
"llm_model": _get_current_model(),
"image_provider": os.getenv("IMAGE_PROVIDER", "google"),
"anthropic_api_key_set": bool(os.getenv("ANTHROPIC_API_KEY")),
"openai_api_key_set": bool(os.getenv("OPENAI_API_KEY")),
"google_api_key_set": bool(os.getenv("GOOGLE_API_KEY")),
"available_llm_providers": LLM_PROVIDERS,
"available_image_providers": IMAGE_PROVIDERS,
}
@SETTINGS_ROUTER.put("/settings")
async def update_settings(
body: SettingsUpdate,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""Update system settings. Changes apply immediately and persist to database."""
if admin.role != "super_admin":
raise HTTPException(status_code=403, detail="Super admin only")
# Validate providers
if body.llm_provider is not None and body.llm_provider not in LLM_PROVIDERS:
raise HTTPException(status_code=400, detail=f"Invalid LLM provider: {body.llm_provider}")
if body.image_provider is not None and body.image_provider not in IMAGE_PROVIDERS:
raise HTTPException(status_code=400, detail=f"Invalid image provider: {body.image_provider}")
from services.settings_service import save_settings
data = body.model_dump(exclude_none=True)
changed = await save_settings(session, data)
return {"ok": True, "changed": changed}
@SETTINGS_ROUTER.get("/settings/models")
async def list_models(
provider: str = Query(..., description="Provider name"),
admin: UserModel = Depends(require_client_admin),
_session: AsyncSession = Depends(get_async_session),
):
"""List available models for a given provider."""
if admin.role != "super_admin":
raise HTTPException(status_code=403, detail="Super admin only")
try:
models = await _fetch_models_for_provider(provider)
return {"provider": provider, "models": models}
except Exception as e:
return {"provider": provider, "models": [], "error": str(e)}
class ConnectionTestRequest(BaseModel):
provider: str
api_key: Optional[str] = None
@SETTINGS_ROUTER.post("/settings/test-connection")
async def test_connection(
body: ConnectionTestRequest,
admin: UserModel = Depends(require_client_admin),
_session: AsyncSession = Depends(get_async_session),
):
"""Test connectivity to an LLM/API provider."""
if admin.role != "super_admin":
raise HTTPException(status_code=403, detail="Super admin only")
start = time.time()
try:
ok, error = await _test_provider_connection(body.provider, body.api_key)
latency_ms = int((time.time() - start) * 1000)
return {"ok": ok, "error": error, "latency_ms": latency_ms}
except Exception as e:
latency_ms = int((time.time() - start) * 1000)
return {"ok": False, "error": str(e), "latency_ms": latency_ms}
def _get_current_model() -> str:
provider = os.getenv("LLM", "anthropic")
model_env_map = {
"anthropic": "ANTHROPIC_MODEL",
"openai": "OPENAI_MODEL",
"google": "GOOGLE_MODEL",
"ollama": "OLLAMA_MODEL",
"custom": "CUSTOM_MODEL",
}
from constants.llm import DEFAULT_ANTHROPIC_MODEL, DEFAULT_OPENAI_MODEL, DEFAULT_GOOGLE_MODEL
defaults = {
"anthropic": DEFAULT_ANTHROPIC_MODEL,
"openai": DEFAULT_OPENAI_MODEL,
"google": DEFAULT_GOOGLE_MODEL,
}
env_key = model_env_map.get(provider, "ANTHROPIC_MODEL")
return os.getenv(env_key, defaults.get(provider, ""))
# --- Known model lists (for providers without a list API) ---
ANTHROPIC_MODELS = [
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-sonnet-4-5-20250929",
"claude-haiku-4-5-20251001",
"claude-sonnet-4-5-20250514",
"claude-3-5-haiku-20241022",
]
GOOGLE_MODELS = [
"models/gemini-2.5-flash",
"models/gemini-2.5-pro",
"models/gemini-2.0-flash",
"models/gemini-2.0-flash-lite",
"models/gemini-1.5-flash",
"models/gemini-1.5-pro",
]
async def _fetch_models_for_provider(provider: str) -> List[str]:
"""Return a list of model IDs for the given provider."""
if provider == "anthropic":
return ANTHROPIC_MODELS
if provider == "google":
# Try listing via API, fallback to hardcoded
key = os.getenv("GOOGLE_API_KEY")
if key:
try:
def _list_google():
import google.genai as genai
client = genai.Client(api_key=key)
result = []
for m in client.models.list():
name = getattr(m, "name", str(m))
if "gemini" in name.lower():
result.append(name)
return sorted(result)
return await asyncio.to_thread(_list_google)
except Exception:
pass
return GOOGLE_MODELS
if provider == "openai":
key = os.getenv("OPENAI_API_KEY")
if key:
try:
def _list_openai():
from openai import OpenAI
client = OpenAI(api_key=key)
models = client.models.list()
return sorted([m.id for m in models.data if "gpt" in m.id or "o1" in m.id or "o3" in m.id or "o4" in m.id])
return await asyncio.to_thread(_list_openai)
except Exception:
pass
return ["gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", "o3", "o4-mini"]
if provider == "ollama":
try:
import httpx
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(os.getenv("OLLAMA_URL", "http://localhost:11434") + "/api/tags")
if resp.status_code == 200:
data = resp.json()
return [m["name"] for m in data.get("models", [])]
except Exception:
pass
return []
return []
async def _test_provider_connection(provider: str, api_key: Optional[str] = None) -> tuple:
"""Test provider connection. Returns (ok: bool, error: str | None)."""
if provider == "anthropic":
key = api_key or os.getenv("ANTHROPIC_API_KEY")
if not key:
return False, "No API key configured"
try:
def _test():
import anthropic
client = anthropic.Anthropic(api_key=key)
client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=5,
messages=[{"role": "user", "content": "hi"}],
)
return True
await asyncio.to_thread(_test)
return True, None
except Exception as e:
return False, str(e)
if provider == "openai":
key = api_key or os.getenv("OPENAI_API_KEY")
if not key:
return False, "No API key configured"
try:
def _test():
from openai import OpenAI
client = OpenAI(api_key=key)
client.models.list()
return True
await asyncio.to_thread(_test)
return True, None
except Exception as e:
return False, str(e)
if provider == "google":
key = api_key or os.getenv("GOOGLE_API_KEY")
if not key:
return False, "No API key configured"
try:
def _test():
import google.genai as genai
client = genai.Client(api_key=key)
list(client.models.list())[:1]
return True
await asyncio.to_thread(_test)
return True, None
except Exception as e:
return False, str(e)
if provider == "ollama":
try:
import httpx
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(os.getenv("OLLAMA_URL", "http://localhost:11434") + "/api/tags")
if resp.status_code == 200:
return True, None
return False, f"HTTP {resp.status_code}"
except Exception as e:
return False, str(e)
return False, f"Unknown provider: {provider}"