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>
281 lines
9.7 KiB
Python
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}"
|