feat: add LLM key validation endpoint, emit agent errors via SSE, and improve key management UI

This commit is contained in:
bryan
2026-04-13 16:25:43 -07:00
parent 0964758b12
commit b7d850ddd0
5 changed files with 249 additions and 71 deletions
+14 -5
View File
@@ -961,11 +961,20 @@ class AgentLoop(AgentProtocol):
error=str(e)[:500],
execution_id=execution_id,
)
# Inject the error as an assistant message so the
# user sees it, then block for their next message.
await conversation.add_assistant_message(
f"[Error: {error_msg}. Please try again.]"
)
# Emit the error via SSE so the frontend renders
# it in the chat, then persist it in the conversation.
visible_error = f"[Error: {error_msg}. Please try again.]"
if self._event_bus and ctx.emits_client_io:
await self._event_bus.emit_client_output_delta(
stream_id=stream_id,
node_id=node_id,
content=visible_error,
snapshot=visible_error,
execution_id=execution_id,
iteration=iteration,
inner_turn=0,
)
await conversation.add_assistant_message(visible_error)
await self._await_user_input(ctx, prompt="")
_llm_turn_failed_waiting_input = True
break # exit retry loop, continue outer iteration
+78 -2
View File
@@ -13,6 +13,31 @@ from framework.server.app import validate_agent_path
logger = logging.getLogger(__name__)
_llm_key_providers_cache: dict | None = None
def _get_llm_key_providers() -> dict:
"""Lazily load the PROVIDERS dict from scripts/check_llm_key.py (cached)."""
global _llm_key_providers_cache
if _llm_key_providers_cache is None:
import importlib.util
from pathlib import Path as _Path
script = _Path(__file__).resolve().parents[3] / "scripts" / "check_llm_key.py"
if not script.exists():
logger.warning("check_llm_key.py not found at %s — key validation disabled", script)
_llm_key_providers_cache = {}
return _llm_key_providers_cache
spec = importlib.util.spec_from_file_location("check_llm_key", script)
if spec is None or spec.loader is None:
logger.warning("Failed to load spec for %s — key validation disabled", script)
_llm_key_providers_cache = {}
return _llm_key_providers_cache
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_llm_key_providers_cache = mod.PROVIDERS
return _llm_key_providers_cache
def _get_store(request: web.Request) -> CredentialStore:
return request.app["credential_store"]
@@ -142,8 +167,18 @@ async def handle_delete_credential(request: web.Request) -> web.Response:
return web.json_response({"deleted": True})
store = _get_store(request)
deleted = store.delete_credential(credential_id)
if not deleted:
deleted_from_store = store.delete_credential(credential_id)
# Also clear the env var for this process so the key doesn't
# reappear via the env-var fallback in _resolve_api_key().
from framework.server.routes_config import PROVIDER_ENV_VARS
env_var = PROVIDER_ENV_VARS.get(credential_id.lower())
deleted_from_env = False
if env_var and os.environ.pop(env_var, None) is not None:
deleted_from_env = True
if not deleted_from_store and not deleted_from_env:
return web.json_response({"error": f"Credential '{credential_id}' not found"}, status=404)
_invalidate_queen_credentials_cache(request)
return web.json_response({"deleted": True})
@@ -396,12 +431,53 @@ async def handle_list_specs(request: web.Request) -> web.Response:
)
async def handle_validate_key(request: web.Request) -> web.Response:
"""POST /api/credentials/validate-key — health-check an LLM provider key.
Body: {"provider_id": "anthropic", "api_key": "sk-..."}
Returns: {"valid": bool|null, "message": str}
Runs the same checks as ``quickstart.sh`` (scripts/check_llm_key.py)
but in-process no subprocess overhead.
"""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON body"}, status=400)
provider_id = body.get("provider_id", "").strip()
api_key = body.get("api_key", "").strip()
if not provider_id or not api_key:
return web.json_response(
{"error": "provider_id and api_key are required"}, status=400
)
try:
checker = _get_llm_key_providers().get(provider_id)
if not checker:
return web.json_response(
{"valid": True, "message": f"No health check for {provider_id}"}
)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, lambda: checker(api_key))
return web.json_response(result)
except Exception as exc:
logger.warning("LLM key validation failed for %s: %s", provider_id, exc)
return web.json_response(
{"valid": None, "message": f"Validation error: {exc}"}
)
def register_routes(app: web.Application) -> None:
"""Register credential routes on the application."""
# specs and check-agent must be registered BEFORE the {credential_id} wildcard
app.router.add_get("/api/credentials/specs", handle_list_specs)
app.router.add_post("/api/credentials/check-agent", handle_check_agent)
app.router.add_post("/api/credentials/resync", handle_resync_credentials)
app.router.add_post("/api/credentials/validate-key", handle_validate_key)
app.router.add_get("/api/credentials", handle_list_credentials)
app.router.add_post("/api/credentials", handle_save_credential)
app.router.add_get("/api/credentials/{credential_id}", handle_get_credential)
+6
View File
@@ -81,4 +81,10 @@ export const credentialsApi = {
resync: () =>
api.post<ResyncResponse>("/credentials/resync", {}),
validateKey: (providerId: string, apiKey: string) =>
api.post<{ valid: boolean | null; message: string }>(
"/credentials/validate-key",
{ provider_id: providerId, api_key: apiKey },
),
};
+150 -63
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { X, Eye, EyeOff, Check, Trash2, ChevronDown, Zap, ThumbsUp } from "lucide-react";
import { X, Eye, EyeOff, Check, Pencil, ChevronDown, Zap, ThumbsUp, Loader2, AlertCircle } from "lucide-react";
import { useColony } from "@/context/ColonyContext";
import { useTheme } from "@/context/ThemeContext";
import { useModel, LLM_PROVIDERS } from "@/context/ModelContext";
import { credentialsApi } from "@/api/credentials";
import type { ModelOption } from "@/api/config";
interface SettingsModalProps {
@@ -21,7 +22,6 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
availableModels,
setModel,
saveProviderKey,
removeProviderKey,
subscriptions,
detectedSubscriptions,
activeSubscription,
@@ -40,6 +40,11 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
// Validation state per provider: "validating" | {valid, message}
const [validation, setValidation] = useState<
Record<string, "validating" | { valid: boolean | null; message: string }>
>({});
// Model selection state
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
@@ -75,26 +80,71 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
};
const handleSaveKey = async (providerId: string) => {
if (!keyInput.trim()) return;
const trimmedKey = keyInput.trim();
if (!trimmedKey) return;
setSaving(true);
setValidation((v) => ({ ...v, [providerId]: "validating" }));
// Validate first — only persist the key if validation passes or is inconclusive.
const validateResult = await credentialsApi
.validateKey(providerId, trimmedKey)
.catch(() => ({ valid: null as boolean | null, message: "Could not verify key" }));
if (validateResult.valid === false) {
// Key is definitively invalid — don't save it.
setSaving(false);
setValidation((v) => ({
...v,
[providerId]: { valid: false, message: validateResult.message },
}));
setTimeout(() => {
setValidation((v) => {
const next = { ...v };
delete next[providerId];
return next;
});
}, 4000);
return;
}
// Validation passed or was inconclusive — save the key.
try {
await saveProviderKey(providerId, keyInput.trim());
setEditingProvider(null);
setKeyInput("");
setShowKey(false);
await saveProviderKey(providerId, trimmedKey);
} catch (err) {
console.error("Failed to save key:", err);
} finally {
setSaving(false);
setValidation((v) => ({
...v,
[providerId]: { valid: false, message: "Failed to save key" },
}));
setTimeout(() => {
setValidation((v) => {
const next = { ...v };
delete next[providerId];
return next;
});
}, 4000);
return;
}
};
const handleRemoveKey = async (providerId: string) => {
try {
await removeProviderKey(providerId);
} catch (err) {
console.error("Failed to remove key:", err);
}
setSaving(false);
setEditingProvider(null);
setKeyInput("");
setShowKey(false);
setValidation((v) => ({
...v,
[providerId]: { valid: validateResult.valid, message: validateResult.message },
}));
// Auto-clear validation result after 4s
setTimeout(() => {
setValidation((v) => {
const next = { ...v };
delete next[providerId];
return next;
});
}, 4000);
};
const handleSelectModel = async (provider: string, modelId: string) => {
@@ -391,6 +441,7 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
{LLM_PROVIDERS.map((provider) => {
const isConnected = connectedProviders.has(provider.id);
const isEditing = editingProvider === provider.id;
const providerValidation = validation[provider.id];
return (
<div key={provider.id}>
@@ -415,16 +466,37 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
{/* Action */}
{isConnected && !isEditing ? (
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-xs text-green-500 font-medium">
<Check className="w-3 h-3" />
Connected
</span>
{providerValidation === "validating" ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground font-medium">
<Loader2 className="w-3 h-3 animate-spin" />
Verifying...
</span>
) : providerValidation && typeof providerValidation === "object" && providerValidation.valid === false ? (
<span className="flex items-center gap-1 text-xs text-red-400 font-medium" title={providerValidation.message}>
<AlertCircle className="w-3 h-3" />
Invalid key
</span>
) : providerValidation && typeof providerValidation === "object" && providerValidation.valid === true ? (
<span className="flex items-center gap-1 text-xs text-green-500 font-medium">
<Check className="w-3 h-3" />
Verified
</span>
) : (
<span className="flex items-center gap-1 text-xs text-green-500 font-medium">
<Check className="w-3 h-3" />
Connected
</span>
)}
<button
onClick={() => handleRemoveKey(provider.id)}
className="p-1 rounded text-muted-foreground/40 hover:text-red-400 transition-colors"
title="Remove key"
onClick={() => {
setEditingProvider(provider.id);
setKeyInput("");
setShowKey(false);
}}
className="p-1 rounded text-muted-foreground/40 hover:text-foreground transition-colors"
title="Change key"
>
<Trash2 className="w-3.5 h-3.5" />
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
) : !isEditing ? (
@@ -443,50 +515,65 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
{/* Inline key entry */}
{isEditing && (
<div className="ml-12 mr-2 mb-2 flex items-center gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
placeholder={`Enter ${provider.name} API key`}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveKey(provider.id);
if (e.key === "Escape") {
setEditingProvider(null);
setKeyInput("");
}
}}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 pr-9 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 font-mono"
/>
<div className="ml-12 mr-2 mb-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
placeholder={`Enter ${provider.name} API key`}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveKey(provider.id);
if (e.key === "Escape") {
setEditingProvider(null);
setKeyInput("");
}
}}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 pr-9 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 font-mono"
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-foreground transition-colors"
>
{showKey ? (
<EyeOff className="w-3.5 h-3.5" />
) : (
<Eye className="w-3.5 h-3.5" />
)}
</button>
</div>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-foreground transition-colors"
onClick={() => handleSaveKey(provider.id)}
disabled={!keyInput.trim() || saving}
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{showKey ? (
<EyeOff className="w-3.5 h-3.5" />
) : (
<Eye className="w-3.5 h-3.5" />
)}
{saving ? "..." : "Save"}
</button>
<button
onClick={() => {
setEditingProvider(null);
setKeyInput("");
}}
className="px-3 py-2 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
>
Cancel
</button>
</div>
<button
onClick={() => handleSaveKey(provider.id)}
disabled={!keyInput.trim() || saving}
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "..." : "Save"}
</button>
<button
onClick={() => {
setEditingProvider(null);
setKeyInput("");
}}
className="px-3 py-2 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
>
Cancel
</button>
{/* Validation feedback inside editing mode */}
{providerValidation === "validating" && (
<span className="flex items-center gap-1 text-xs text-muted-foreground font-medium">
<Loader2 className="w-3 h-3 animate-spin" />
Verifying...
</span>
)}
{providerValidation && typeof providerValidation === "object" && providerValidation.valid === false && (
<span className="flex items-center gap-1 text-xs text-red-400 font-medium">
<AlertCircle className="w-3 h-3" />
{providerValidation.message}
</span>
)}
</div>
)}
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { useParams, useSearchParams, useLocation } from "react-router-dom";
import { Loader2, Users } from "lucide-react";
import ChatPanel, {
type ChatMessage,