""" Admin-only settings API for per-service runtime configuration. All endpoints require the caller to be an admin (Depends(get_current_admin)). Config files live on the shared app_config volume (/config/). """ import asyncio from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from app.core.app_config import ( DocServiceConfig, _merge_api_key, load_doc_service_config, load_doc_service_config_masked, save_doc_service_config, ) from app.deps import get_current_admin from app.models.user import User router = APIRouter() # ── 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 # ── Documents settings ───────────────────────────────────────────────────────── @router.get("/documents") async def get_documents_settings( _: User = Depends(get_current_admin), ) -> dict: return load_doc_service_config_masked() @router.patch("/documents/ai") async def update_documents_ai( body: AIProviderUpdate, _: User = Depends(get_current_admin), ) -> 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 = load_doc_service_config() config.ai.provider = body.provider # Anthropic if body.anthropic_api_key: config.ai.anthropic.api_key = _merge_api_key( body.anthropic_api_key, config.ai.anthropic.api_key ) if body.anthropic_model: config.ai.anthropic.model = body.anthropic_model # Ollama if body.ollama_base_url: config.ai.ollama.base_url = body.ollama_base_url if body.ollama_model: config.ai.ollama.model = body.ollama_model if body.ollama_api_key: config.ai.ollama.api_key = _merge_api_key(body.ollama_api_key, config.ai.ollama.api_key) # LM Studio if body.lmstudio_base_url: config.ai.lmstudio.base_url = body.lmstudio_base_url if body.lmstudio_model: config.ai.lmstudio.model = body.lmstudio_model if body.lmstudio_api_key: config.ai.lmstudio.api_key = _merge_api_key( body.lmstudio_api_key, config.ai.lmstudio.api_key ) await asyncio.to_thread(save_doc_service_config, config) return load_doc_service_config_masked() @router.post("/documents/ai/test") async def test_documents_ai( _: User = Depends(get_current_admin), ) -> dict: """Test the configured AI connection with a minimal prompt.""" from app.core.app_config import load_service_config raw = await asyncio.to_thread(load_service_config, "doc_service") ai_cfg = raw.get("ai", {}) provider_name = ai_cfg.get("provider", "anthropic") try: if provider_name == "anthropic": import anthropic client = anthropic.AsyncAnthropic(api_key=ai_cfg["anthropic"]["api_key"]) msg = await client.messages.create( model=ai_cfg["anthropic"].get("model", "claude-haiku-4-5-20251001"), max_tokens=16, messages=[{"role": "user", "content": "Reply with: ok"}], ) return {"ok": True, "provider": provider_name, "response": msg.content[0].text} elif provider_name in ("ollama", "lmstudio"): import openai pcfg = ai_cfg[provider_name] client = openai.AsyncOpenAI( base_url=pcfg["base_url"], api_key=pcfg.get("api_key") or "none", ) resp = await client.chat.completions.create( model=pcfg["model"], messages=[{"role": "user", "content": "Reply with: ok"}], max_tokens=16, temperature=0, ) return { "ok": True, "provider": provider_name, "response": resp.choices[0].message.content, } else: raise HTTPException(status_code=422, detail=f"Unknown provider: {provider_name}") except Exception as exc: return {"ok": False, "provider": provider_name, "error": str(exc)} @router.patch("/documents/limits") async def update_documents_limits( body: LimitsUpdate, _: User = Depends(get_current_admin), ) -> 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 = load_doc_service_config() config.documents.max_pdf_bytes = body.max_pdf_mb * 1024 * 1024 await asyncio.to_thread(save_doc_service_config, config) return load_doc_service_config_masked()