feat(model-connections): improve model discovery error handling and enhance UI components

This commit is contained in:
Anish Sarkar 2026-06-12 22:50:50 +05:30
parent 407f2a9612
commit 55f004e1da
3 changed files with 55 additions and 24 deletions

View file

@ -32,6 +32,7 @@ from app.schemas import (
VerifyConnectionResponse, VerifyConnectionResponse,
) )
from app.services.model_connection_service import ( from app.services.model_connection_service import (
ModelDiscoveryError,
derive_capabilities, derive_capabilities,
discover_models, discover_models,
persist_verification, persist_verification,
@ -313,7 +314,10 @@ async def preview_connection_models(
search_space_id=data.search_space_id if data.scope == ConnectionScope.SEARCH_SPACE else None, search_space_id=data.search_space_id if data.scope == ConnectionScope.SEARCH_SPACE else None,
user_id=user.id, user_id=user.id,
) )
discovered = await discover_models(draft) try:
discovered = await discover_models(draft)
except ModelDiscoveryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return [_preview_model_read(item) for item in discovered] return [_preview_model_read(item) for item in discovered]
@ -367,7 +371,10 @@ async def discover_connection_models(
): ):
conn = await _load_connection(session, connection_id) conn = await _load_connection(session, connection_id)
await _assert_connection_access(session, user, conn, Permission.LLM_CONFIGS_CREATE.value) await _assert_connection_access(session, user, conn, Permission.LLM_CONFIGS_CREATE.value)
discovered = await discover_models(conn) try:
discovered = await discover_models(conn)
except ModelDiscoveryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
by_model_id = {model.model_id: model for model in conn.models} by_model_id = {model.model_id: model for model in conn.models}
for item in discovered: for item in discovered:
db_model = by_model_id.get(item["model_id"]) db_model = by_model_id.get(item["model_id"])

View file

