This commit is contained in:
Timothy Jaeryang Baek
2026-08-13 00:07:31 -06:00
parent 3813fd4cdc
commit 85c3d0ae2f
10 changed files with 897 additions and 21 deletions
+261 -2
View File
@@ -246,9 +246,48 @@ router = APIRouter()
LLAMACPP_LOADED_STATES = {'loaded', 'sleeping'}
LLAMACPP_UNLOADED_STATES = {'loading', 'unloaded'}
MODEL_MANAGEMENT_ENDPOINTS = {
'llama.cpp': {
'list': '/models',
'download': '/models',
'delete': '/models',
'load': '/models/load',
'unload': '/models/unload',
'sse': '/models/sse',
},
'lmstudio': {
'list': '/api/v1/models',
'download': '/api/v1/models/download',
'download_status': '/api/v1/models/download/status/{job_id}',
'load': '/api/v1/models/load',
'unload': '/api/v1/models/unload',
},
}
def get_llamacpp_model_loaded_state(model: dict, provider: str, manual_model_ids: bool = False) -> bool | None:
def get_model_management_root_url(url: str, provider: str) -> str:
root_url = url.rstrip('/')
if provider in ('llama.cpp', 'lmstudio'):
for suffix in ('/api/v1', '/api/v0', '/v1'):
if root_url.endswith(suffix):
return root_url.removesuffix(suffix)
return root_url
def get_provider_model_loaded_state(model: dict, provider: str, manual_model_ids: bool = False) -> bool | None:
if provider == 'lmstudio':
if model.get('loaded_instances'):
return True
state = model.get('state')
if state == 'loaded':
return True
if state == 'not-loaded':
return False
return None
if provider != 'llama.cpp':
return None
@@ -307,6 +346,111 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
return url, key, api_config
async def clear_openai_model_cache(request: Request):
await get_all_models.cache.clear()
request.app.state.BASE_MODELS = []
request.app.state.OPENAI_MODELS = {}
models = getattr(request.app.state, 'MODELS', None)
if hasattr(models, 'clear'):
models.clear()
else:
request.app.state.MODELS = {}
async def get_model_management_connection(url_idx: int) -> tuple[str, str, dict, str]:
if not await Config.get('openai.enable'):
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
try:
url, key, api_config = await get_openai_connection(url_idx)
except IndexError:
raise HTTPException(status_code=404, detail='Connection not found')
provider = api_config.get('provider', '')
if provider not in MODEL_MANAGEMENT_ENDPOINTS:
raise HTTPException(
status_code=400,
detail=f'Provider "{provider or "default"}" does not support model management',
)
return get_model_management_root_url(url, provider), key, api_config, provider
def get_model_management_path(provider: str, operation: str, path_params: dict | None = None) -> str:
try:
path = MODEL_MANAGEMENT_ENDPOINTS[provider][operation]
except KeyError:
raise HTTPException(status_code=400, detail=f'Provider "{provider}" does not support {operation}')
return path.format(**(path_params or {}))
def get_model_management_payload(provider: str, operation: str, payload: dict | None) -> dict | None:
if provider == 'lmstudio' and operation == 'unload' and payload:
return {'instance_id': payload.get('instance_id') or payload.get('model')}
return payload
async def send_model_management_request(
request: Request,
url_idx: int,
operation: str,
method: str = 'GET',
payload: dict | None = None,
query: dict | None = None,
path_params: dict | None = None,
stream: bool = False,
user: UserModel | None = None,
):
root_url, key, api_config, provider = await get_model_management_connection(url_idx)
path = get_model_management_path(provider, operation, path_params=path_params)
payload = get_model_management_payload(provider, operation, payload)
headers, cookies = await get_headers_and_cookies(request, root_url, key, api_config, user=user)
response = None
streaming = False
try:
session = await get_session()
response = await session.request(
method,
f'{root_url}{path}',
json=payload,
params=query,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=get_client_timeout(stream=stream),
)
if not response.ok:
try:
error = await response.json(loads=JSONCodec.loads)
except Exception:
error = await response.text()
raise HTTPException(status_code=response.status, detail=error)
if stream:
streaming = True
return StreamingResponse(
stream_wrapper(response, passthrough=True),
status_code=response.status,
headers=_clean_proxy_headers(response.headers),
)
try:
return await response.json(loads=JSONCodec.loads)
except Exception:
return {'success': True}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=response.status if response else 500, detail=str(e))
finally:
if not streaming:
await cleanup_response(response)
async def get_anthropic_token_count_target(request: Request, form_data: dict, user: UserModel):
"""Resolve the upstream LiteLLM connection for an Anthropic token-count request."""
requested_model = form_data.get('model')
@@ -697,7 +841,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
'urlIdx': idx,
}
loaded = get_llamacpp_model_loaded_state(
loaded = get_provider_model_loaded_state(
model,
provider,
manual_model_ids=bool(api_config.get('model_ids')),
@@ -793,6 +937,121 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
return models
class ProviderModelOperationForm(BaseModel):
model: str
model_config = ConfigDict(extra='allow')
@router.get('/models/{url_idx}/catalog')
async def get_provider_model_catalog(request: Request, url_idx: int, user=Depends(get_admin_user)):
return await send_model_management_request(request, url_idx, 'list', user=user)
@router.post('/models/{url_idx}/download')
async def download_provider_model(
request: Request,
url_idx: int,
form_data: ProviderModelOperationForm,
user=Depends(get_admin_user),
):
root_url, _, api_config, provider = await get_model_management_connection(url_idx)
payload = form_data.model_dump(exclude_none=True)
payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id'))
result = await send_model_management_request(request, url_idx, 'download', 'POST', payload, user=user)
await clear_openai_model_cache(request)
await publish_event(
request,
EVENTS.MODEL_PROVIDER_MODEL_CREATED,
actor=user,
subject_id=payload['model'],
data={'provider': provider, 'url_idx': url_idx, 'base_url': root_url},
)
return result
@router.get('/models/{url_idx}/download/status/{job_id}')
async def get_provider_model_download_status(
request: Request,
url_idx: int,
job_id: str,
user=Depends(get_admin_user),
):
return await send_model_management_request(
request,
url_idx,
'download_status',
path_params={'job_id': job_id},
user=user,
)
@router.post('/models/{url_idx}/load')
async def load_provider_model(
request: Request,
url_idx: int,
form_data: ProviderModelOperationForm,
user=Depends(get_admin_user),
):
_, _, api_config, _ = await get_model_management_connection(url_idx)
payload = form_data.model_dump(exclude_none=True)
payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id'))
result = await send_model_management_request(request, url_idx, 'load', 'POST', payload, user=user)
await clear_openai_model_cache(request)
return result
@router.post('/models/{url_idx}/unload')
async def unload_provider_model(
request: Request,
url_idx: int,
form_data: ProviderModelOperationForm,
user=Depends(get_admin_user),
):
_, _, api_config, _ = await get_model_management_connection(url_idx)
payload = form_data.model_dump(exclude_none=True)
payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id'))
result = await send_model_management_request(request, url_idx, 'unload', 'POST', payload, user=user)
await clear_openai_model_cache(request)
return result
@router.get('/models/{url_idx}/sse')
async def stream_provider_model_events(request: Request, url_idx: int, user=Depends(get_admin_user)):
return await send_model_management_request(request, url_idx, 'sse', stream=True, user=user)
@router.delete('/models/{url_idx}')
async def delete_provider_model(
request: Request,
url_idx: int,
model: str,
user=Depends(get_admin_user),
):
root_url, _, api_config, provider = await get_model_management_connection(url_idx)
actual_model = strip_provider_model_prefix(model, api_config.get('prefix_id'))
result = await send_model_management_request(
request,
url_idx,
'delete',
'DELETE',
query={'model': actual_model},
user=user,
)
await clear_openai_model_cache(request)
await publish_event(
request,
EVENTS.MODEL_PROVIDER_MODEL_DELETED,
actor=user,
subject_id=actual_model,
data={'provider': provider, 'url_idx': url_idx, 'base_url': root_url},
)
return result
class ConnectionVerificationForm(BaseModel):
url: str
key: str
+4
View File
@@ -71,6 +71,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
'evaluation.arena.models',
'models.default_metadata',
)
if refresh:
await openai.get_all_models.cache.clear()
await ollama.get_all_models.cache.clear()
if (
request.app.state.MODELS
and request.app.state.BASE_MODELS
+177 -1
View File
@@ -131,9 +131,185 @@ export const getOpenAIModels = async (token: string, urlIdx?: number) => {
return res;
};
export const getProviderModelCatalog = async (token: string, urlIdx: number) => {
let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/catalog`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err?.detail ?? err?.error?.message ?? 'Server connection failed';
return null;
});
if (error) {
throw error;
}
return res;
};
export const downloadProviderModel = async (token: string, urlIdx: number, model: string) => {
let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/download`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ model })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err?.detail ?? err?.error?.message ?? 'Server connection failed';
return null;
});
if (error) {
throw error;
}
return res;
};
export const getProviderModelDownloadStatus = async (token: string, urlIdx: number, jobId: string) => {
let error = null;
const res = await fetch(
`${OPENAI_API_BASE_URL}/models/${urlIdx}/download/status/${encodeURIComponent(jobId)}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err?.detail ?? err?.error?.message ?? 'Server connection failed';
return null;
});
if (error) {
throw error;
}
return res;
};
export const loadProviderModel = async (token: string, urlIdx: number, model: string) => {
let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/load`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ model })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err?.detail ?? err?.error?.message ?? 'Server connection failed';
return null;
});
if (error) {
throw error;
}
return res;
};
export const unloadProviderModel = async (
token: string,
urlIdx: number,
model: string,
instanceId?: string
) => {
let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/unload`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ model, ...(instanceId ? { instance_id: instanceId } : {}) })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err?.detail ?? err?.error?.message ?? 'Server connection failed';
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteProviderModel = async (token: string, urlIdx: number, model: string) => {
let error = null;
const res = await fetch(
`${OPENAI_API_BASE_URL}/models/${urlIdx}?${new URLSearchParams({ model })}`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err?.detail ?? err?.error?.message ?? 'Server connection failed';
return null;
});
if (error) {
throw error;
}
return res;
};
export const verifyOpenAIConnection = async (
token: string = '',
connection: dict = {},
connection: Record<string, any> = {},
direct: boolean = false
) => {
const { url, key, config } = connection;
@@ -606,6 +606,7 @@
<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="lmstudio">{$i18n.t('LM Studio')}</option>
<option value="litellm">{$i18n.t('LiteLLM')}</option>
</select>
</div>
@@ -14,6 +14,7 @@
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowPath from '$lib/components/icons/ArrowPath.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import OpenAIConnection from './Connections/OpenAIConnection.svelte';
@@ -50,6 +51,7 @@
let pipelineUrls: Record<string, boolean> = {};
let showAddOpenAIConnectionModal = false;
let showAddOllamaConnectionModal = false;
let modelListRefreshing = false;
const updateOpenAIHandler = async () => {
if (ENABLE_OPENAI_API !== null) {
@@ -120,6 +122,19 @@
}
};
const refreshModelListHandler = async () => {
modelListRefreshing = true;
try {
await models.set(await getModels());
toast.success($i18n.t('Model list refreshed'));
} catch (error) {
toast.error(`${error}`);
} finally {
modelListRefreshing = false;
}
};
const addOpenAIConnectionHandler = async (connection: any) => {
OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, connection.url];
OPENAI_API_KEYS = [...OPENAI_API_KEYS, connection.key];
@@ -373,13 +388,33 @@
)}
let:labelId
>
<Switch
bind:state={connectionsConfig.ENABLE_BASE_MODELS_CACHE}
on:change={async () => {
updateConnectionsHandler();
}}
ariaLabelledbyId={labelId}
/>
<div class="flex items-center gap-1.5">
{#if connectionsConfig.ENABLE_BASE_MODELS_CACHE}
<Tooltip content={$i18n.t('Refresh')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-gray-300"
type="button"
disabled={modelListRefreshing}
on:click={refreshModelListHandler}
aria-label={$i18n.t('Refresh')}
>
{#if modelListRefreshing}
<Spinner className="size-3.5" />
{:else}
<ArrowPath className="size-4" />
{/if}
</button>
</Tooltip>
{/if}
<Switch
bind:state={connectionsConfig.ENABLE_BASE_MODELS_CACHE}
on:change={async () => {
updateConnectionsHandler();
}}
ariaLabelledbyId={labelId}
/>
</div>
</AdminSettingRow>
</AdminSettingSection>
{:else}
@@ -0,0 +1,56 @@
<script lang="ts">
import { getContext } from 'svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import ManageProviderModels from './ManageProviderModels.svelte';
const i18n: any = getContext('i18n');
export let connections: { idx: number; url: string; provider?: string }[] = [];
let selectedUrlIdx = `${connections[0]?.idx ?? 0}`;
const getProviderLabel = (provider = '') => {
if (provider === 'lmstudio') return $i18n.t('LM Studio');
if (provider === 'llama.cpp') return $i18n.t('llama.cpp');
return provider;
};
const providerSupportsDelete = (provider = '') => provider === 'llama.cpp';
$: if (
connections.length > 0 &&
!connections.some((connection) => `${connection.idx}` === selectedUrlIdx)
) {
selectedUrlIdx = `${connections[0].idx}`;
}
$: selectedConnection =
connections.find((connection) => `${connection.idx}` === selectedUrlIdx) ?? connections[0];
</script>
{#if connections.length > 0}
<div class="mb-2 text-sm font-normal">{$i18n.t('Model providers')}</div>
<div class="mb-2.5 flex-1">
<SettingsSelect
bind:value={selectedUrlIdx}
className="w-full"
placeholder={$i18n.t('Select an instance')}
>
{#each connections as connection}
<option value={`${connection.idx}`}>
{getProviderLabel(connection.provider)} - {connection.url}
</option>
{/each}
</SettingsSelect>
</div>
<div>
<ManageProviderModels
urlIdx={Number(selectedUrlIdx)}
provider={selectedConnection?.provider ?? ''}
providerLabel={getProviderLabel(selectedConnection?.provider)}
supportsDelete={providerSupportsDelete(selectedConnection?.provider)}
/>
</div>
{/if}
@@ -0,0 +1,295 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
import {
deleteProviderModel,
downloadProviderModel,
getProviderModelCatalog,
loadProviderModel,
unloadProviderModel
} from '$lib/apis/openai';
import { getModels } from '$lib/apis';
import { config, models, settings } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Download from '$lib/components/icons/Download.svelte';
import Play from '$lib/components/icons/Play.svelte';
import Refresh from '$lib/components/icons/Refresh.svelte';
import Trash from '$lib/components/icons/Trash.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n: any = getContext('i18n');
export let urlIdx: number;
type ProviderModel = {
id?: string;
key?: string;
name?: string;
model?: string;
display_name?: string;
status?: string | { value?: string };
path?: string;
size?: number;
loaded_instances?: { id?: string }[];
[key: string]: unknown;
};
let loading = true;
let actionModel = '';
let modelRef = '';
let modelToDelete = '';
let showDeleteConfirm = false;
export let provider = '';
export let providerLabel = '';
export let supportsDelete = false;
let providerModels: ProviderModel[] = [];
const inputClass =
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2.5 text-left text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 disabled:opacity-50 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:focus:border-blue-500';
const iconButtonClass =
'inline-flex h-7 items-center justify-center rounded-lg border border-gray-100/50 bg-gray-50/40 px-2.5 text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]';
const getModelId = (model: ProviderModel) => model.key ?? model.id ?? model.name ?? model.model ?? '';
const getDisplayName = (model: ProviderModel) => model.display_name ?? getModelId(model);
const getUnloadId = (model: ProviderModel) => model.loaded_instances?.[0]?.id ?? getModelId(model);
const getStatus = (model: ProviderModel) => {
if (model.loaded_instances?.length) {
return 'loaded';
}
if (provider === 'lmstudio') {
return 'unloaded';
}
const status = model.status;
if (typeof status === 'string') return status;
return status?.value ?? 'available';
};
const getStatusClass = (status: string) => {
if (status === 'loaded' || status === 'sleeping') {
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
if (status === 'loading' || status === 'downloading') {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300';
}
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300';
};
const normalizeModels = (response: any): ProviderModel[] => {
const entries = Array.isArray(response)
? response
: (response?.models ?? response?.data ?? response?.items ?? []);
return entries
.map((model: ProviderModel | string) =>
typeof model === 'string' ? { id: model, name: model } : model
)
.filter((model: ProviderModel) => getModelId(model) !== '')
.sort((a: ProviderModel, b: ProviderModel) => getModelId(a).localeCompare(getModelId(b)));
};
const refreshModels = async () => {
loading = true;
const res = await getProviderModelCatalog(localStorage.token, urlIdx).catch((error) => {
toast.error(`${error}`);
return null;
});
providerModels = normalizeModels(res);
loading = false;
};
const refreshGlobalModels = async () => {
await models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections ? ($settings?.directConnections ?? null) : null
)
);
};
const runModelAction = async (
modelId: string,
action: (token: string, urlIdx: number, model: string) => Promise<unknown>,
successMessage: string
) => {
actionModel = modelId;
const res = await action(localStorage.token, urlIdx, modelId).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success(successMessage);
await refreshModels();
await refreshGlobalModels();
}
actionModel = '';
};
const downloadModelHandler = async () => {
const model = modelRef.trim();
if (!model) return;
await runModelAction(
model,
downloadProviderModel,
$i18n.t('Model download started')
);
modelRef = '';
};
const loadModelHandler = async (model: string) => {
await runModelAction(model, loadProviderModel, $i18n.t('Model loaded successfully'));
};
const unloadModelHandler = async (model: ProviderModel) => {
const modelId = getModelId(model);
const instanceId = getUnloadId(model);
actionModel = modelId;
const res = await unloadProviderModel(localStorage.token, urlIdx, modelId, instanceId).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (res) {
toast.success($i18n.t('Model unloaded successfully'));
await refreshModels();
await refreshGlobalModels();
}
actionModel = '';
};
const deleteModelHandler = async () => {
await runModelAction(modelToDelete, deleteProviderModel, $i18n.t('Model deleted successfully'));
modelToDelete = '';
};
$: if (urlIdx !== undefined) {
refreshModels();
}
onMount(refreshModels);
</script>
<ConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete Model')}
message={$i18n.t('This will delete the cached model and cannot be undone.')}
onConfirm={deleteModelHandler}
/>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div class="text-sm font-normal">{providerLabel || provider}</div>
<Tooltip content={$i18n.t('Refresh')}>
<button class={iconButtonClass} type="button" on:click={refreshModels} disabled={loading}>
<Refresh className="size-4" />
</button>
</Tooltip>
</div>
<form class="flex gap-1.5" on:submit|preventDefault={downloadModelHandler}>
<input
class={inputClass}
type="text"
bind:value={modelRef}
placeholder={$i18n.t('Type a model ref')}
autocomplete="off"
/>
<Tooltip content={$i18n.t('Download Model')}>
<button class={iconButtonClass} type="submit" disabled={actionModel !== '' || modelRef.trim() === ''}>
<Download className="size-4" />
</button>
</Tooltip>
</form>
{#if loading}
<div class="py-5">
<Spinner />
</div>
{:else if providerModels.length === 0}
<div class="py-5 text-center text-xs text-gray-400">
{$i18n.t('No models found')}
</div>
{:else}
<div class="max-h-96 overflow-y-auto rounded-lg border border-gray-100/50 dark:border-white/[0.04]">
{#each providerModels as model}
{@const modelId = getModelId(model)}
{@const displayName = getDisplayName(model)}
{@const status = getStatus(model)}
<div
class="flex items-center justify-between gap-2 border-b border-gray-100/50 px-2 py-2 last:border-b-0 dark:border-white/[0.04]"
>
<div class="min-w-0 flex-1">
<div class="truncate text-xs font-medium text-gray-700 dark:text-gray-200">
{displayName}
</div>
{#if displayName !== modelId}
<div class="truncate text-[0.65rem] text-gray-400">{modelId}</div>
{/if}
<div class="mt-1 flex items-center gap-1.5">
<span class="rounded-full px-1.5 py-0.5 text-[0.65rem] {getStatusClass(status)}">
{status}
</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Tooltip content={$i18n.t('Load Model')}>
<button
class={iconButtonClass}
type="button"
aria-label={$i18n.t('Load Model')}
disabled={actionModel !== '' || status === 'loaded' || status === 'loading'}
on:click={() => loadModelHandler(modelId)}
>
<Play className="size-4" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Unload Model')}>
<button
class={iconButtonClass}
type="button"
aria-label={$i18n.t('Unload Model')}
disabled={actionModel !== '' || status === 'unloaded'}
on:click={() => unloadModelHandler(model)}
>
<XMark className="size-4" />
</button>
</Tooltip>
{#if supportsDelete}
<Tooltip content={$i18n.t('Delete Model')}>
<button
class={iconButtonClass}
type="button"
aria-label={$i18n.t('Delete Model')}
disabled={actionModel !== ''}
on:click={() => {
modelToDelete = modelId;
showDeleteConfirm = true;
}}
>
<Trash className="size-4" />
</button>
</Tooltip>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -1,38 +1,72 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
<script lang="ts">
import { getContext, onMount } from 'svelte';
const i18n: any = getContext('i18n');
import { user } from '$lib/stores';
import XMark from '$lib/components/icons/XMark.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import ManageOllama from './Manage/ManageOllama.svelte';
import { getOllamaConfig } from '$lib/apis/ollama';
import { getOpenAIConfig } from '$lib/apis/openai';
import Spinner from '$lib/components/common/Spinner.svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import ManageMultipleOllama from './Manage/ManageMultipleOllama.svelte';
import ManageMultipleProviderModels from './Manage/ManageMultipleProviderModels.svelte';
export let show = false;
let selected = null;
let ollamaConfig = null;
type ProviderConnection = { idx: number; url: string; provider: string; config: Record<string, any> };
const MANAGEMENT_PROVIDERS = new Set(['llama.cpp', 'lmstudio']);
let selected: '' | 'ollama' | 'provider' | null = null;
let ollamaConfig: any = null;
let providerConnections: ProviderConnection[] = [];
$: hasOllamaManagement =
ollamaConfig?.ENABLE_OLLAMA_API && (ollamaConfig?.OLLAMA_BASE_URLS ?? []).length > 0;
$: hasProviderManagement = providerConnections.length > 0;
onMount(async () => {
if ($user?.role === 'admin') {
let openaiConfig: any = null;
await Promise.all([
(async () => {
ollamaConfig = await getOllamaConfig(localStorage.token);
})(),
(async () => {
openaiConfig = await getOpenAIConfig(localStorage.token);
})()
]);
if (ollamaConfig) {
providerConnections =
openaiConfig?.ENABLE_OPENAI_API
? (openaiConfig.OPENAI_API_BASE_URLS ?? [])
.map((url: string, idx: number) => ({
idx,
url,
provider:
(openaiConfig.OPENAI_API_CONFIGS?.[idx] ??
openaiConfig.OPENAI_API_CONFIGS?.[String(idx)] ??
openaiConfig.OPENAI_API_CONFIGS?.[url] ??
{})?.provider ?? '',
config:
openaiConfig.OPENAI_API_CONFIGS?.[idx] ??
openaiConfig.OPENAI_API_CONFIGS?.[String(idx)] ??
openaiConfig.OPENAI_API_CONFIGS?.[url] ??
{}
}))
.filter((connection: ProviderConnection) => MANAGEMENT_PROVIDERS.has(connection.provider))
: [];
const hasOllama = ollamaConfig?.ENABLE_OLLAMA_API && (ollamaConfig?.OLLAMA_BASE_URLS ?? []).length > 0;
const hasProvider = providerConnections.length > 0;
if (hasOllama) {
selected = 'ollama';
return;
}
selected = '';
selected = hasProvider ? 'provider' : '';
}
});
</script>
@@ -64,8 +98,22 @@
{:else if selected !== null}
<div class=" flex w-full flex-col">
<div class=" px-1.5 py-1">
{#if hasOllamaManagement && hasProviderManagement}
<div class="mb-2">
<SettingsSelect
bind:value={selected}
className="w-full"
placeholder={$i18n.t('Select an engine')}
>
<option value="ollama">{$i18n.t('Ollama')}</option>
<option value="provider">{$i18n.t('Model providers')}</option>
</SettingsSelect>
</div>
{/if}
{#if selected === 'ollama'}
<ManageMultipleOllama {ollamaConfig} />
{:else if selected === 'provider'}
<ManageMultipleProviderModels connections={providerConnections} />
{/if}
</div>
</div>
@@ -1716,6 +1716,7 @@
"Model Parameters": "",
"Model Params": "",
"Model Permissions": "",
"Model list refreshed": "",
"Model removed from pinned models": "",
"Model removed from selected models": "",
"Model Response Mode": "",
@@ -1719,6 +1719,7 @@
"Model Parameters": "",
"Model Params": "",
"Model Permissions": "",
"Model list refreshed": "",
"Model removed from pinned models": "",
"Model removed from selected models": "",
"Model Response Mode": "",