-
+
+
+
+
{getProviderIcon(providerLabel, { className: "size-4" })}
- {providerLabel}
+ {providerLabel}
+ {connection.scope === "GLOBAL" ? (
+
+ Default
+
+ ) : null}
-
+
{connection.base_url || "Provider default endpoint"}
-
+
- verifyConnection.mutate(connection.id)}
- >
- Test
-
- discoverModels.mutate(connection.id)}>
- Discover
-
-
- Delete
-
-
-
-
- {connection.last_status && connection.last_status !== "OK" ? (
-
- {connection.last_error || "Could not list models."} Chat may still work — add model IDs
- manually below.
-
- ) : null}
-
- {!isLocal ? (
-
-
Model IDs filter (optional)
-
- setAllowlistText(event.target.value)}
- placeholder="Comma-separated, e.g. anthropic/claude-sonnet-4-5, google/gemini-2.5-pro"
- />
-
- Save filter
-
-
-
- Leave empty to discover all models. Recommended for providers with large catalogs (e.g.
- OpenRouter).
-
-
- ) : null}
-
-
-
setManualModelId(event.target.value)}
- onKeyDown={(event) => {
- if (event.key === "Enter") {
- event.preventDefault();
- addModel();
- }
- }}
- placeholder="Add a model ID manually (for providers without /models)"
- />
-
- Add model
-
-
-
- {connection.models.length > 0 ? (
-
- Filter models
- {MODEL_CAPABILITY_FILTERS.map((filter) => {
- const count = connection.models.filter((model) => capability(model, filter.key)).length;
- const isActive = modelFilter === filter.key;
-
- return (
- setModelFilter(isActive ? null : filter.key)}
- >
- {filter.label}
- {count}
+
+
+
+
- );
- })}
-
- ) : null}
+
+
+
+
+ {getProviderIcon(providerLabel, { className: "size-5" })}
+
+
+ Configure {providerLabel}
+
+
+ Manage credentials and choose which models are available from this provider.
+
+
+
+
-
- {filteredModels.length === 0 && modelFilter ? (
-
- No {MODEL_CAPABILITY_FILTERS.find((filter) => filter.key === modelFilter)?.label.toLowerCase()}{" "}
- models found on this connection.
-
- ) : null}
- {filteredModels.map((model) => (
-
-
-
- {getProviderIcon(providerLabel, { className: "size-4" })}
- {modelLabel(model)}
- {model.source === "MANUAL" ? (
-
- manual
-
- ) : null}
+
+
+
+
API Base URL
+
+
+ Leave empty to use the provider default endpoint.
+
+
+
+
+
API Key
+
+ setApiKeyDraft(event.target.value)}
+ placeholder={connection.has_api_key ? "Saved API key" : "Paste an API key"}
+ type={showApiKey ? "text" : "password"}
+ className="pr-11"
+ />
+ setShowApiKey((current) => !current)}
+ disabled={!apiKeyDraft}
+ aria-label={showApiKey ? "Hide API key" : "Show API key"}
+ >
+ {showApiKey ? : }
+
+
+
+
+ {!isLocal ? (
+
+
Model IDs filter (optional)
+
+ setAllowlistText(event.target.value)}
+ placeholder="Comma-separated, e.g. anthropic/claude-sonnet-4-5, google/gemini-2.5-pro"
+ />
+
+ Save filter
+
+
+
+ Leave empty to discover all models. Recommended for providers with large
+ catalogs.
+
+
+ ) : null}
+
+
+
+
+
+
+
Models
+
+ Select models to make available for this provider.
+
+
+
+
+ {allFilteredModelsEnabled ? "Deselect All" : "Select All"}
+
+ discoverModels.mutate(connection.id)}
+ disabled={discoverModels.isPending}
+ aria-label={`Refresh ${providerLabel} models`}
+ >
+
+
+
+
+
+
+ setManualModelId(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ addModel();
+ }
+ }}
+ placeholder="Add a model ID manually"
+ />
+
+ Add model
+
+
+
+ {connection.models.length > 0 ? (
+
+
+ Filter models
+
+ {MODEL_CAPABILITY_FILTERS.map((filter) => {
+ const count = connection.models.filter((model) =>
+ capability(model, filter.key)
+ ).length;
+ const isActive = modelFilter === filter.key;
+
+ return (
+ setModelFilter(isActive ? null : filter.key)}
+ >
+ {filter.label}
+ {count}
+
+ );
+ })}
+
+ ) : null}
+
+
+ {connection.models.length === 0 ? (
+
+ No models yet. Use the refresh button to discover models or add one
+ manually.
+
+ ) : null}
+ {filteredModels.length === 0 && modelFilter ? (
+
+ No{" "}
+ {MODEL_CAPABILITY_FILTERS.find(
+ (filter) => filter.key === modelFilter
+ )?.label.toLowerCase()}{" "}
+ models found on this connection.
+
+ ) : null}
+
+ {filteredModels.map((model) => (
+
+
+ updateModel.mutate({
+ id: model.id,
+ data: { enabled: checked === true },
+ })
+ }
+ disabled={updateModel.isPending}
+ />
+
+
+ {modelLabel(model)}
+ {model.source === "MANUAL" ? (
+
+ manual
+
+ ) : null}
+
+
+ {["chat", "vision", "image_gen"]
+ .filter((key) =>
+ capability(model, key as "chat" | "vision" | "image_gen")
+ )
+ .join(", ") || "No discovered capabilities"}
+
+
+
+ ))}
+
+
+
+
+ {connection.last_status && connection.last_status !== "OK" ? (
+
+ {connection.last_error || "Could not list models."} Chat may still work; add
+ model IDs manually if discovery is unavailable.
+
+ ) : null}
+
-
- {["chat", "vision", "image_gen"]
- .filter((key) => capability(model, key as "chat" | "vision" | "image_gen"))
- .join(", ") || "No discovered capabilities"}
-
-
-
-
testModel.mutate(model.id)}>
- Test
-
+
+
+ verifyConnection.mutate(connection.id)}
+ disabled={verifyConnection.isPending}
+ >
+ Test
+
+
+ Update
+
+
+
+
+
+
- updateModel.mutate({ id: model.id, data: { enabled: !model.enabled } })
- }
+ variant="ghost"
+ size="icon"
+ disabled={deleteConnection.isPending}
+ aria-label={`Delete ${providerLabel}`}
>
- {model.enabled ? "Enabled" : "Enable"}
+
-
-
- ))}
+
+
+
+ Delete this provider?
+
+ {providerLabel} and all of
+ its models will be removed from this search space. This cannot be undone.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+
);
@@ -394,19 +688,13 @@ export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: num
Base URL
- setBaseUrl(event.target.value)}
+ onChange={setBaseUrl}
placeholder={
isOllama ? "http://host.docker.internal:11434" : "https://api.example.com/v1"
}
- list="model-conn-url-suggestions"
/>
-
- {URL_SUGGESTIONS.map((url) => (
-
- ))}
-
{isOllama ? "API Key (optional)" : "API Key"}
@@ -425,7 +713,7 @@ export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: num
Boolean(selectedProvider?.base_url_required && !baseUrl.trim())
}
>
-
Add
+ Add
@@ -439,11 +727,17 @@ export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: num
-
- {connections.map((connection) => (
-
- ))}
-
+ {connections.length > 0 ? (
+
+
+
Available Providers
+
+ {connections.map((connection) => (
+
+ ))}
+
+
+ ) : null}
diff --git a/surfsense_web/contracts/types/model-connections.types.ts b/surfsense_web/contracts/types/model-connections.types.ts
index a34687d74..c75f4c90a 100644
--- a/surfsense_web/contracts/types/model-connections.types.ts
+++ b/surfsense_web/contracts/types/model-connections.types.ts
@@ -26,6 +26,7 @@ export const connectionRead = z.object({
id: z.number(),
provider: z.string(),
base_url: z.string().nullable().optional(),
+ api_key: z.string().nullable().optional(),
extra: z.record(z.string(), z.any()).default({}),
scope: z.union([connectionScopeEnum, z.string()]),
search_space_id: z.number().nullable().optional(),
@@ -73,6 +74,11 @@ export const modelUpdateRequest = z.object({
capabilities_override: z.record(z.string(), z.any()).optional(),
});
+export const modelsBulkUpdateRequest = z.object({
+ model_ids: z.array(z.number()).min(1).max(1000),
+ enabled: z.boolean(),
+});
+
export const verifyConnectionResponse = z.object({
status: z.string(),
ok: z.boolean(),
@@ -107,6 +113,7 @@ export type ConnectionCreateRequest = z.infer
;
export type ConnectionUpdateRequest = z.infer;
export type ModelCreateRequest = z.infer;
export type ModelUpdateRequest = z.infer;
+export type ModelsBulkUpdateRequest = z.infer;
export type ModelRoles = z.infer;
export type VerifyConnectionResponse = z.infer;
export type ModelProviderRead = z.infer;
diff --git a/surfsense_web/lib/apis/model-connections-api.service.ts b/surfsense_web/lib/apis/model-connections-api.service.ts
index 12ad8e0d2..bd5aa1309 100644
--- a/surfsense_web/lib/apis/model-connections-api.service.ts
+++ b/surfsense_web/lib/apis/model-connections-api.service.ts
@@ -10,12 +10,14 @@ import {
type ModelProviderRead,
type ModelRead,
type ModelRoles,
+ type ModelsBulkUpdateRequest,
type ModelUpdateRequest,
modelCreateRequest,
- modelProviderListResponse,
modelListResponse,
+ modelProviderListResponse,
modelRead,
modelRoles,
+ modelsBulkUpdateRequest,
modelUpdateRequest,
type VerifyConnectionResponse,
verifyConnectionResponse,
@@ -97,6 +99,25 @@ class ModelConnectionsApiService {
});
};
+ bulkUpdateModels = async (
+ connectionId: number,
+ request: ModelsBulkUpdateRequest
+ ): Promise => {
+ const parsed = modelsBulkUpdateRequest.safeParse(request);
+ if (!parsed.success) {
+ throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
+ }
+ return baseApiService.request(
+ `/api/v1/model-connections/${connectionId}/models`,
+ modelListResponse,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: parsed.data,
+ }
+ );
+ };
+
testModel = async (id: number): Promise => {
return baseApiService.post(`/api/v1/models/${id}/test`, verifyConnectionResponse);
};