@ -31,6 +31,10 @@ class VerifyResult:
message: str = "" message: str = ""
class ModelDiscoveryError(Exception):
"""User-correctable discovery failure for provider configuration issues."""
def _auth_headers(conn: Connection) -> dict[str, str]: def _auth_headers(conn: Connection) -> dict[str, str]:
if not conn.api_key: if not conn.api_key:
return {} return {}
@ -120,6 +124,23 @@ async def persist_verification(conn: Connection) -> VerifyResult:
return result return result
def _discovery_error_message(conn: Connection, exc: httpx.HTTPError) -> str:
base_url = _base_url_or_default(conn)
if isinstance(exc, httpx.HTTPStatusError):
status_code = exc.response.status_code
if status_code in (401, 403):
return "Authentication failed while discovering models."
if status_code == 404:
spec = spec_for(conn.provider)
if spec.transport == Transport.OPENAI_COMPATIBLE:
return "OpenAI-compatible servers should expose /v1/models."
return "Model discovery endpoint returned 404."
return f"Model discovery failed with HTTP {status_code}."
if isinstance(exc, httpx.TimeoutException):
return f"Model discovery timed out: {exc}"
return _docker_hint(base_url, exc)
def _allowlist(conn: Connection) -> set[str]: def _allowlist(conn: Connection) -> set[str]:
raw = (conn.extra or {}).get("model_ids") or [] raw = (conn.extra or {}).get("model_ids") or []
return {str(item).strip() for item in raw if str(item).strip()} return {str(item).strip() for item in raw if str(item).strip()}
@ -339,20 +360,23 @@ async def discover_models(conn: Connection) -> list[dict[str, Any]]:
allowlist = _allowlist(conn) allowlist = _allowlist(conn)
spec = spec_for(conn.provider) spec = spec_for(conn.provider)
if spec.discovery == "ollama": try:
results = await _ollama_tags_then_show(conn) if spec.discovery == "ollama":
elif spec.discovery == "openrouter": results = await _ollama_tags_then_show(conn)
results = await _openrouter_models(conn) elif spec.discovery == "openrouter":
elif spec.discovery == "anthropic_models": results = await _openrouter_models(conn)
results = await _discover_anthropic_models(conn) elif spec.discovery == "anthropic_models":
elif spec.discovery == "openai_models": results = await _discover_anthropic_models(conn)
results = await _discover_openai_shaped_models(conn, conn.base_url) elif spec.discovery == "openai_models":
elif spec.discovery == "bedrock_models": results = await _discover_openai_shaped_models(conn, conn.base_url)
results = await _discover_bedrock_models(conn) elif spec.discovery == "bedrock_models":
elif spec.discovery == "static": results = await _discover_bedrock_models(conn)
results = _litellm_static_models(conn) elif spec.discovery == "static":
else: results = _litellm_static_models(conn)
results = [] else:
results = []
except httpx.HTTPError as exc:
raise ModelDiscoveryError(_discovery_error_message(conn, exc)) from exc
if allowlist: if allowlist:
results = [item for item in results if item["model_id"] in allowlist] results = [item for item in results if item["model_id"] in allowlist]
@ -376,6 +400,7 @@ async def test_model(conn: Connection, model: Model) -> VerifyResult:
__all__ = [ __all__ = [
"ModelDiscoveryError",
"VerifyResult", "VerifyResult",
"derive_capabilities", "derive_capabilities",
"discover_models", "discover_models",

View file

@ -1,4 +1,4 @@
import { RefreshCcw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -32,7 +32,7 @@ interface ModelsSelectionPanelProps {
export function ModelsSelectionPanel({ export function ModelsSelectionPanel({
models, models,
description = "Select models to make available for this provider.", description = "Select models to make available for this provider.",
emptyMessage = "No models yet. Use the refresh button to discover models or add one manually.", emptyMessage = "No models available.",
manualInputPlaceholder = "Add a model ID manually", manualInputPlaceholder = "Add a model ID manually",
refreshLabel = "Refresh models", refreshLabel = "Refresh models",
isRefreshing = false, isRefreshing = false,
@ -86,14 +86,14 @@ export function ModelsSelectionPanel({
</Button> </Button>
{onRefresh ? ( {onRefresh ? (
<Button <Button
variant="outline" variant="ghost"
size="icon" size="icon"
type="button" type="button"
onClick={onRefresh} onClick={onRefresh}
disabled={isRefreshing} disabled={isRefreshing}
aria-label={refreshLabel} aria-label={refreshLabel}
> >
<RefreshCcw className="h-4 w-4" /> <RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button> </Button>
) : null} ) : null}
</div> </div>
@ -113,7 +113,6 @@ export function ModelsSelectionPanel({
placeholder={manualInputPlaceholder} placeholder={manualInputPlaceholder}
/> />
<Button <Button
variant="outline"
size="sm" size="sm"
type="button" type="button"
onClick={addModel} onClick={addModel}
@ -135,9 +134,9 @@ export function ModelsSelectionPanel({
<Button <Button
key={filter.key} key={filter.key}
type="button" type="button"
variant={isActive ? "secondary" : "outline"} variant="secondary"
size="sm" size="sm"
className="h-7 rounded-full px-3 text-xs" className={`h-7 rounded-full px-3 text-xs ${isActive ? "" : "opacity-80"}`}
onClick={() => setModelFilter(isActive ? null : filter.key)} onClick={() => setModelFilter(isActive ? null : filter.key)}
> >
{filter.label} {filter.label}
@ -148,7 +147,7 @@ export function ModelsSelectionPanel({
</div> </div>
) : null} ) : null}
<div className="max-h-80 overflow-y-auto rounded-xl border bg-muted/20 p-2"> <div className="h-80 overflow-y-auto rounded-xl border bg-muted/20 p-2">
{models.length === 0 ? ( {models.length === 0 ? (
<div className="rounded-lg px-3 py-6 text-center text-sm text-muted-foreground"> <div className="rounded-lg px-3 py-6 text-center text-sm text-muted-foreground">
{emptyMessage} {emptyMessage}