""" User-level settings: AI provider configuration. """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import decrypt_field, encrypt_field from app.dependencies import get_current_user, get_db from app.db.models.user import User router = APIRouter(prefix="/settings", tags=["settings"]) SUPPORTED_PROVIDERS = {"anthropic", "openai"} class AiSettingsResponse(BaseModel): provider: str | None has_api_key: bool base_url: str | None model: str | None class AiSettingsSave(BaseModel): provider: str api_key: str = "" base_url: str = "" model: str = "" @router.get("/ai", response_model=AiSettingsResponse) async def get_ai_settings(user: User = Depends(get_current_user)): return AiSettingsResponse( provider=user.ai_provider, has_api_key=bool(user.ai_api_key_enc), base_url=user.ai_base_url, model=user.ai_model, ) @router.put("/ai", response_model=AiSettingsResponse) async def save_ai_settings( body: AiSettingsSave, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): if body.provider not in SUPPORTED_PROVIDERS: raise HTTPException(status_code=400, detail=f"Unsupported provider. Choose: {', '.join(SUPPORTED_PROVIDERS)}") values: dict = { "ai_provider": body.provider, "ai_base_url": body.base_url.rstrip("/") or None, "ai_model": body.model.strip() or None, } if body.api_key.strip(): values["ai_api_key_enc"] = encrypt_field(body.api_key.strip()) elif not user.ai_api_key_enc: raise HTTPException(status_code=400, detail="api_key is required when no key is saved yet") await db.execute(update(User).where(User.id == user.id).values(**values)) await db.commit() return AiSettingsResponse( provider=body.provider, has_api_key=True, base_url=values["ai_base_url"], model=values["ai_model"], ) _STATUS_MEANINGS = { 400: "Bad request — the model name may be invalid or the request format is unsupported.", 401: "Unauthorised — your API key is incorrect or has been revoked.", 403: "Forbidden — your API key doesn't have permission to use this model or endpoint.", 404: "Not found — the API URL path is wrong. Check your custom URL doesn't include /v1 (MyMidas adds it automatically).", 405: "Method not allowed — the API URL is pointing at the wrong place. For Open WebUI, the base URL should be just http://host:port with no path suffix.", 422: "Unprocessable — the model name was rejected. Check it matches exactly what the provider expects.", 429: "Rate limited — too many requests. Try again in a moment.", 500: "Server error — the AI provider returned an internal error.", 502: "Bad gateway — could not reach the AI provider. Check the URL is correct and the service is running.", 503: "Service unavailable — the AI provider is down or overloaded.", } @router.post("/ai/test") async def test_ai_settings( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Send a minimal test prompt to verify the configured AI provider works.""" import httpx from sqlalchemy import select as sa_select user_row = await db.get(User, user.id) if not user_row or not user_row.ai_provider or not user_row.ai_api_key_enc: return {"ok": False, "message": "No AI provider configured. Save your settings first."} api_key = decrypt_field(user_row.ai_api_key_enc) custom_base_url = (user_row.ai_base_url or "").rstrip("/") custom_model = (user_row.ai_model or "").strip() test_prompt = "Reply with the single word: OK" try: if user_row.ai_provider == "anthropic": base_url = custom_base_url or "https://api.anthropic.com" model = custom_model or "claude-haiku-4-5-20251001" async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( f"{base_url}/v1/messages", headers={"x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json"}, json={"model": model, "max_tokens": 10, "messages": [{"role": "user", "content": test_prompt}]}, ) resp.raise_for_status() reply = resp.json()["content"][0]["text"].strip() elif user_row.ai_provider == "openai": base_url = custom_base_url or "https://api.openai.com" model = custom_model or "gpt-4o-mini" async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( f"{base_url}/v1/chat/completions", headers={"Authorization": f"Bearer {api_key}", "content-type": "application/json"}, json={"model": model, "max_tokens": 10, "messages": [{"role": "user", "content": test_prompt}]}, ) resp.raise_for_status() reply = resp.json()["choices"][0]["message"]["content"].strip() else: return {"ok": False, "message": "Unknown provider."} return {"ok": True, "message": f'Connection successful. Model replied: "{reply}"'} except httpx.HTTPStatusError as e: code = e.response.status_code meaning = _STATUS_MEANINGS.get(code, f"HTTP {code} error.") return {"ok": False, "message": f"HTTP {code}: {meaning}"} except httpx.ConnectError: return {"ok": False, "message": "Connection refused — the URL is unreachable. Check the host and port are correct and the service is running."} except httpx.TimeoutException: return {"ok": False, "message": "Request timed out — the server didn't respond within 15 seconds."} except httpx.RequestError as e: return {"ok": False, "message": f"Network error: {e}"} @router.delete("/ai", status_code=204) async def clear_ai_settings( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): await db.execute( update(User) .where(User.id == user.id) .values(ai_provider=None, ai_api_key_enc=None, ai_base_url=None, ai_model=None) ) await db.commit()