Files
open-webui/src/lib/components/AddConnectionModal.svelte
T
Classic298 e5b24a22d0 fix: model ID whitelists accepting duplicate entries (#28251)
Adding a model ID that was already on the list in the connection settings modal simply appended it again, so the same model could sit in the whitelist any number of times. The arena model modal had the same flaw, its dropdown kept offering models that were already selected.

The connection modal now rejects a duplicate with a toast and trims the input first; surrounding whitespace renders invisibly in the list, so an untrimmed ID would slip past the duplicate check and still show up as a visually identical row. The arena modal instead filters already-added models out of the dropdown, matching the existing model selector in the admin settings, so a duplicate can no longer be picked at all. Both modals also drop duplicates when loading a stored list, so configs that already contain them are cleaned on their next save.

Until such a config is re-saved, one residual effect of old data remains: a duplicated ID in an arena model's stored list keeps double weight in the random model draw. New duplicates can no longer be created through the UI.

Fixes #28249
2026-08-10 19:27:12 -06:00

798 lines
22 KiB
Svelte

<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { verifyOpenAIConnection } from '$lib/apis/openai';
import { verifyOllamaConnection } from '$lib/apis/ollama';
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Tags from './common/Tags.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Textarea from './common/Textarea.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
export let show = false;
export let edit = false;
export let ollama = false;
export let direct = false;
export let connection = null;
let url = '';
let key = '';
let auth_type = 'bearer';
let connectionType = 'external';
let provider = '';
$: azure =
provider === 'azure' ||
((url.includes('azure.') || url.includes('cognitive.microsoft.com')) &&
!direct &&
provider === '' &&
!/\/openai\/v1(\/|$)/.test(url));
let prefixId = '';
let enable = true;
let apiVersion = '';
let apiType = ''; // '' = chat completions (default), 'responses' = Responses API
let headers = '';
let passthroughParams = '';
let tags = [];
let modelId = '';
let modelIds = [];
let loading = false;
let showDeleteConfirmDialog = false;
let showAdvanced = false;
const inputClass =
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const selectClass =
'bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const parsePassthroughParams = (value: string) =>
value
.split(',')
.map((param) => param.trim())
.filter(Boolean);
const verifyOllamaHandler = async () => {
// remove trailing slash from url
url = url.replace(/\/$/, '');
const res = await verifyOllamaConnection(localStorage.token, {
url,
key
}).catch((error) => {
toast.error(`${error}`);
});
if (res) {
toast.success($i18n.t('Server connection verified'));
}
};
const verifyOpenAIHandler = async () => {
// remove trailing slash from url
url = url.replace(/\/$/, '');
let _headers = null;
if (headers) {
try {
_headers = JSON.parse(headers);
if (typeof _headers !== 'object' || Array.isArray(_headers)) {
_headers = null;
throw new Error('Headers must be a valid JSON object');
}
headers = JSON.stringify(_headers, null, 2);
} catch (error) {
toast.error($i18n.t('Headers must be a valid JSON object'));
return;
}
}
const res = await verifyOpenAIConnection(
localStorage.token,
{
url,
key,
config: {
auth_type,
...(provider ? { provider } : {}),
...(azure ? { azure: true } : {}),
api_version: apiVersion,
passthrough_params: parsePassthroughParams(passthroughParams),
...(_headers ? { headers: _headers } : {})
}
},
direct
).catch((error) => {
toast.error(`${error}`);
});
if (res) {
toast.success($i18n.t('Server connection verified'));
}
};
const verifyHandler = () => {
if (ollama) {
verifyOllamaHandler();
} else {
verifyOpenAIHandler();
}
};
const addModelHandler = () => {
const newModelId = modelId.trim();
if (!newModelId) {
return;
}
if (modelIds.includes(newModelId)) {
toast.error($i18n.t('Model ID is already added'));
return;
}
modelIds = [...modelIds, newModelId];
modelId = '';
};
const submitHandler = async () => {
loading = true;
if (!ollama && !url) {
loading = false;
toast.error($i18n.t('URL is required'));
return;
}
if (azure) {
if (!apiVersion) {
loading = false;
showAdvanced = true;
toast.error($i18n.t('API Version is required'));
return;
}
if (!key && !['azure_ad', 'microsoft_entra_id'].includes(auth_type)) {
loading = false;
toast.error($i18n.t('Key is required'));
return;
}
if (modelIds.length === 0) {
loading = false;
showAdvanced = true;
toast.error($i18n.t('Deployment names are required for Azure OpenAI'));
return;
}
}
if (headers) {
try {
const _headers = JSON.parse(headers);
if (typeof _headers !== 'object' || Array.isArray(_headers)) {
throw new Error('Headers must be a valid JSON object');
}
headers = JSON.stringify(_headers, null, 2);
} catch (error) {
toast.error($i18n.t('Headers must be a valid JSON object'));
return;
}
}
// remove trailing slash from url
url = url.replace(/\/$/, '');
const connection = {
url,
key,
config: {
enable: enable,
tags: tags,
prefix_id: prefixId,
model_ids: modelIds,
connection_type: connectionType,
auth_type,
headers: headers ? JSON.parse(headers) : undefined,
passthrough_params: parsePassthroughParams(passthroughParams),
...(provider ? { provider } : {}),
...(!ollama && azure ? { azure: true } : {}),
...(azure ? { api_version: apiVersion } : {}),
...(apiType ? { api_type: apiType } : {})
}
};
await onSubmit(connection);
loading = false;
show = false;
url = '';
key = '';
auth_type = 'bearer';
prefixId = '';
passthroughParams = '';
showAdvanced = false;
tags = [];
modelIds = [];
};
const init = () => {
if (connection) {
url = connection.url;
key = connection.key;
auth_type = connection.config.auth_type ?? 'bearer';
headers = connection.config?.headers
? JSON.stringify(connection.config.headers, null, 2)
: '';
enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
passthroughParams = Array.isArray(connection.config?.passthrough_params)
? connection.config.passthrough_params.join(', ')
: (connection.config?.passthrough_params ?? '');
modelIds = [...new Set(connection.config?.model_ids ?? [])];
if (ollama) {
connectionType = connection.config?.connection_type ?? 'local';
} else {
connectionType = connection.config?.connection_type ?? 'external';
provider = connection.config?.provider ?? (connection.config?.azure ? 'azure' : '');
apiVersion = connection.config?.api_version ?? '';
apiType = connection.config?.api_type ?? '';
}
}
};
$: if (show) {
init();
}
onMount(() => {
init();
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-1.5">
<h1 class="text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Connection')}
{:else}
{$i18n.t('Add Connection')}
{/if}
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close modal')}
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1">
{#if !direct}
<div class="flex gap-2">
<div class="flex w-full justify-between items-center">
<div class=" text-xs text-gray-500">{$i18n.t('Connection Type')}</div>
<div class="">
<button
on:click={() => {
connectionType = connectionType === 'local' ? 'external' : 'local';
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if connectionType === 'local'}
{$i18n.t('Local')}
{:else}
{$i18n.t('External')}
{/if}
</button>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-1.5">
<div class="flex flex-col w-full">
<label
for="url-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('URL')}</label
>
<div class="flex-1">
<input
id="url-input"
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
autocomplete="off"
list={ollama ? undefined : 'suggestions'}
required
/>
{#if !ollama}
<datalist id="suggestions">
<option value="https://api.openai.com/v1" />
<option value="https://api.anthropic.com/v1" />
<option value="https://generativelanguage.googleapis.com/v1beta/openai" />
<option value="https://api.mistral.ai/v1" />
<option value="https://api.groq.com/openai/v1" />
<option value="https://openrouter.ai/api/v1" />
<option value="https://api.x.ai/v1" />
</datalist>
{/if}
</div>
</div>
<Tooltip content={$i18n.t('Verify Connection')} className="self-end -mb-1">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}
type="button"
aria-label={$i18n.t('Verify Connection')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<div class="flex flex-col shrink-0 self-end">
<label class="sr-only" for="toggle-connection"
>{$i18n.t('Toggle whether current connection is active.')}</label
>
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch id="toggle-connection" bind:state={enable} />
</Tooltip>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label for="select-bearer-or-session" class={`text-xs text-gray-500`}
>{$i18n.t('Auth')}</label
>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm ${selectClass}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
{#if !ollama}
<option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option>
<option value="microsoft_entra_id">{$i18n.t('Entra ID')}</option>
{/if}
{/if}
</select>
</div>
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Uses DefaultAzureCredential to authenticate')}
</div>
{/if}
</div>
</div>
</div>
</div>
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-1">
<label
for="api-type-toggle"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('API Type')}</label
>
<div>
<button
on:click={() => {
apiType = apiType === 'responses' ? '' : 'responses';
}}
type="button"
id="api-type-toggle"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if apiType === 'responses'}
{$i18n.t('Responses')}
{:else}
{$i18n.t('Chat Completions')}
{/if}
</button>
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<button
type="button"
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
on:click={() => (showAdvanced = !showAdvanced)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Advanced')}
</button>
</div>
{#if showAdvanced}
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
{/if}
{#if !ollama && !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="allowed-passthrough-params-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Passthrough params')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Comma-separated top-level request parameters this upstream may receive without translation. Use * to allow all captured passthrough params.'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="allowed-passthrough-params-input"
bind:value={passthroughParams}
placeholder={$i18n.t('thinking, output_config')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Prefix ID')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="prefix-id-input"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="provider-select"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Provider')}</label
>
<div>
<select
id="provider-select"
bind:value={provider}
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
>
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
<option value="litellm">{$i18n.t('LiteLLM')}</option>
</select>
</div>
</div>
{/if}
{#if azure}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="api-version-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('API Version')}</label
>
<div class="flex-1">
<input
id="api-version-input"
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
autocomplete="off"
required
/>
</div>
</div>
</div>
{/if}
<div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between">
<div
class={`mb-0.5 text-xs text-gray-500
`}
>
{$i18n.t('Model IDs')}
</div>
</div>
{#if modelIds.length > 0}
<ul class="flex flex-col">
{#each modelIds as modelId, modelIdx}
<li class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 py-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
MODELID: modelId
})}
type="button"
on:click={() => {
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</li>
{/each}
</ul>
{:else}
<div
class={`text-gray-500 text-xs text-center py-2 px-10
`}
>
{#if ollama}
{$i18n.t(
'Leave empty to include all models from "{{url}}/api/tags" endpoint',
{
url: url
}
)}
{:else if azure}
{$i18n.t('Deployment names are required for Azure OpenAI')}
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
url: `${url}/openai/deployments`
})} -->
{:else}
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
})}
{/if}
</div>
{/if}
</div>
<div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
bind:value={modelId}
id="add-model-id-input"
placeholder={$i18n.t('Add a model ID')}
/>
<div>
<button
type="button"
aria-label={$i18n.t('Add')}
on:click={() => {
addModelHandler();
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
{/if}
</div>
<div class="flex justify-between items-center pt-3 text-sm font-medium">
<div>
{#if edit}
<button
class="px-1 py-1.5 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:underline transition"
type="button"
on:click={() => {
showDeleteConfirmDialog = true;
}}
>
{$i18n.t('Delete')}
</button>
{/if}
</div>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<span class="shrink-0">
<Spinner />
</span>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
message={$i18n.t(
'Are you sure you want to delete this connection? This action cannot be undone.'
)}
confirmLabel={$i18n.t('Delete')}
on:confirm={() => {
onDelete();
show = false;
}}
/>