feat: add LLM key validation endpoint, emit agent errors via SSE, and improve key management UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user