feat(model-connections): implement manual model addition and enhance model discovery

This commit is contained in:
Anish Sarkar 2026-06-11 00:11:53 +05:30
parent 85114d2a0e
commit 780e242132
8 changed files with 335 additions and 101 deletions

View file

@ -10,6 +10,7 @@ from app.db import (
Connection,
ConnectionScope,
Model,
ModelSource,
Permission,
SearchSpace,
User,
@ -19,6 +20,7 @@ from app.schemas import (
ConnectionCreate,
ConnectionRead,
ConnectionUpdate,
ModelCreate,
ModelRead,
ModelRolesRead,
ModelRolesUpdate,
@ -26,6 +28,7 @@ from app.schemas import (
VerifyConnectionResponse,
)
from app.services.model_connection_service import (
derive_capabilities,
discover_models,
persist_verification,
test_model,
@ -254,6 +257,41 @@ async def discover_connection_models(
return [_model_read(model) for model in conn.models]
@router.post("/model-connections/{connection_id}/models", response_model=ModelRead)
async def add_manual_model(
connection_id: int,
data: ModelCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
conn = await _load_connection(session, connection_id)
await _assert_connection_access(session, user, conn, Permission.LLM_CONFIGS_UPDATE.value)
model_id = data.model_id.strip()
if not model_id:
raise HTTPException(status_code=400, detail="model_id is required")
if any(existing.model_id == model_id for existing in conn.models):
raise HTTPException(status_code=400, detail="Model already exists on this connection")
capabilities = derive_capabilities(conn, model_id)
model = Model(
connection_id=conn.id,
model_id=model_id,
display_name=data.display_name or None,
source=ModelSource.MANUAL,
capabilities=capabilities,
capabilities_declared=capabilities,
capabilities_verified={},
capabilities_override={},
enabled=True,
catalog={},
)
session.add(model)
await session.commit()
await session.refresh(model)
return _model_read(model)
@router.put("/models/{model_id}", response_model=ModelRead)
async def update_model(
model_id: int,

View file

@ -48,6 +48,7 @@ from .model_connections import (
ConnectionCreate,
ConnectionRead,
ConnectionUpdate,
ModelCreate,
ModelRead,
ModelRolesRead,
ModelRolesUpdate,

View file

@ -65,6 +65,17 @@ class ConnectionUpdate(BaseModel):
enabled: bool | None = None
class ModelCreate(BaseModel):
"""Manually register a model id on a connection.
For providers without a usable ``/models`` endpoint (Perplexity, MiniMax,
Azure deployments, etc.) or to pin a single model from a noisy provider.
"""
model_id: str = Field(..., max_length=255)
display_name: str | None = Field(None, max_length=255)
class ModelUpdate(BaseModel):
display_name: str | None = Field(None, max_length=255)
enabled: bool | None = None

View file

@ -122,6 +122,17 @@ def _litellm_capabilities(model_string: str, model_id: str) -> dict[str, bool]:
return capabilities
def _allowlist(conn: Connection) -> set[str]:
"""Per-connection model-id allowlist stored in ``extra.model_ids``.
Empty/absent means "no restriction" (discover everything), mirroring
OpenWebUI's behaviour. A non-empty list restricts discovery to those ids —
essential for providers like OpenRouter that expose hundreds of models.
"""
raw = (conn.extra or {}).get("model_ids") or []
return {str(item).strip() for item in raw if str(item).strip()}
def derive_capabilities(conn: Connection, model_id: str, metadata: dict | None = None) -> dict[str, bool]:
metadata = metadata or {}
if conn.protocol == ConnectionProtocol.OLLAMA:
@ -140,13 +151,15 @@ def derive_capabilities(conn: Connection, model_id: str, metadata: dict | None =
async def discover_models(conn: Connection) -> list[dict[str, Any]]:
allowlist = _allowlist(conn)
if conn.protocol == ConnectionProtocol.OLLAMA:
url = f"{conn.base_url.rstrip('/')}/api/tags"
async with httpx.AsyncClient(timeout=DISCOVERY_TIMEOUT_SECONDS) as client:
response = await client.get(url, headers=_auth_headers(conn))
response.raise_for_status()
models = response.json().get("models", [])
return [
results = [
{
"model_id": item.get("model") or item.get("name"),
"display_name": item.get("name") or item.get("model"),
@ -157,14 +170,13 @@ async def discover_models(conn: Connection) -> list[dict[str, Any]]:
for item in models
if item.get("model") or item.get("name")
]
if conn.protocol == ConnectionProtocol.OPENAI_COMPATIBLE:
elif conn.protocol == ConnectionProtocol.OPENAI_COMPATIBLE:
url = f"{ensure_v1(conn.base_url)}/models"
async with httpx.AsyncClient(timeout=DISCOVERY_TIMEOUT_SECONDS) as client:
response = await client.get(url, headers=_auth_headers(conn))
response.raise_for_status()
models = response.json().get("data", [])
return [
results = [
{
"model_id": item.get("id"),
"display_name": item.get("name") or item.get("id"),
@ -175,9 +187,13 @@ async def discover_models(conn: Connection) -> list[dict[str, Any]]:
for item in models
if item.get("id")
]
else:
# Native providers rely on curated/global catalog entries or manual rows.
return []
# Native providers rely on curated/global catalog entries or manual rows.
return []
if allowlist:
results = [item for item in results if item["model_id"] in allowlist]
return results
async def test_model(conn: Connection, model: Model) -> VerifyResult:

View file

@ -3,6 +3,7 @@ import { toast } from "sonner";
import type {
ConnectionCreateRequest,
ConnectionUpdateRequest,
ModelCreateRequest,
ModelRoles,
ModelUpdateRequest,
} from "@/contracts/types/model-connections.types";
@ -67,8 +68,17 @@ export const verifyModelConnectionMutationAtom = atomWithMutation((get) => {
mutationKey: ["model-connections", "verify"],
mutationFn: (id: number) => modelConnectionsApiService.verifyConnection(id),
onSuccess: (result) => {
if (result.ok) toast.success("Connection verified");
else toast.error(result.message || "Connection failed");
if (result.ok) {
toast.success("Connection verified");
} else {
// Non-fatal: many providers lack a /models endpoint yet still serve
// chat. Guide the user to add model IDs manually instead of alarming.
toast.warning(
result.message
? `${result.message} Chat may still work — add model IDs manually.`
: "Couldn't list models. Chat may still work — add model IDs manually."
);
}
invalidateModelConnections(searchSpaceId);
},
onError: (error: Error) => toast.error(error.message || "Failed to verify connection"),
@ -88,6 +98,20 @@ export const discoverConnectionModelsMutationAtom = atomWithMutation((get) => {
};
});
export const addManualModelMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
return {
mutationKey: ["models", "add-manual"],
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelCreateRequest }) =>
modelConnectionsApiService.addManualModel(connectionId, data),
onSuccess: () => {
toast.success("Model added");
invalidateModelConnections(searchSpaceId);
},
onError: (error: Error) => toast.error(error.message || "Failed to add model"),
};
});
export const updateModelMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
return {

View file

@ -1,12 +1,14 @@
"use client";
import { useAtom, useAtomValue } from "jotai";
import { CheckCircle2, PlugZap, RefreshCcw, XCircle } from "lucide-react";
import { CheckCircle2, PlugZap, Plus, RefreshCcw, XCircle } from "lucide-react";
import { useMemo, useState } from "react";
import {
addManualModelMutationAtom,
createModelConnectionMutationAtom,
discoverConnectionModelsMutationAtom,
testModelMutationAtom,
updateModelConnectionMutationAtom,
updateModelMutationAtom,
updateModelRolesMutationAtom,
verifyModelConnectionMutationAtom,
@ -46,9 +48,16 @@ type Preset = {
};
const PRESETS: Preset[] = [
{ id: "custom", label: "OpenAI-compatible (any URL)", protocol: "OPENAI_COMPATIBLE" },
{ id: "openai", label: "OpenAI", protocol: "NATIVE", nativeProvider: "OPENAI" },
{ id: "anthropic", label: "Anthropic", protocol: "NATIVE", nativeProvider: "ANTHROPIC" },
{ id: "openrouter", label: "OpenRouter", protocol: "NATIVE", nativeProvider: "OPENROUTER" },
{
id: "openrouter",
label: "OpenRouter",
protocol: "NATIVE",
nativeProvider: "OPENROUTER",
baseUrl: "https://openrouter.ai/api/v1",
},
{
id: "ollama",
label: "Ollama",
@ -86,6 +95,22 @@ const PRESETS: Preset[] = [
},
];
// Free-text URL hints (datalist), mirroring OpenWebUI. These never restrict
// what the user can type — any OpenAI-compatible endpoint works.
const URL_SUGGESTIONS = [
"https://api.openai.com/v1",
"https://api.anthropic.com/v1",
"https://openrouter.ai/api/v1",
"https://generativelanguage.googleapis.com/v1beta/openai",
"https://api.groq.com/openai/v1",
"https://api.mistral.ai/v1",
"https://api.deepseek.com/v1",
"https://api.x.ai/v1",
"http://host.docker.internal:11434",
"http://host.docker.internal:1234/v1",
"http://host.docker.internal:8000/v1",
];
function modelLabel(model: ModelRead) {
return model.display_name || model.model_id;
}
@ -123,22 +148,183 @@ function flattenModels(connections: ConnectionRead[]) {
);
}
function ConnectionCard({ connection }: { connection: ConnectionRead }) {
const verifyConnection = useAtomValue(verifyModelConnectionMutationAtom);
const discoverModels = useAtomValue(discoverConnectionModelsMutationAtom);
const updateConnection = useAtomValue(updateModelConnectionMutationAtom);
const addManualModel = useAtomValue(addManualModelMutationAtom);
const updateModel = useAtomValue(updateModelMutationAtom);
const testModel = useAtomValue(testModelMutationAtom);
const allowlist = Array.isArray(connection.extra?.model_ids)
? (connection.extra.model_ids as string[])
: [];
const [allowlistText, setAllowlistText] = useState(allowlist.join(", "));
const [manualModelId, setManualModelId] = useState("");
const providerLabel = connection.native_provider || connection.protocol;
const isLocal = connection.protocol === "OLLAMA" || !connection.base_url?.startsWith("https");
function saveAllowlist() {
const ids = allowlistText
.split(",")
.map((value) => value.trim())
.filter(Boolean);
updateConnection.mutate({
id: connection.id,
data: { extra: { ...(connection.extra ?? {}), model_ids: ids } },
});
}
function addModel() {
const modelId = manualModelId.trim();
if (!modelId) return;
addManualModel.mutate(
{ connectionId: connection.id, data: { model_id: modelId } },
{ onSuccess: () => setManualModelId("") }
);
}
return (
<div className="rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-medium">
{getProviderIcon(providerLabel, { className: "size-4" })}
{providerLabel}
</div>
<div className="text-sm text-muted-foreground">
{connection.base_url || "Provider default endpoint"}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge connection={connection} />
<Button
variant="outline"
size="sm"
onClick={() => verifyConnection.mutate(connection.id)}
>
Test
</Button>
<Button
variant="outline"
size="sm"
onClick={() => discoverModels.mutate(connection.id)}
>
<RefreshCcw className="mr-2 h-4 w-4" /> Discover
</Button>
</div>
</div>
{connection.last_status && connection.last_status !== "OK" ? (
<p className="mt-2 text-sm text-amber-600 dark:text-amber-500">
{connection.last_error || "Could not list models."} Chat may still work add model
IDs manually below.
</p>
) : null}
{!isLocal ? (
<div className="mt-4 space-y-1">
<Label className="text-xs">Model IDs filter (optional)</Label>
<div className="flex gap-2">
<Input
value={allowlistText}
onChange={(event) => setAllowlistText(event.target.value)}
placeholder="Comma-separated, e.g. anthropic/claude-sonnet-4-5, google/gemini-2.5-pro"
/>
<Button
variant="outline"
size="sm"
onClick={saveAllowlist}
disabled={updateConnection.isPending}
>
Save filter
</Button>
</div>
<p className="text-xs text-muted-foreground">
Leave empty to discover all models. Recommended for providers with large catalogs
(e.g. OpenRouter).
</p>
</div>
) : null}
<div className="mt-4 flex gap-2">
<Input
value={manualModelId}
onChange={(event) => setManualModelId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addModel();
}
}}
placeholder="Add a model ID manually (for providers without /models)"
/>
<Button
variant="outline"
size="sm"
onClick={addModel}
disabled={addManualModel.isPending || !manualModelId.trim()}
>
<Plus className="mr-2 h-4 w-4" /> Add model
</Button>
</div>
<div className="mt-4 grid gap-2">
{connection.models.map((model) => (
<div
key={model.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-muted/40 px-3 py-2"
>
<div>
<div className="flex items-center gap-2 text-sm font-medium">
{getProviderIcon(providerLabel, { className: "size-4" })}
{modelLabel(model)}
{model.source === "MANUAL" ? (
<Badge variant="outline" className="text-[10px]">
manual
</Badge>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{["chat", "vision", "image_gen"]
.filter((key) => Boolean(model.capabilities?.[key]))
.join(", ") || "No verified capabilities"}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => testModel.mutate(model.id)}>
Test
</Button>
<Button
variant={model.enabled ? "secondary" : "outline"}
size="sm"
onClick={() =>
updateModel.mutate({ id: model.id, data: { enabled: !model.enabled } })
}
>
{model.enabled ? "Enabled" : "Enable"}
</Button>
</div>
</div>
))}
</div>
</div>
);
}
export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: number }) {
const [{ data: globalConnections = [] }] = useAtom(globalModelConnectionsAtom);
const [{ data: connections = [] }] = useAtom(modelConnectionsAtom);
const [{ data: roles }] = useAtom(modelRolesAtom);
const createConnection = useAtomValue(createModelConnectionMutationAtom);
const verifyConnection = useAtomValue(verifyModelConnectionMutationAtom);
const discoverModels = useAtomValue(discoverConnectionModelsMutationAtom);
const updateModel = useAtomValue(updateModelMutationAtom);
const testModel = useAtomValue(testModelMutationAtom);
const updateRoles = useAtomValue(updateModelRolesMutationAtom);
const visiblePresets = useMemo(
() => PRESETS.filter((preset) => !(isCloud() && preset.local)),
[]
);
const [presetId, setPresetId] = useState(visiblePresets[0]?.id ?? "openai");
const [presetId, setPresetId] = useState(visiblePresets[0]?.id ?? "custom");
const preset = visiblePresets.find((item) => item.id === presetId) ?? visiblePresets[0];
const [baseUrl, setBaseUrl] = useState(preset?.baseUrl ?? "");
const [apiKey, setApiKey] = useState("");
@ -157,16 +343,19 @@ export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: num
function handleCreate() {
if (!preset) return;
createConnection.mutate({
protocol: preset.protocol,
native_provider: preset.nativeProvider,
base_url: baseUrl || null,
api_key: apiKey || null,
scope: "SEARCH_SPACE",
search_space_id: searchSpaceId,
extra: {},
enabled: true,
});
createConnection.mutate(
{
protocol: preset.protocol,
native_provider: preset.nativeProvider,
base_url: baseUrl || null,
api_key: apiKey || null,
scope: "SEARCH_SPACE",
search_space_id: searchSpaceId,
extra: {},
enabled: true,
},
{ onSuccess: () => setApiKey("") }
);
}
function renderModelOption(model: ModelRead & { connectionName: string; provider: string }) {
@ -192,7 +381,7 @@ export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: num
<CardContent className="space-y-6">
<div className="grid gap-3 md:grid-cols-[220px_1fr_1fr_auto]">
<div className="space-y-2">
<Label>Preset</Label>
<Label>Provider</Label>
<Select value={presetId} onValueChange={onPresetChange}>
<SelectTrigger>
<SelectValue />
@ -217,7 +406,13 @@ export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: num
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="https://api.example.com/v1"
list="model-conn-url-suggestions"
/>
<datalist id="model-conn-url-suggestions">
{URL_SUGGESTIONS.map((url) => (
<option key={url} value={url} />
))}
</datalist>
</div>
<div className="space-y-2">
<Label>API Key</Label>
@ -239,85 +434,16 @@ export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: num
Local URLs are tested from the backend container. Use host.docker.internal instead of
localhost.
</p>
) : preset?.protocol === "OPENAI_COMPATIBLE" ? (
<p className="text-xs text-muted-foreground">
Works with any OpenAI-compatible endpoint (OpenRouter, Together, Groq, vLLM, LM
Studio). After adding, hit Discover to list models.
</p>
) : null}
<div className="space-y-3">
{connections.map((connection) => (
<div key={connection.id} className="rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-medium">
{getProviderIcon(connection.native_provider || connection.protocol, {
className: "size-4",
})}
{connection.native_provider || connection.protocol}
</div>
<div className="text-sm text-muted-foreground">
{connection.base_url || "Provider default endpoint"}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge connection={connection} />
<Button
variant="outline"
size="sm"
onClick={() => verifyConnection.mutate(connection.id)}
>
Test
</Button>
<Button
variant="outline"
size="sm"
onClick={() => discoverModels.mutate(connection.id)}
>
<RefreshCcw className="mr-2 h-4 w-4" /> Discover
</Button>
</div>
</div>
{connection.last_error ? (
<p className="mt-2 text-sm text-destructive">{connection.last_error}</p>
) : null}
<div className="mt-4 grid gap-2">
{connection.models.map((model) => (
<div
key={model.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-muted/40 px-3 py-2"
>
<div>
<div className="flex items-center gap-2 text-sm font-medium">
{getProviderIcon(connection.native_provider || connection.protocol, {
className: "size-4",
})}
{modelLabel(model)}
</div>
<div className="text-xs text-muted-foreground">
{["chat", "vision", "image_gen"]
.filter((key) => Boolean(model.capabilities?.[key]))
.join(", ") || "No verified capabilities"}
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => testModel.mutate(model.id)}
>
Test
</Button>
<Button
variant={model.enabled ? "secondary" : "outline"}
size="sm"
onClick={() =>
updateModel.mutate({ id: model.id, data: { enabled: !model.enabled } })
}
>
{model.enabled ? "Enabled" : "Enable"}
</Button>
</div>
</div>
))}
</div>
</div>
<ConnectionCard key={connection.id} connection={connection} />
))}
</div>
</CardContent>

View file

@ -66,6 +66,11 @@ export const connectionUpdateRequest = z.object({
enabled: z.boolean().optional(),
});
export const modelCreateRequest = z.object({
model_id: z.string().min(1),
display_name: z.string().nullable().optional(),
});
export const modelUpdateRequest = z.object({
display_name: z.string().nullable().optional(),
enabled: z.boolean().optional(),
@ -93,6 +98,7 @@ export type ModelRead = z.infer<typeof modelRead>;
export type ConnectionRead = z.infer<typeof connectionRead>;
export type ConnectionCreateRequest = z.infer<typeof connectionCreateRequest>;
export type ConnectionUpdateRequest = z.infer<typeof connectionUpdateRequest>;
export type ModelCreateRequest = z.infer<typeof modelCreateRequest>;
export type ModelUpdateRequest = z.infer<typeof modelUpdateRequest>;
export type ModelRoles = z.infer<typeof modelRoles>;
export type VerifyConnectionResponse = z.infer<typeof verifyConnectionResponse>;

View file

@ -5,8 +5,10 @@ import {
connectionListResponse,
connectionRead,
connectionUpdateRequest,
type ModelCreateRequest,
type ModelRoles,
type ModelUpdateRequest,
modelCreateRequest,
modelListResponse,
modelRead,
modelRoles,
@ -60,6 +62,16 @@ class ModelConnectionsApiService {
return baseApiService.post(`/api/v1/model-connections/${id}/discover`, modelListResponse);
};
addManualModel = async (connectionId: number, request: ModelCreateRequest) => {
const parsed = modelCreateRequest.safeParse(request);
if (!parsed.success) {
throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
}
return baseApiService.post(`/api/v1/model-connections/${connectionId}/models`, modelRead, {
body: parsed.data,
});
};
updateModel = async (id: number, request: ModelUpdateRequest) => {
const parsed = modelUpdateRequest.safeParse(request);
if (!parsed.success) {