- POST /settings/ai/test sends a minimal text prompt to verify the provider - Returns ok/message — maps HTTP status codes to human-readable explanations - 405 → "URL pointing at wrong place, check custom URL has no path suffix" - 401 → invalid key, 404 → wrong path, 429 → rate limited, etc. - Test button appears once a key is saved; result shown inline below buttons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158 lines
6.3 KiB
Python
158 lines
6.3 KiB
Python
"""
|
|
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()
|