mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
feat(model-connections): implement manual model addition and enhance model discovery
This commit is contained in:
parent
85114d2a0e
commit
780e242132
8 changed files with 335 additions and 101 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ from .model_connections import (
|
|||
ConnectionCreate,
|
||||
ConnectionRead,
|
||||
ConnectionUpdate,
|
||||
ModelCreate,
|
||||
ModelRead,
|
||||
ModelRolesRead,
|
||||
ModelRolesUpdate,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue