Add Test Connection button to AI settings with plain-English error messages
- 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>
This commit is contained in:
parent
e676e698ff
commit
1ece0908af
4 changed files with 8732 additions and 2 deletions
|
|
@ -71,6 +71,80 @@ async def save_ai_settings(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_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)
|
@router.delete("/ai", status_code=204)
|
||||||
async def clear_ai_settings(
|
async def clear_ai_settings(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|
|
||||||
8619
frontend/package-lock.json
generated
Normal file
8619
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -37,6 +37,11 @@ export async function clearAiSettings(): Promise<void> {
|
||||||
await api.delete("/settings/ai");
|
await api.delete("/settings/ai");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testAiSettings(): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const { data } = await api.post("/settings/ai/test");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function parseReceipt(txnId: string, attachmentId: string): Promise<ParsedReceipt> {
|
export async function parseReceipt(txnId: string, attachmentId: string): Promise<ParsedReceipt> {
|
||||||
const { data } = await api.post(`/transactions/${txnId}/attachments/${attachmentId}/parse`);
|
const { data } = await api.post(`/transactions/${txnId}/attachments/${attachmentId}/parse`);
|
||||||
return data;
|
return data;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
changePassword, updateProfile, exportData, getMe,
|
changePassword, updateProfile, exportData, getMe,
|
||||||
} from "@/api/auth";
|
} from "@/api/auth";
|
||||||
import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin";
|
import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin";
|
||||||
import { getAiSettings, saveAiSettings, clearAiSettings } from "@/api/settings";
|
import { getAiSettings, saveAiSettings, clearAiSettings, testAiSettings } from "@/api/settings";
|
||||||
import type { AiSettings } from "@/api/settings";
|
import type { AiSettings } from "@/api/settings";
|
||||||
import type { BackupFile } from "@/api/admin";
|
import type { BackupFile } from "@/api/admin";
|
||||||
import { cn } from "@/utils/cn";
|
import { cn } from "@/utils/cn";
|
||||||
|
|
@ -699,6 +699,13 @@ function AiSection() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: testAiSettings,
|
||||||
|
onSuccess: (result) => setTestResult(result),
|
||||||
|
onError: (e: any) => setTestResult({ ok: false, message: e?.response?.data?.detail ?? "Test failed" }),
|
||||||
|
});
|
||||||
|
|
||||||
const defaultModel = provider === "anthropic" ? "claude-haiku-4-5-20251001" : "gpt-4o-mini";
|
const defaultModel = provider === "anthropic" ? "claude-haiku-4-5-20251001" : "gpt-4o-mini";
|
||||||
const defaultUrl = provider === "anthropic" ? "https://api.anthropic.com" : "https://api.openai.com";
|
const defaultUrl = provider === "anthropic" ? "https://api.anthropic.com" : "https://api.openai.com";
|
||||||
|
|
||||||
|
|
@ -793,7 +800,7 @@ function AiSection() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
disabled={saveMutation.isPending || (!apiKey && !current?.has_api_key)}
|
disabled={saveMutation.isPending || (!apiKey && !current?.has_api_key)}
|
||||||
|
|
@ -803,6 +810,17 @@ function AiSection() {
|
||||||
Save settings
|
Save settings
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{current?.has_api_key && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setTestResult(null); testMutation.mutate(); }}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
className="flex items-center gap-2 border border-border px-4 py-2 rounded-lg text-sm font-medium hover:bg-secondary disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
Test connection
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{current?.has_api_key && (
|
{current?.has_api_key && (
|
||||||
<button
|
<button
|
||||||
onClick={() => clearMutation.mutate()}
|
onClick={() => clearMutation.mutate()}
|
||||||
|
|
@ -814,6 +832,20 @@ function AiSection() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-start gap-2 rounded-lg px-3 py-2.5 text-sm",
|
||||||
|
testResult.ok
|
||||||
|
? "bg-success/10 border border-success/30 text-success"
|
||||||
|
: "bg-destructive/10 border border-destructive/30 text-destructive"
|
||||||
|
)}>
|
||||||
|
{testResult.ok
|
||||||
|
? <CheckCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
: <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />}
|
||||||
|
<span>{testResult.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue