mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-13 01:02:25 -06:00
refac
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user