""" Admin-only settings API for per-service runtime configuration. All endpoints require the caller to be an admin (Depends(get_current_admin)). Config files are stored in the 'config' bucket of storage-service. """ import re as _re import httpx from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from app.core.app_config import ( SYSTEM_PROMPT_SERVICES, AppearanceConfig, _merge_api_key, delete_theme, load_ai_service_config, load_ai_service_config_masked, load_all_system_prompts, load_all_themes, load_appearance_config, load_doc_service_config, load_doc_service_config_masked, load_theme_by_id, save_ai_service_config, save_appearance_config, save_doc_service_config, save_service_system_prompts, save_theme, validate_theme_tokens, ) from app.core.config import settings from app.deps import get_current_admin, get_current_user, get_service_admin from app.models.user import User router = APIRouter() _THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$") # ── Pydantic request bodies ──────────────────────────────────────────────────── class AIProviderUpdate(BaseModel): provider: str anthropic_api_key: str = "" anthropic_model: str = "" ollama_base_url: str = "" ollama_model: str = "" ollama_api_key: str = "" lmstudio_base_url: str = "" lmstudio_model: str = "" lmstudio_api_key: str = "" class LimitsUpdate(BaseModel): max_pdf_mb: int class SystemPromptUpdate(BaseModel): system: str user_template: str class AppearanceUpdate(BaseModel): theme: str default_mode: str class ThemeColors(BaseModel): primary: str primary_hover: str accent: str accent_hover: str background: str surface: str border: str text_primary: str text_muted: str class ThemeCreate(BaseModel): id: str label: str light: ThemeColors dark: ThemeColors class ThemeUpdate(BaseModel): label: str | None = None light: ThemeColors | None = None dark: ThemeColors | None = None # ── AI settings ──────────────────────────────────────────────────────────────── @router.get("/ai") async def get_ai_settings( _: User = Depends(get_service_admin("ai-service")), ) -> dict: return await load_ai_service_config_masked() @router.patch("/ai") async def update_ai_settings( body: AIProviderUpdate, _: User = Depends(get_service_admin("ai-service")), ) -> dict: valid_providers = ("anthropic", "ollama", "lmstudio") if body.provider not in valid_providers: raise HTTPException(status_code=422, detail=f"provider must be one of {valid_providers}") config = await load_ai_service_config() config.provider = body.provider # Anthropic if body.anthropic_api_key: config.anthropic.api_key = _merge_api_key( body.anthropic_api_key, config.anthropic.api_key ) if body.anthropic_model: config.anthropic.model = body.anthropic_model # Ollama if body.ollama_base_url: config.ollama.base_url = body.ollama_base_url if body.ollama_model: config.ollama.model = body.ollama_model if body.ollama_api_key: config.ollama.api_key = _merge_api_key(body.ollama_api_key, config.ollama.api_key) # LM Studio if body.lmstudio_base_url: config.lmstudio.base_url = body.lmstudio_base_url if body.lmstudio_model: config.lmstudio.model = body.lmstudio_model if body.lmstudio_api_key: config.lmstudio.api_key = _merge_api_key( body.lmstudio_api_key, config.lmstudio.api_key ) await save_ai_service_config(config) return await load_ai_service_config_masked() @router.post("/ai/test") async def test_ai_connection( _: User = Depends(get_service_admin("ai-service")), ) -> dict: """Proxy a minimal chat request to ai-service to verify the connection.""" try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( f"{settings.AI_SERVICE_URL}/chat", json={ "messages": [{"role": "user", "content": "Reply with: ok"}], "max_tokens": 16, "temperature": 0, }, ) if resp.status_code == 200: data = resp.json() return {"ok": True, "provider": data.get("provider"), "response": data.get("content")} return {"ok": False, "error": f"ai-service returned {resp.status_code}: {resp.text[:200]}"} except Exception as exc: return {"ok": False, "error": str(exc)} # ── Document limits ──────────────────────────────────────────────────────────── @router.get("/documents/limits") async def get_documents_limits( _: User = Depends(get_service_admin("doc-service")), ) -> dict: return await load_doc_service_config_masked() @router.patch("/documents/limits") async def update_documents_limits( body: LimitsUpdate, _: User = Depends(get_service_admin("doc-service")), ) -> dict: if body.max_pdf_mb < 1 or body.max_pdf_mb > 200: raise HTTPException(status_code=422, detail="max_pdf_mb must be between 1 and 200") config = await load_doc_service_config() config.documents.max_pdf_bytes = body.max_pdf_mb * 1024 * 1024 await save_doc_service_config(config) return await load_doc_service_config_masked() # ── System prompts ───────────────────────────────────────────────────────────── @router.get("/system-prompts") async def get_system_prompts( _: User = Depends(get_service_admin("ai-service")), ) -> dict: return await load_all_system_prompts() @router.patch("/system-prompts/{service_id}") async def update_system_prompt( service_id: str, body: SystemPromptUpdate, _: User = Depends(get_service_admin("ai-service")), ) -> dict: if service_id not in SYSTEM_PROMPT_SERVICES: raise HTTPException(status_code=404, detail=f"No system prompts registered for {service_id!r}") await save_service_system_prompts(service_id, body.system, body.user_template) return await load_all_system_prompts() # ── Appearance (global default — auth read, admin write) ─────────────────────── @router.get("/appearance") async def get_appearance( _: User = Depends(get_current_user), ) -> dict: config = await load_appearance_config() return config.model_dump() @router.patch("/appearance") async def update_appearance( body: AppearanceUpdate, _: User = Depends(get_current_admin), ) -> dict: if body.default_mode not in ("light", "dark", "system"): raise HTTPException(status_code=422, detail="default_mode must be 'light', 'dark', or 'system'") themes = await load_all_themes() theme_ids = {t["id"] for t in themes} if body.theme not in theme_ids: raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}") config = AppearanceConfig(theme=body.theme, default_mode=body.default_mode) await save_appearance_config(config) return config.model_dump() # ── Theme CRUD ───────────────────────────────────────────────────────────────── @router.get("/themes") async def list_themes( _: User = Depends(get_current_user), ) -> list: return await load_all_themes() @router.post("/themes", status_code=201) async def create_theme( body: ThemeCreate, _: User = Depends(get_current_admin), ) -> dict: if not _THEME_ID_RE.match(body.id): raise HTTPException(status_code=422, detail="Theme ID must match [a-z0-9_-]{1,64}") existing = {t["id"] for t in await load_all_themes()} if body.id in existing: raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}") light = body.light.model_dump() dark = body.dark.model_dump() for mode, colors in (("light", light), ("dark", dark)): errors = validate_theme_tokens(colors) if errors: raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}") theme = {"id": body.id, "label": body.label, "builtin": False, "light": light, "dark": dark} await save_theme(theme) return theme @router.patch("/themes/{theme_id}") async def update_theme( theme_id: str, body: ThemeUpdate, _: User = Depends(get_current_admin), ) -> dict: theme = await load_theme_by_id(theme_id) if theme is None: raise HTTPException(status_code=404, detail="Theme not found") if theme.get("builtin"): raise HTTPException(status_code=400, detail="Cannot edit a built-in theme") if body.label is not None: theme["label"] = body.label if body.light is not None: light = body.light.model_dump() errors = validate_theme_tokens(light) if errors: raise HTTPException(status_code=422, detail=f"light: {'; '.join(errors)}") theme["light"] = light if body.dark is not None: dark = body.dark.model_dump() errors = validate_theme_tokens(dark) if errors: raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}") theme["dark"] = dark await save_theme(theme) return theme @router.delete("/themes/{theme_id}", status_code=204) async def remove_theme( theme_id: str, _: User = Depends(get_current_admin), ) -> None: try: await delete_theme(theme_id) except FileNotFoundError: raise HTTPException(status_code=404, detail="Theme not found") except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc))