ui : show every provider's models in the manager

The table grows a section per enabled provider, each with its own mark,
error and loading state, and a provider filter with live repo counts.
Rows gain their capability gates back, the draft column with its
use-as-draft action, and the quant badge names the provider on its rows.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-10-01 16:51:16 +02:00
parent 50d3e086db
commit d789d02899
39 changed files with 1105 additions and 247 deletions
@@ -7,12 +7,13 @@
import * as Card from '$lib/components/ui/card';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip';
import { BackendProtocol } from '$lib/constants';
import { backendsModelsStore } from '$lib/stores/backendsModels.svelte';
import type { Backend, BackendProtocol } from '$lib/types';
import type { Backend } from '$lib/types';
const PROTOCOL_LABELS: Record<BackendProtocol, string> = {
'llama.cpp': 'Llama-compatible',
openai: 'OpenAI-compatible'
[BackendProtocol.COMPAT]: 'Llama-compatible',
[BackendProtocol.OPENAI]: 'OpenAI-compatible'
};
const CARD_ICON_CLASS = 'h-5 w-5';
@@ -3,12 +3,16 @@
import * as Collapsible from '$lib/components/ui/collapsible';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { DEFAULT_BACKEND_CHAT_PATH, DEFAULT_BACKEND_MODELS_PATH } from '$lib/constants';
import type { Backend, BackendProtocol } from '$lib/types';
import {
BackendProtocol,
DEFAULT_BACKEND_CHAT_PATH,
DEFAULT_BACKEND_MODELS_PATH
} from '$lib/constants';
import type { Backend } from '$lib/types';
const PROTOCOL_OPTIONS: Array<{ label: string; value: BackendProtocol }> = [
{ label: 'OpenAI-compatible', value: 'openai' },
{ label: 'Llama-compatible (llama-server)', value: 'llama.cpp' }
{ label: 'OpenAI-compatible', value: BackendProtocol.OPENAI },
{ label: 'Llama-compatible (llama-server)', value: BackendProtocol.COMPAT }
];
interface Props {
@@ -41,13 +45,13 @@
let detectionLabel = $derived.by(() => {
if (detecting) return 'Checking what the endpoint speaks...';
if (detected === 'llama.cpp') {
if (detected === BackendProtocol.COMPAT) {
return apiKeyRequired
? 'llama.cpp server detected. It asks for an API key.'
: 'llama.cpp server detected.';
}
if (detected === 'openai') return 'OpenAI-compatible endpoint detected.';
if (detected === BackendProtocol.OPENAI) return 'OpenAI-compatible endpoint detected.';
return null;
});
@@ -8,13 +8,14 @@
import {
BACKEND_ID_PREFIX,
BACKEND_PRESETS,
BackendProtocol,
DISMISSED_RECOMMENDED_BACKENDS_LOCALSTORAGE_KEY
} from '$lib/constants';
import { BooleanString } from '$lib/enums';
import { BackendsService } from '$lib/services';
import type { BackendTestResult } from '$lib/services/backends.service';
import { backendsStore } from '$lib/stores';
import type { Backend, BackendPreset, BackendProtocol } from '$lib/types';
import type { Backend, BackendPreset } from '$lib/types';
import { findBackendPreset, uuid } from '$lib/utils';
import { untrack } from 'svelte';
@@ -28,7 +29,7 @@
let {
backend = null,
defaultProtocol = 'openai',
defaultProtocol = BackendProtocol.OPENAI,
onOpenChange,
onSaved,
open = $bindable(false)
@@ -1,6 +1,14 @@
<script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { conversationsStore, deviceStore, modelsStore, serverStore } from '$lib/stores';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import {
backendsModelsStore,
backendsStore,
conversationsStore,
deviceStore,
modelsStore,
serverStore
} from '$lib/stores';
import { getConversationModel } from '$lib/utils';
interface Props {
@@ -28,7 +36,14 @@
}: Props = $props();
let isRouter = $derived(serverStore.isRouterMode);
let isOffline = $derived(!!serverStore.error);
// the provider this selector is pointed at, which is the one its colours report
let selectorError = $derived.by(() => {
const backendId = backendsStore.active.id;
return backendId === LOCAL_BACKEND_ID
? Boolean(serverStore.error)
: backendsModelsStore.get(backendId).error !== null;
});
let conversationModel = $derived(
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
@@ -52,10 +67,16 @@
$effect(() => {
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
if (modelsStore.models.some((m) => m.model === conversationModel)) {
const option = modelsStore.models.find((m) => m.model === conversationModel);
// only sync models served by the active backend; a model from another
// backend must not yank the active tab (and trigger a full backend
// switch) just because the conversation used it. sends resolve their
// backend explicitly via ensureModelBackend
if (option && option.backendId === backendsStore.active.id) {
modelsStore.selectedModelName = conversationModel;
modelsStore.selectModelByName(conversationModel);
} else {
} else if (!option) {
modelsStore.selectedModelName = null;
modelsStore.clearSelection();
}
@@ -155,7 +176,8 @@
<ModelsSelectorSheet
bind:this={selectorModelRef}
currentModel={selectorModel}
disabled={disabled || isOffline}
{disabled}
error={selectorError}
{forceForegroundText}
{useGlobalSelection}
/>
@@ -163,7 +185,8 @@
<ModelsSelectorDropdown
bind:this={selectorModelRef}
currentModel={selectorModel}
disabled={disabled || isOffline}
{disabled}
error={selectorError}
{forceForegroundText}
{useGlobalSelection}
/>
@@ -2,10 +2,17 @@
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { serverStore } from '$lib/stores';
import { useBackendAvailability } from '$lib/hooks/use-backend-availability.svelte';
import { backendsStore, serverStore } from '$lib/stores';
const availability = useBackendAvailability();
let hasError = $derived(!!serverStore.error);
let isLoadingModel = $derived(serverStore.status === 503);
// A single provider leaves nowhere to switch to, so the banner is the only
// place the failure can be reported. With another provider available the
// selector carries it instead, and stays usable.
let isOnlyProvider = $derived(backendsStore.enabled.length <= 1);
let hasError = $derived(availability.isOffline && isOnlyProvider);
</script>
{#if hasError || isLoadingModel}
@@ -5,7 +5,7 @@
import ModelsManagerModelProviders from '$lib/components/app/models/ModelsManager/ModelsManagerModelProviders.svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { MODEL_ICON } from '$lib/constants';
import { MODEL_ICON, MODELS_DIALOG_VIEW, type ModelsDialogView } from '$lib/constants';
import { uiStore } from '$lib/stores';
import { untrack } from 'svelte';
@@ -16,15 +16,13 @@
let { onOpenChange, open = $bindable(false) }: Props = $props();
type View = 'discover' | 'manage' | 'providers';
/** How long a view takes to fade out before the next one fades in. */
const VIEW_FADE_MS = 150;
// what the user asked for, and what is actually rendered: a view change fades the
// current one out, swaps, then fades the next one in
let view = $state<View>('manage');
let shownView = $state<View>('manage');
let view = $state<ModelsDialogView>(MODELS_DIALOG_VIEW.MANAGE);
let shownView = $state<ModelsDialogView>(MODELS_DIALOG_VIEW.MANAGE);
let isSwapping = $state(false);
$effect(() => {
@@ -52,7 +50,11 @@
});
let title = $derived(
view === 'discover' ? 'Discover' : view === 'providers' ? 'Providers' : 'Models'
view === MODELS_DIALOG_VIEW.DISCOVER
? 'Discover'
: view === MODELS_DIALOG_VIEW.PROVIDERS
? 'Providers'
: 'Models'
);
// the sidebar's Discover entry opens this dialog on its Discover view
@@ -61,7 +63,7 @@
untrack(() => {
uiStore.discoverModelsOpen = false;
view = 'discover';
view = MODELS_DIALOG_VIEW.DISCOVER;
handleOpenChange(true);
});
});
@@ -87,10 +89,10 @@
<Button
aria-label="Back to models"
class="absolute top-1/2 left-1 h-7 w-7 -translate-y-1/2 transition-[opacity,visibility] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
'manage'
MODELS_DIALOG_VIEW.MANAGE
? 'invisible opacity-0'
: 'visible opacity-100'}"
onclick={() => (view = 'manage')}
onclick={() => (view = MODELS_DIALOG_VIEW.MANAGE)}
size="icon"
variant="ghost"
>
@@ -99,7 +101,7 @@
<Dialog.Title
class="flex min-h-7 items-center gap-2 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
'manage'
MODELS_DIALOG_VIEW.MANAGE
? 'translate-x-0'
: 'translate-x-8'}"
>
@@ -107,21 +109,21 @@
<span class="grid h-5 w-5 shrink-0 place-items-center">
<MODEL_ICON
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
'manage'
MODELS_DIALOG_VIEW.MANAGE
? 'opacity-100'
: 'opacity-0'}"
/>
<Compass
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
'discover'
MODELS_DIALOG_VIEW.DISCOVER
? 'opacity-100'
: 'opacity-0'}"
/>
<Server
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
'providers'
MODELS_DIALOG_VIEW.PROVIDERS
? 'opacity-100'
: 'opacity-0'}"
/>
@@ -132,12 +134,12 @@
</Dialog.Header>
<div class="dialog-view min-h-0 flex-1" data-visible={!isSwapping}>
{#if shownView === 'manage'}
{#if shownView === MODELS_DIALOG_VIEW.MANAGE}
<ModelsManager class="h-full">
{#snippet toolbarEnd()}
<Button
class="gap-1.5"
onclick={() => (view = 'discover')}
onclick={() => (view = MODELS_DIALOG_VIEW.DISCOVER)}
size="sm"
variant="tertiary"
>
@@ -148,7 +150,7 @@
<Button
class="gap-1.5"
onclick={() => (view = 'providers')}
onclick={() => (view = MODELS_DIALOG_VIEW.PROVIDERS)}
size="sm"
variant="tertiary"
>
@@ -158,7 +160,7 @@
</Button>
{/snippet}
</ModelsManager>
{:else if shownView === 'discover'}
{:else if shownView === MODELS_DIALOG_VIEW.DISCOVER}
<div class="grid h-full overflow-hidden" style="grid-template-columns: auto 1fr;">
<ModelsDiscover />
</div>
@@ -1,9 +1,13 @@
<script lang="ts">
import ModelOrgAvatar from './ModelOrgAvatar.svelte';
import { HF_BASE_MODEL_TAG_REGEX } from '$lib/constants';
import { BackendIcon } from '$lib/components/app/backends';
import { Logo } from '$lib/components/app/misc';
import { HF_BASE_MODEL_TAG_REGEX, LOCAL_BACKEND_ID, MODEL_ICON } from '$lib/constants';
import { HuggingFaceService, ModelsService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
import { nearViewport, orgOf } from '$lib/utils';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
import type { Snippet } from 'svelte';
interface Props {
@@ -38,6 +42,12 @@
let parsedId = $derived(ModelsService.parseModelId(option.model));
let orgName = $derived(parsedId.orgName);
// a llama-compat model whose id carries no `org/name` is not a Hugging Face repo,
// so the provider's own mark identifies it better than an initial
let isLlamaCompat = $derived(getBackendCapabilities(getBackend(option.backendId)).props);
let useProviderIcon = $derived(isLlamaCompat && !orgName);
// the bundled server has no favicon to resolve, its mark is the llama.cpp logo
let isLocal = $derived(getBackend(option.backendId)?.id === LOCAL_BACKEND_ID);
// Avatars come from the Hub, so with the metadata setting off they are
// hidden entirely and no base-model lookup runs.
let hubEnabled = $derived(HuggingFaceService.isEnabled());
@@ -59,6 +69,12 @@
if (!hubEnabled) return;
// external provider ids (`~openai/gpt-...`, `deepseek/deepseek-chat`) are
// not HF repos; their org is already the provider slug
const backend = getBackend(option.backendId);
if (backend && !getBackendCapabilities(backend).props) return;
let cancelled = false;
void HuggingFaceService.getBaseModel(option.model)
@@ -74,7 +90,19 @@
});
</script>
{#if orgName && hubEnabled}
{#if useProviderIcon}
<span class={['inline-flex shrink-0', className]}>
<BackendIcon backend={getBackend(option.backendId)} class={size}>
{#snippet fallback()}
{#if isLocal}
<Logo class={size} style="--size: 100%" />
{:else}
<MODEL_ICON class={size} />
{/if}
{/snippet}
</BackendIcon>
</span>
{:else if orgName && hubEnabled}
<span
use:nearViewport={() => (isNearViewport = true)}
class={['inline-flex shrink-0', className]}
@@ -1,5 +1,6 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import { isLocalOption } from './ModelsManager/utils';
import { MODEL_ID } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
@@ -21,7 +22,7 @@
$effect(() => {
template = '';
if (!isNearViewport) return;
if (!isNearViewport || !isLocalOption(option)) return;
// only the chat template says whether tools and reasoning work, and the Hub
// carries it for a local GGUF whether the model is loaded or not
@@ -1,4 +1,5 @@
<script lang="ts">
import { isLocalOption } from './ModelsManager/utils';
import { MODEL_ID } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
@@ -22,7 +23,7 @@
$effect(() => {
fetched = null;
if (reported || !isNearViewport) return;
if (reported || !isNearViewport || !isLocalOption(option)) return;
// a local GGUF carries its trained context in the model metadata
const repo = option.model.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0] ?? '';
@@ -1,7 +1,6 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import ModelDraftSidecars from './ModelDraftSidecars.svelte';
import type { ModelDraft } from './ModelsManager/utils';
import { Database, ScrollText } from '@lucide/svelte';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -38,10 +37,8 @@
contextLength?: number;
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
sizeRange?: { min: number; max: number } | null;
/** Draft sidecars available for the model, badged with their own quant. */
/** Draft sidecars available for the model, badged as `+ [KIND] [QUANT]`. */
draftSidecars?: ModelSidecarBadge[];
/** Drafts a load would use, rendered as `+ [KIND] [QUANT]` next to the id. */
drafts?: ModelDraft[];
/** Allow badges to wrap onto new lines instead of truncating. */
wrap?: boolean;
class?: string;
@@ -51,7 +48,6 @@
aliases,
class: className = '',
contextLength,
drafts = [],
draftSidecars = [],
hideCapabilities = false,
hideModalities = false,
@@ -94,7 +90,6 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
let uniqueDraftSidecars = $derived(draftSidecars.filter((badge) => !isAuxSidecar(badge.kind)));
let activeDrafts = $derived(drafts.filter((draft) => draft.active && draft.kind));
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
@@ -102,7 +97,6 @@
let hasBadges = $derived(
parsed.sidecar ||
uniqueDraftSidecars.length > 0 ||
activeDrafts.length > 0 ||
(parsed.params && !hideParameters) ||
(parsed.quantization && !resolvedHideQuantization) ||
primaryAlias ||
@@ -145,18 +139,6 @@
</span>
{/if}
{#each activeDrafts as draft (draft.kind ?? 'draft')}
<span class="flex shrink-0 items-center gap-1">
<span class="text-[10px] text-muted-foreground">+</span>
<span class={variantBadgeClass} title="Speculative draft in use">{draft.kind}</span>
{#if draft.quant}
<span class={badgeClass}>{draft.quant}</span>
{/if}
</span>
{/each}
<ModelDraftSidecars {draftSidecars} />
{#if primaryAlias}
@@ -1,10 +1,14 @@
<script lang="ts">
import { ActionIcon } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { ICON_CLASS_DEFAULT, MODEL_LOAD_ICONS } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import { backendsModelsStore, modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
interface Props {
/** Backend can load and unload models, llama-compat servers only. */
canLoad: boolean;
class?: string;
isFailed?: boolean;
isLoaded: boolean;
@@ -15,9 +19,14 @@
revealOnHover?: boolean;
/** Renders the state alone, for a caller that moves load and unload elsewhere. */
showAction?: boolean;
/** Non-loadable rows show the provider mark, which identifies them in a flat list. */
showBackendMark?: boolean;
/** Table rows mark a remote provider, which this UI cannot load or unload. */
showRemoteMark?: boolean;
}
let {
canLoad,
class: className = '',
isFailed = false,
isLoaded,
@@ -25,12 +34,35 @@
isSleeping = false,
option,
revealOnHover = true,
showAction = true
showAction = true,
showBackendMark = false,
showRemoteMark = false
}: Props = $props();
let backendName = $derived(getBackend(option.backendId)?.name ?? 'Remote provider');
/** The provider's listing failed, so nothing it serves is selectable right now. */
let isBackendFailed = $derived(
Boolean(option.backendId && backendsModelsStore.get(option.backendId).error)
);
</script>
<div class={['flex w-5 shrink-0 items-center justify-center', className]}>
{#if isLoading}
{#if !canLoad}
{#if showBackendMark}
<BackendIcon backend={getBackend(option.backendId)} class="h-3.5 w-3.5" />
{:else if showRemoteMark}
<span
class="flex items-center gap-1 {isBackendFailed ? 'opacity-50 grayscale' : ''}"
title={isBackendFailed ? `${backendName} is unavailable` : backendName}
>
<BackendIcon backend={getBackend(option.backendId)} class="h-3.5 w-3.5" />
{#if isBackendFailed}
<MODEL_LOAD_ICONS.failed class="h-3 w-3 text-destructive" />
{/if}
</span>
{/if}
{:else if isLoading}
<MODEL_LOAD_ICONS.loading class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
{:else}
<!-- the state dot is what the row shows at rest; the action takes its place on hover -->
@@ -3,22 +3,31 @@
import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
import {
groupModelQuants,
loadExtraArgs,
loadOverrides,
modelContextLength,
modelDraftBadges,
type ModelOverride,
type ModelQuantGroup,
type ModelsTableGroup,
modelSupports
modelSupports,
saveOverrides
} from './utils';
import {
LOCAL_BACKEND_ID,
type ModalityKey,
MODELS_TABLE_GROUP_LABELS,
ModelsTableGroupKind
ModelsTableGroupKind,
ModelsTableProviderKind
} from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { conversationsStore, modelsStore, uiStore } from '$lib/stores';
import { backendsStore, conversationsStore, modelsStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
import { type Snippet, untrack } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
interface Props {
class?: string;
@@ -29,10 +38,13 @@
let { class: className, toolbarEnd }: Props = $props();
let filter = $state('');
let providerFilter = $state<string[]>([]);
let contextLimit = $state(0);
let modalityFilter = $state<ModalityKey[]>([]);
let capabilityFilter = $state<ModelCapability[]>([]);
let draftFilter = $state(false);
let selectedId = $state<string | null>(null);
let overrides = $state<Record<string, ModelOverride>>(loadOverrides());
let allModels = $derived(modelsStore.models);
@@ -100,6 +112,8 @@
let isFavorite = $derived((option: ModelOption) =>
modelsStore.favoriteModelIds.has(option.model)
);
// every filter but the provider one, so a provider count does not fall to zero
// the moment that provider is the one being looked at
let matching = $derived.by(() => {
const term = filter.trim().toLowerCase();
@@ -115,6 +129,13 @@
return false;
}
if (
draftFilter &&
modelDraftBadges(option, overrides[option.id]?.load?.speculativeDecoding).length === 0
) {
return false;
}
// a model whose modalities are unknown cannot be shown to match
if (modalityFilter.length > 0 && !modalityFilter.some((key) => option.modalities?.[key])) {
return false;
@@ -123,6 +144,24 @@
return contextLimit === 0 || modelContextLength(option) >= contextLimit;
});
});
let visible = $derived.by(() =>
providerFilter.length === 0
? matching
: matching.filter((option) => providerFilter.includes(option.backendId ?? LOCAL_BACKEND_ID))
);
// the rail counts follow the active view and filter, so it always says how many
// repos each provider contributes to what the table is showing
let providerCounts = $derived.by(() => {
const counts: Record<string, number> = {};
for (const entry of groupModelQuants(matching)) {
const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID;
counts[backendId] = (counts[backendId] ?? 0) + 1;
}
return counts;
});
// recently used models lead their section, the rest keep the server's order
let rank = $derived.by(() => {
@@ -137,11 +176,15 @@
Math.min(...entry.quants.map((quant) => rank.get(quant.id) ?? Number.MAX_SAFE_INTEGER));
const byRecency = (list: ModelQuantGroup[]) =>
rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b));
// one entry per repo, so a model with several quants takes a single table row
let entries = $derived(byRecency(groupModelQuants(matching)));
// one entry per repo, so a model with several quants takes a single table row;
// loaded models lead the table, then favorites, then one block per provider
let entries = $derived(byRecency(groupModelQuants(visible)));
let groups = $derived.by(() => {
// a loaded quant is a model of its own: its repo keeps the quants left behind
const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model);
// a loaded quant is a model of its own: its repo keeps the quants left behind.
// Only llama-compat servers report a load state.
const isLoaded = (option: ModelOption) =>
getBackendCapabilities(getBackend(option.backendId)).loadUnload &&
modelsStore.isModelLoaded(option.model);
const loaded: ModelQuantGroup[] = [];
const rest: ModelQuantGroup[] = [];
@@ -168,23 +211,58 @@
(entry) => !claimed.has(entry.key) && entry.quants.some((q) => modelsStore.isHidden(q.id))
);
const hiddenKeys = new SvelteSet(hidden.map((entry) => entry.key));
const local = rest.filter((entry) => !claimed.has(entry.key) && !hiddenKeys.has(entry.key));
const byBackend = new SvelteMap<string, ModelQuantGroup[]>();
for (const entry of rest) {
if (claimed.has(entry.key) || hiddenKeys.has(entry.key)) continue;
const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID;
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
byBackend.get(backendId)!.push(entry);
}
const ordered: ModelsTableGroup[] = [];
// loaded models lead the table, then favorites, then the local block
const pushSection = (kind: ModelsTableGroupKind, items: ModelQuantGroup[]): void => {
// loaded models lead the table, then favorites, then one block per backend
const pushSection = (
kind: ModelsTableGroup['kind'],
items: ModelQuantGroup[],
provider?: { id: string; label: string }
): void => {
if (items.length === 0) return;
const isLocal = kind === ModelsTableGroupKind.LOCAL;
ordered.push({
backendId: provider?.id ?? (isLocal ? LOCAL_BACKEND_ID : null),
isLocal,
items,
key: kind === ModelsTableGroupKind.LOCAL ? LOCAL_BACKEND_ID : kind,
key: provider?.id ?? (isLocal ? LOCAL_BACKEND_ID : kind),
kind,
label: MODELS_TABLE_GROUP_LABELS[kind]
label: provider?.label ?? MODELS_TABLE_GROUP_LABELS[kind as ModelsTableGroupKind]
});
};
pushSection(ModelsTableGroupKind.LOADED, loaded);
pushSection(ModelsTableGroupKind.FAVORITES, favorites);
pushSection(ModelsTableGroupKind.LOCAL, local);
const localItems = byBackend.get(LOCAL_BACKEND_ID);
if (localItems?.length) pushSection(ModelsTableGroupKind.LOCAL, localItems);
for (const backend of backendsStore.enabled) {
if (backend.id === LOCAL_BACKEND_ID) continue;
const items = byBackend.get(backend.id);
if (items?.length)
pushSection(ModelsTableProviderKind.PROVIDER, items, {
id: backend.id,
label: backend.name
});
}
pushSection(ModelsTableGroupKind.HIDDEN, hidden);
return ordered;
@@ -209,7 +287,7 @@
return;
}
await modelsStore.status.load(option.model);
await modelsStore.status.load(option.model, loadExtraArgs(overrides[option.id]));
}
async function useInNewChat(option: ModelOption): Promise<void> {
@@ -218,6 +296,24 @@
// the chat is behind the dialog, so it takes focus once the dialog is out of the way
uiStore.requestComposerFocus();
}
/** Point the selected model's load settings at another model as its draft. */
function useAsDraft(draft: ModelOption, targetId: string): void {
const target = modelsStore.models.find((option) => option.id === targetId);
if (!target) return;
saveOverride(target, {
...overrides[target.id],
load: { ...overrides[target.id]?.load, speculativeDecoding: draft.id }
});
}
function saveOverride(option: ModelOption, override: ModelOverride): void {
overrides = { ...overrides, [option.id]: override };
saveOverrides(overrides);
toast.success(`Saved settings for ${option.name}`);
}
</script>
<div class={['flex min-h-0 flex-1', className]}>
@@ -225,11 +321,16 @@
<ModelsManagerModelsTable
bind:capabilities={capabilityFilter}
bind:contextLimit
bind:draft={draftFilter}
bind:filter
bind:modalities={modalityFilter}
bind:providers={providerFilter}
{groups}
{isFavorite}
onSelect={(option) => (selectedId = option.id)}
onUseAsDraft={useAsDraft}
{overrides}
{providerCounts}
{selectedId}
{toolbarEnd}
/>
@@ -1,12 +1,17 @@
<script lang="ts">
import { ScrollCarousel } from '$lib/components/app';
import { Check, ChevronDown, Server } from '@lucide/svelte';
import { Logo, ScrollCarousel } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Select from '$lib/components/ui/select';
import { Toggle } from '$lib/components/ui/toggle';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import {
CAPABILITY_ICONS,
CAPABILITY_LABELS,
FILTER_TOGGLE_ITEM_CLASS,
FILTER_TRIGGER_CLASS,
LOCAL_BACKEND_ID,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_LABELS,
@@ -14,20 +19,32 @@
type ModalityKey
} from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import type { Backend } from '$lib/types/backend';
interface Props {
backends: Backend[];
/** Capabilities a model must have every one of. */
capabilities?: ModelCapability[];
/** Smallest context a model must support; 0 keeps every model. */
contextLimit?: number;
/** Keep only models that have a draft sidecar to speculate with. */
draft?: boolean;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
/** Backend ids to keep; empty keeps every provider. */
providers?: string[];
/** Repos each provider contributes to the current search, shown in the menu. */
providerCounts?: Record<string, number>;
}
let {
backends,
capabilities = $bindable<ModelCapability[]>([]),
contextLimit = $bindable(0),
modalities = $bindable<ModalityKey[]>([])
draft = $bindable(false),
modalities = $bindable<ModalityKey[]>([]),
providerCounts = {},
providers = $bindable<string[]>([])
}: Props = $props();
const CONTEXT_STEPS: { label: string; value: number }[] = [
@@ -62,12 +79,83 @@
modalities = values.filter((value): value is ModalityKey => MODALITY_VALUES.has(value));
}
// none selected means every provider, so the label names the selection
let providerLabel = $derived(
providers.length === 0
? 'All providers'
: providers.length === 1
? (backends.find((backend) => backend.id === providers[0])?.name ?? '1 provider')
: `${providers.length} providers`
);
let contextLabel = $derived(
CONTEXT_STEPS.find((step) => step.value === contextLimit)?.label ?? CONTEXT_STEPS[0].label
);
function toggleProvider(id: string, checked: boolean | 'indeterminate'): void {
providers = checked === true ? [...providers, id] : providers.filter((entry) => entry !== id);
}
</script>
{#snippet providerMark(backend: Backend)}
{#if backend.id === LOCAL_BACKEND_ID}
<BackendIcon {backend} class="h-3.5 w-3.5">
{#snippet fallback()}
<Logo class="shrink-0" style="--size: 0.875rem" />
{/snippet}
</BackendIcon>
{:else}
<BackendIcon {backend} class="h-3.5 w-3.5" />
{/if}
{/snippet}
<ScrollCarousel alwaysShowArrows class="min-w-0 flex-1" gapSize="2" innerClass="items-center">
{#if backends.length > 1}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<button
{...props}
class="inline-flex items-center whitespace-nowrap {FILTER_TRIGGER_CLASS}"
type="button"
>
<Server class="h-3.5 w-3.5" />
{providerLabel}
<ChevronDown class="h-3.5 w-3.5 opacity-60" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-48">
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Providers</DropdownMenu.GroupHeading>
{#each backends as backend (backend.id)}
<DropdownMenu.CheckboxItem
checked={providers.includes(backend.id)}
onCheckedChange={(checked) => toggleProvider(backend.id, checked)}
>
{@render providerMark(backend)}
{backend.name}
<DropdownMenu.Shortcut>{providerCounts[backend.id] ?? 0}</DropdownMenu.Shortcut>
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Group>
{#if providers.length > 0}
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => (providers = [])}
>Show every provider</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
<Select.Root
onValueChange={(value) => (contextLimit = Number(value))}
type="single"
@@ -86,6 +174,26 @@
</Select.Content>
</Select.Root>
<!-- a checkbox chip: the whole pill is the control, the box is its indicator -->
<Toggle
bind:pressed={draft}
class="inline-flex items-center whitespace-nowrap {FILTER_TRIGGER_CLASS} px-2!"
variant="outline"
>
<span
aria-hidden="true"
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-shadow {draft
? 'border-border/30 bg-muted-foreground/15 text-foreground dark:border-border/20 dark:bg-muted-foreground/25'
: 'border-input bg-background dark:bg-input/30'}"
>
{#if draft}
<Check class="size-3" />
{/if}
</span>
Has draft sidecar
</Toggle>
<ToggleGroup.Root
class="border border-border/30 bg-muted/60 shadow-sm dark:border-border/20 dark:bg-muted/75"
onValueChange={setToggles}
@@ -2,8 +2,9 @@
import { Plus } from '@lucide/svelte';
import { BackendCard, DialogBackendForm } from '$lib/components/app/backends';
import { Button } from '$lib/components/ui/button';
import { BackendProtocol } from '$lib/constants';
import { backendsModelsStore, backendsStore, serverStore } from '$lib/stores';
import type { Backend, BackendProtocol } from '$lib/types';
import type { Backend } from '$lib/types';
import { fade } from 'svelte/transition';
interface Props {
@@ -20,18 +21,18 @@
: backendsStore.external
);
// the bundled server belongs to the list whenever llama.cpp providers are in it
let showsLocal = $derived(protocol === undefined || protocol === 'llama.cpp');
let showsLocal = $derived(protocol === undefined || protocol === BackendProtocol.COMPAT);
let addTitle = $derived(
protocol === 'openai'
protocol === BackendProtocol.OPENAI
? 'Add a backend'
: protocol === 'llama.cpp'
: protocol === BackendProtocol.COMPAT
? 'Add a Llama-compatible backend'
: 'Add new provider'
);
let addDescription = $derived(
protocol === 'openai'
protocol === BackendProtocol.OPENAI
? 'Connect an OpenAI-compatible endpoint.'
: protocol === 'llama.cpp'
: protocol === BackendProtocol.COMPAT
? 'Point at another llama-server.'
: 'External llama-server or connect an OpenAI-compatible API.'
);
@@ -66,7 +67,7 @@
<DialogBackendForm
bind:open={isAdding}
backend={editing}
defaultProtocol={protocol ?? 'llama.cpp'}
defaultProtocol={protocol ?? BackendProtocol.COMPAT}
onOpenChange={handleOpenChange}
onSaved={() => void backendsModelsStore.loadAll()}
/>
@@ -4,8 +4,8 @@
import ModelContext from '../ModelContext.svelte';
import ModelId from '../ModelId.svelte';
import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
import { modelRowActions } from './row-actions';
import { configuredContext } from './utils';
import { modelRowActions, type ModelRowDraftTarget } from './row-actions';
import { canLoadOption, configuredContext, modelDraftBadges, type ModelOverride } from './utils';
import { MoreHorizontal } from '@lucide/svelte';
import { DropdownMenuActions } from '$lib/components/app';
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
@@ -14,19 +14,37 @@
import type { ModelOption } from '$lib/types/models';
interface Props {
/** Model the pane has open, when this row can be set as its draft. */
draftTarget?: ModelRowDraftTarget | null;
isFavorite: (option: ModelOption) => boolean;
/** Stored per-model overrides, for the drafts and context the row reports. */
overrides?: Record<string, ModelOverride>;
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
selected: boolean;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
let {
draftTarget = null,
indent = 0,
isFavorite,
onDelete,
onSelect,
onUseAsDraft,
option,
overrides,
selected
}: Props = $props();
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
let draftBadges = $derived(
modelDraftBadges(option, overrides?.[option.id]?.load?.speculativeDecoding)
);
function handleKeydown(event: KeyboardEvent): void {
if (event.key === KeyboardKey.SPACE) event.preventDefault();
@@ -59,7 +77,7 @@
<ModelId
aliases={option.aliases}
class="min-w-0 flex-1"
draftSidecars={option.draftSidecars}
draftSidecars={draftBadges}
hideCapabilities
hideModalities
modalities={option.modalities}
@@ -72,13 +90,21 @@
</span>
</span>
<ModelContext class="justify-self-end" configured={configuredContext(option)} {option} />
<ModelContext
class="justify-self-end"
configured={configuredContext(option, overrides)}
{option}
/>
<ModelsManagerStatusCell {option} />
<div class="flex items-center justify-center justify-self-center">
<DropdownMenuActions
actions={modelRowActions(option, favorite, isHidden, onDelete)}
actions={modelRowActions(
option,
{ canLoad: canLoadOption(option), draftTarget, favorite, isHidden },
{ onDelete, onUseAsDraft }
)}
align="end"
triggerIcon={MoreHorizontal}
triggerTooltip="Model actions"
@@ -3,9 +3,11 @@
import ModelsManagerQuantRow from './ModelsManagerQuantRow.svelte';
import ModelsManagerRepoRow from './ModelsManagerRepoRow.svelte';
import ModelsManagerTableToolbar from './ModelsManagerTableToolbar.svelte';
import { type ModelRowDraftTarget } from './row-actions';
import {
isModelRunning,
modelContextLength,
type ModelOverride,
type ModelQuantGroup,
type ModelsTableGroup
} from './utils';
@@ -36,10 +38,11 @@
MODEL_ROW_GRID_CLASS,
MODEL_ROW_WINDOW,
ModelsTableGroupKind,
ModelsTableProviderKind,
ModelsTableSortKey
} from '$lib/constants';
import { KeyboardKey, ModelCapability, ModelDownloadConfirmAction } from '$lib/enums';
import { modelsStore, settingsStore } from '$lib/stores';
import { backendsModelsStore, modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
import type { Snippet } from 'svelte';
@@ -50,12 +53,22 @@
capabilities?: ModelCapability[];
/** Smallest context a model must support; 0 keeps every model. */
contextLimit?: number;
/** Keep only models that have a draft sidecar to speculate with. */
draft?: boolean;
filter?: string;
groups: ModelsTableGroup[];
isFavorite: (option: ModelOption) => boolean;
onSelect: (option: ModelOption) => void;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
/** Per-model load and inference overrides, keyed by backend-qualified id. */
overrides: Record<string, ModelOverride>;
/** Backend ids to keep; empty keeps every provider. */
providers?: string[];
/** Repos each provider contributes to the current search, for the filter menu. */
providerCounts?: Record<string, number>;
/** Called when a row is set as the draft of the selected model. */
onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
selectedId: string | null;
/** Rendered at the toolbar's right end, past the filters. */
toolbarEnd?: Snippet;
@@ -64,17 +77,35 @@
let {
capabilities = $bindable<ModelCapability[]>([]),
contextLimit = $bindable(0),
draft = $bindable(false),
filter = $bindable(''),
groups,
isFavorite,
modalities = $bindable<ModalityKey[]>([]),
onSelect,
onUseAsDraft,
overrides,
providerCounts = {},
providers = $bindable<string[]>([]),
selectedId,
toolbarEnd
}: Props = $props();
let isEmpty = $derived(groups.every((group) => group.items.length === 0));
let hasFilters = $derived(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
let hasFilters = $derived(
providers.length > 0 ||
contextLimit > 0 ||
modalities.length > 0 ||
capabilities.length > 0 ||
draft
);
/** Model the configuration pane has open, when a row can be set as its draft. */
let draftTarget = $derived.by<ModelRowDraftTarget | null>(() => {
const selected = modelsStore.models.find((option) => option.id === selectedId);
return selected ? { id: selected.id, label: selected.name } : null;
});
/** In-flight and paused downloads, tracked by the status feed. */
let downloadEntries = $derived(modelsStore.status.getDownloadEntries());
@@ -226,27 +257,35 @@
expanded={!collapsedQuants.has(entry.key)}
{indent}
onToggle={() => toggleQuants(entry.key)}
{overrides}
/>
<CollapsibleRegion open={!collapsedQuants.has(entry.key)}>
{#each entry.quants as quant (quant.id)}
<ModelsManagerQuantRow
{draftTarget}
indent={indent + 24}
{isFavorite}
onDelete={requestDelete}
{onSelect}
{onUseAsDraft}
option={quant}
{overrides}
selected={selectedId === quant.id}
showProvider={entry.kind === 'providers'}
/>
{/each}
</CollapsibleRegion>
{:else}
<ModelsManagerModelRow
{draftTarget}
{indent}
{isFavorite}
onDelete={requestDelete}
{onSelect}
{onUseAsDraft}
option={entry.base}
{overrides}
selected={selectedId === entry.base.id}
/>
{/if}
@@ -343,8 +382,11 @@
<ModelsManagerTableToolbar
bind:capabilities
bind:contextLimit
bind:draft
bind:filter
bind:modalities
bind:providers
{providerCounts}
{toolbarEnd}
/>
@@ -391,12 +433,19 @@
{/if}
{/snippet}
{@const backendState = group.backendId ? backendsModelsStore.get(group.backendId) : null}
<ModelsSection
backendId={group.kind === ModelsTableProviderKind.PROVIDER
? (group.backendId ?? undefined)
: undefined}
chevronClass="mr-7"
count={group.items.length}
defaultOpen={group.kind !== ModelsTableGroupKind.HIDDEN}
icon={groupIcon}
error={Boolean(backendState?.error)}
icon={group.kind === ModelsTableProviderKind.PROVIDER ? undefined : groupIcon}
label={group.label}
loading={Boolean(backendState?.loading)}
persistKey={group.key}
revealChevronOnHover
sticky
@@ -2,8 +2,8 @@
import ModelContext from '../ModelContext.svelte';
import ModelDraftSidecars from '../ModelDraftSidecars.svelte';
import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
import { modelRowActions } from './row-actions';
import { configuredContext } from './utils';
import { modelRowActions, type ModelRowDraftTarget } from './row-actions';
import { canLoadOption, configuredContext, modelDraftBadges, type ModelOverride } from './utils';
import { MoreHorizontal } from '@lucide/svelte';
import { DropdownMenuActions } from '$lib/components/app';
import { Badge } from '$lib/components/ui/badge';
@@ -11,18 +11,37 @@
import { KeyboardKey } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
interface Props {
/** Model the pane has open, when this row can be set as its draft. */
draftTarget?: ModelRowDraftTarget | null;
isFavorite: (option: ModelOption) => boolean;
/** Stored per-model overrides, for the drafts and context the row reports. */
overrides?: Record<string, ModelOverride>;
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
selected: boolean;
/** Badge the backend name instead of the quant, for a provider group. */
showProvider?: boolean;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
let {
draftTarget = null,
indent = 0,
isFavorite,
onDelete,
onSelect,
onUseAsDraft,
option,
overrides,
selected,
showProvider = false
}: Props = $props();
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
@@ -48,20 +67,30 @@
tabindex="0"
>
<span class="flex min-w-0 items-center gap-3" style="padding-left: {indent}px">
<Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">{quant}</Badge>
<Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">
{showProvider ? (getBackend(option.backendId)?.name ?? quant) : quant}
</Badge>
<ModelDraftSidecars draftSidecars={option.draftSidecars} />
<ModelDraftSidecars draftSidecars={modelDraftBadges(option)} />
<span class="truncate text-sm text-muted-foreground">{option.model}</span>
</span>
<ModelContext class="justify-self-end" configured={configuredContext(option)} {option} />
<ModelContext
class="justify-self-end"
configured={configuredContext(option, overrides)}
{option}
/>
<ModelsManagerStatusCell {option} />
<div class="flex items-center justify-center justify-self-center">
<DropdownMenuActions
actions={modelRowActions(option, favorite, isHidden, onDelete)}
actions={modelRowActions(
option,
{ canLoad: canLoadOption(option), draftTarget, favorite, isHidden },
{ onDelete, onUseAsDraft }
)}
align="end"
triggerIcon={MoreHorizontal}
triggerTooltip="Model actions"
@@ -4,7 +4,7 @@
import ModelContext from '../ModelContext.svelte';
import ModelId from '../ModelId.svelte';
import type { ModelQuantGroup } from './utils';
import { configuredContext, isModelRunning } from './utils';
import { configuredContext, isModelRunning, type ModelOverride } from './utils';
import { ChevronDown, ChevronUp } from '@lucide/svelte';
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
@@ -14,16 +14,21 @@
entry: ModelQuantGroup;
expanded: boolean;
onToggle: () => void;
/** Stored per-model overrides, for the drafts and context the row reports. */
overrides?: Record<string, ModelOverride>;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
let { entry, expanded, indent = 0, onToggle }: Props = $props();
let { entry, expanded, indent = 0, onToggle, overrides }: Props = $props();
let providerCount = $derived(new Set(entry.quants.map((option) => option.backendId ?? '')).size);
let groupLabel = $derived(
entry.kind === 'variants'
? `${entry.quants.length} variants`
: `${entry.quants.length} quants available`
entry.kind === 'providers'
? `${providerCount} provider${providerCount === 1 ? '' : 's'}`
: entry.kind === 'variants'
? `${entry.quants.length} variants`
: `${entry.quants.length} quants available`
);
let anyLoaded = $derived(entry.quants.some((quant) => isModelRunning(quant)));
// a repo row stands for its quants, so it reports what they agree on
@@ -78,7 +83,7 @@
<ModelContext
class="justify-self-end"
configured={configuredContext(contextSource)}
configured={configuredContext(contextSource, overrides)}
option={contextSource}
/>
@@ -1,5 +1,6 @@
<script lang="ts">
import ModelLoadControl from '../ModelLoadControl.svelte';
import { canLoadOption } from './utils';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
@@ -20,10 +21,12 @@
</script>
<ModelLoadControl
canLoad={canLoadOption(option)}
class="justify-self-center {className}"
isFailed={status === ServerModelStatus.FAILED}
{isLoaded}
isLoading={status === ServerModelStatus.LOADING || isOperationInProgress}
isSleeping={status === ServerModelStatus.SLEEPING}
{option}
showRemoteMark
/>
@@ -5,7 +5,7 @@
import { Button } from '$lib/components/ui/button';
import { type ModalityKey } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { uiStore } from '$lib/stores';
import { backendsStore, uiStore } from '$lib/stores';
import type { Snippet } from 'svelte';
interface Props {
@@ -13,9 +13,15 @@
capabilities?: ModelCapability[];
/** Smallest context a model must support; 0 keeps every model. */
contextLimit?: number;
/** Keep only models that have a draft sidecar to speculate with. */
draft?: boolean;
filter?: string;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
/** Repos each provider contributes to the current search, for the filter menu. */
providerCounts?: Record<string, number>;
/** Backend ids to keep; empty keeps every provider. */
providers?: string[];
/** Rendered at the toolbar's right end, past the filters. */
toolbarEnd?: Snippet;
}
@@ -23,12 +29,21 @@
let {
capabilities = $bindable<ModelCapability[]>([]),
contextLimit = $bindable(0),
draft = $bindable(false),
filter = $bindable(''),
modalities = $bindable<ModalityKey[]>([]),
providerCounts = {},
providers = $bindable<string[]>([]),
toolbarEnd
}: Props = $props();
let hasFilters = $derived(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
let hasFilters = $derived(
providers.length > 0 ||
contextLimit > 0 ||
modalities.length > 0 ||
capabilities.length > 0 ||
draft
);
let filterInput = $state<HTMLInputElement | null>(null);
// the dialog hands focus to its first control, so the filter takes it instead
@@ -59,15 +74,25 @@
size="sm"
/>
<ModelsManagerFilters bind:capabilities bind:contextLimit bind:modalities />
<ModelsManagerFilters
bind:capabilities
bind:contextLimit
bind:draft
bind:modalities
bind:providers
backends={backendsStore.enabled}
{providerCounts}
/>
{#if hasFilters}
<Button
class="gap-1.5 text-muted-foreground"
onclick={() => {
providers = [];
contextLimit = 0;
modalities = [];
capabilities = [];
draft = false;
}}
size="sm"
variant="ghost"
@@ -1,27 +1,59 @@
import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
import { Eye, EyeOff, Heart, HeartOff, Trash2, Zap } from '@lucide/svelte';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
/** Model the configuration pane has open, which a row can be set as the draft of. */
export interface ModelRowDraftTarget {
id: string;
label: string;
}
interface RowState {
/** Backend can load and unload the model. */
canLoad: boolean;
/** Model the pane has open, when one is selected. */
draftTarget?: ModelRowDraftTarget | null;
favorite: boolean;
isHidden: boolean;
}
interface RowHandlers {
onDelete: (option: ModelOption) => void;
onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
}
/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
export function modelRowActions(
option: ModelOption,
favorite: boolean,
isHidden: boolean,
onDelete: (option: ModelOption) => void
) {
export function modelRowActions(option: ModelOption, state: RowState, handlers: RowHandlers) {
const { canLoad, draftTarget, favorite, isHidden } = state;
const canBeDraft = canLoad && !!draftTarget && draftTarget.id !== option.id;
return [
...(canBeDraft
? [
{
icon: Zap,
label: `Use as draft for ${draftTarget.label}`,
onclick: () => handlers.onUseAsDraft?.(option, draftTarget.id),
separator: true
}
]
: []),
{
icon: favorite ? HeartOff : Heart,
label: favorite ? 'Remove from favorites' : 'Add to favorites',
onclick: () => modelsStore.toggleFavorite(option.model)
},
{
icon: Trash2,
label: 'Delete from disk',
onclick: () => onDelete(option),
separator: true,
variant: 'destructive' as const
},
...(canLoad
? [
{
icon: Trash2,
label: 'Delete from disk',
onclick: () => handlers.onDelete(option),
separator: true,
variant: 'destructive' as const
}
]
: []),
{
icon: isHidden ? Eye : EyeOff,
label: isHidden ? 'Unhide model' : 'Hide model',
@@ -5,13 +5,19 @@ import {
ModelGroupKind,
type ModelSidecar,
ModelsTableGroupKind,
ModelsTableProviderKind,
SETTINGS_KEYS,
SPEC_TYPE
} from '$lib/constants';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { backendsModelsStore, modelsStore, settingsStore } from '$lib/stores';
import type { ModelLoadProgress, ModelOption, ModelSidecarFile } from '$lib/types/models';
import type {
ModelLoadProgress,
ModelOption,
ModelSidecarBadge,
ModelSidecarFile
} from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
@@ -71,30 +77,10 @@ export interface ModelsTableGroup {
isLocal?: boolean;
key: string;
/** Manager sections use the kind constants, provider blocks their own kinds. */
kind: ModelsTableGroupKind | 'compat' | 'provider';
kind: ModelsTableGroupKind | ModelsTableProviderKind;
label: string;
}
/** Values the load form falls back to when the server reports nothing. */
export const LOAD_DEFAULTS = {
batchSize: 2048,
contextLength: 8192,
cpuThreads: 13,
gpuOffload: 42,
speculativeDecoding: 'off',
ubatchSize: 512
};
export const SAMPLING_DEFAULTS = {
minP: 0.05,
repeatPenalty: 1.1,
temperature: 1,
topK: 64,
topP: 0.95
};
export const SPECULATIVE_OPTIONS = ['off', 'draft-model'];
/** True when the user saved anything for this model. */
export function isCustomized(override?: ModelOverride): boolean {
return override !== undefined && Object.keys(override).length > 0;
@@ -261,6 +247,31 @@ export function modelDrafts(
return drafts;
}
/**
* Draft sidecars of a model, as ModelId badges them: what the store's listing reports
* plus the drafts a load would use, one kind/quant pair per sidecar.
*/
export function modelDraftBadges(
option: ModelOption,
settingValue?: string | null
): ModelSidecarBadge[] {
const badges = [...(option.draftSidecars ?? [])];
for (const draft of modelDraftsFor(option, settingValue)) {
if (!draft.kind) continue;
if (badges.some((badge) => badge.kind === draft.kind && badge.quant === draft.quant)) continue;
badges.push({
kind: draft.kind,
quant: draft.quant,
repo: draft.model ?? repoOf(option.model) ?? ''
});
}
return badges;
}
/**
* Whether a model has a capability. A listing that declares nothing is not a listing
* that lacks it: the chat template the Hub carries says whether tools or reasoning work.
@@ -276,10 +287,25 @@ export function isModelRunning(option: ModelOption): boolean {
}
/** Context the model runs with: what a loaded model reports. */
export function configuredContext(option: ModelOption): number | null {
/** Context the model runs with: the stored override, else what a loaded local model reports. */
export function configuredContext(
option: ModelOption,
overrides?: Record<string, ModelOverride>
): number | null {
const override = overrides?.[option.id]?.load?.contextLength;
if (override) return override;
if (!isLocalOption(option)) return null;
return isModelRunning(option) ? modelsStore.props.getModelContextSize(option.model) : null;
}
/** True when the backend that serves the model can load and unload it. */
export function canLoadOption(option: ModelOption): boolean {
return getBackendCapabilities(getBackend(option.backendId)).loadUnload;
}
export function modelSupports(option: ModelOption, capability: ModelCapability): boolean {
if (option.capabilities.includes(capability)) return true;
@@ -398,7 +424,7 @@ export function groupModelQuants(models: ModelOption[], mergeProviders = false):
// the very same id twice is not a quant set; keep those rows apart, unless
// the group exists to list the providers that serve it
if (
kind !== 'providers' &&
kind !== ModelGroupKind.PROVIDERS &&
modelIds.length > 1 &&
modelIds.every((model) => model === modelIds[0])
) {
@@ -419,7 +445,7 @@ export function groupModelQuants(models: ModelOption[], mergeProviders = false):
function groupKind(quants: ModelOption[]): ModelGroupKind {
const backends = new Set(quants.map((option) => option.backendId ?? ''));
if (backends.size > 1) return 'providers';
if (backends.size > 1) return ModelGroupKind.PROVIDERS;
const isQuant = quants.every(
(option) => (option.parsedId ?? ModelsService.parseModelId(option.model)).quantization
@@ -1,11 +1,15 @@
<script lang="ts">
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
import { CollapsibleSection } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { modelsStore } from '$lib/stores';
import { getBackend } from '$lib/utils/api-base';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
/** Renders the backend's logo in the header. */
backendId?: string;
/** Extra classes for the chevron, to line it up with a row's own control. */
chevronClass?: string;
/** Number shown next to the label, omitted when undefined. */
@@ -29,6 +33,7 @@
}
let {
backendId,
chevronClass = '',
children,
count,
@@ -78,6 +83,8 @@
{#if icon}
{@render icon()}
{:else if backendId}
<BackendIcon backend={getBackend(backendId)} class="h-3.5 w-3.5" />
{/if}
<span class="truncate">{label}</span>
@@ -8,6 +8,7 @@
ModelsSelectorOption,
ModelsSelectorTriggerIcon
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
import type { ModelItem } from '$lib/components/app/navigation/utils';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -23,13 +24,20 @@
import { modelsStore, settingsStore, uiStore } from '$lib/stores';
import type { ModelOption, ModelSidecarBadge } from '$lib/types/models';
import { modelLoadFraction } from '$lib/utils';
import { rawModelId } from '$lib/utils/model-option-id';
interface Props {
class?: string;
currentModel?: string | null;
disabled?: boolean;
/** The provider behind this selector is unreachable. */
error?: boolean;
forceForegroundText?: boolean;
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
onModelChange?: (
modelId: string,
modelName: string,
backendId?: string
) => Promise<boolean> | boolean | void;
useGlobalSelection?: boolean;
}
@@ -37,6 +45,7 @@
class: className = '',
currentModel = null,
disabled = false,
error = false,
forceForegroundText = false,
onModelChange,
useGlobalSelection = false
@@ -44,6 +53,7 @@
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
let showAddBackend = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -117,6 +127,9 @@
for (const group of ms.groupedFilteredOptions.available) {
for (const item of group.items) order.push(item.option.id);
}
for (const provider of ms.groupedFilteredOptions.providers) {
for (const item of provider.items) order.push(item.option.id);
}
return order;
});
@@ -150,6 +163,13 @@
setTimeout(() => uiStore.openModelsManager(), 0);
}
function handleAddBackend() {
isOpen = false;
// let the menu finish closing before the dialog takes focus
setTimeout(() => (showAddBackend = true), 0);
}
// Alt+Enter only unloads and keeps the dropdown open.
async function handleModelKeyAction(modelId: string, unload: boolean) {
if (!unload) {
@@ -158,12 +178,13 @@
return;
}
const model = modelsStore.routerModels.find((m) => m.id === modelId);
const rawId = rawModelId(modelId);
const model = modelsStore.routerModels.find((m) => m.id === rawId);
const status = model?.status?.value as ServerModelStatus | undefined;
if (status === ServerModelStatus.LOADING) return;
await modelsStore.status.unload(modelId);
await modelsStore.status.unload(rawId);
}
export function open() {
@@ -210,7 +231,13 @@
<MODEL_ICON class="h-3.5 w-3.5 shrink-0" />
</span>
{:else}
<span class="text-xs text-muted-foreground">No models yet.</span>
<button
class="cursor-pointer text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={handleAddBackend}
type="button"
>
No models yet. Add a backend to get started.
</button>
{/if}
{:else}
{@const triggerStatus = triggerModel
@@ -234,13 +261,15 @@
{...props}
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-foreground',
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
isOpen && 'text-foreground',
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
]}
@@ -357,6 +386,8 @@
favorites={ms.favoriteItems}
groups={ms.groupedFilteredOptions}
loaded={ms.loadedItems}
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="[&:not(:first-child)]:mt-1 mb-1 px-2 py-2 text-[13px] font-semibold text-foreground/80 select-none"
@@ -430,3 +461,8 @@
{/if}
{/if}
</div>
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -28,6 +28,10 @@
loaded?: ModelItem[];
/** Show the organization name in every model id of the list. */
showOrgName?: boolean;
/** Open one provider's full list, offered when a section is cut short. */
onProviderOpen?: (backendId: string) => void;
/** Leave the drilled-in provider; enables the back affordance. */
onProviderBack?: () => void;
}
let {
@@ -36,6 +40,8 @@
favorites = [],
groups,
loaded = [],
onProviderBack,
onProviderOpen,
onSelect,
renderOption,
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
@@ -218,6 +224,39 @@
</ModelsSection>
{/if}
<!-- One section per remote provider. -->
{#each groups.providers as provider (provider.backendId)}
<ModelsSection
backendId={provider.backendId}
error={Boolean(provider.error)}
label={provider.name}
loading={provider.loading}
onBack={onProviderBack}
persistKey={provider.backendId}
revealChevronOnHover
sticky
>
{#if provider.items.length > 0}
{@render listRows(provider.items, provider.backendId)}
{#if onProviderOpen && provider.matched > provider.items.length}
<!-- same box as a model row, it opens the provider's full list -->
<button
class="flex w-full cursor-pointer items-center gap-2 rounded-sm p-2 text-left text-sm text-muted-foreground transition hover:bg-accent hover:text-foreground focus:outline-none"
onclick={() => onProviderOpen(provider.backendId)}
type="button"
>
+ {provider.matched - provider.items.length} more
</button>
{/if}
{:else if provider.catalog === 0}
<p class="px-4 pb-2 text-xs text-muted-foreground">
{provider.error ?? (provider.loading ? 'Loading models...' : 'No models')}
</p>
{/if}
</ModelsSection>
{/each}
<DialogConfirmDownload
action={ModelDownloadConfirmAction.CANCEL}
onClose={() => (cancelOpen = false)}
@@ -1,5 +1,6 @@
<script lang="ts">
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import { modelDraftBadges } from '../ModelsManager/utils';
import {
ModelAvatar,
ModelCapabilities,
@@ -12,6 +13,8 @@
import { modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
interface Props {
option: ModelOption;
@@ -41,6 +44,9 @@
showRepoOrgAvatar = false
}: Props = $props();
// row actions follow the backend that serves the row, not the selected one
let rowBackend = $derived(getBackend(option.backendId));
let canLoad = $derived(rowBackend ? getBackendCapabilities(rowBackend).loadUnload : false);
let currentRouterModels = $derived(modelsStore.routerModels);
let serverStatus = $derived.by(() => {
const model = currentRouterModels.find((m) => m.id === option.model);
@@ -92,7 +98,7 @@
<ModelId
aliases={option.aliases}
class="min-w-0 flex-1"
draftSidecars={option.draftSidecars}
draftSidecars={modelDraftBadges(option)}
hideCapabilities
hideModalities
{hideOrgName}
@@ -109,7 +115,15 @@
<div class="flex shrink-0 items-center gap-1">
<ModelRowActions {isFav} {option} />
<ModelLoadControl {isFailed} {isLoaded} {isLoading} {isSleeping} {option} />
<ModelLoadControl
{canLoad}
{isFailed}
{isLoaded}
{isLoading}
{isSleeping}
{option}
showBackendMark
/>
</div>
{#if isLoading}
@@ -7,6 +7,7 @@
ModelsSelectorTriggerIcon,
SearchInput
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
import * as Sheet from '$lib/components/ui/sheet';
import { MODEL_ICON, SETTINGS_KEYS } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
@@ -18,8 +19,14 @@
class?: string;
currentModel?: string | null;
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
onModelChange?: (
modelId: string,
modelName: string,
backendId?: string
) => Promise<boolean> | boolean | void;
disabled?: boolean;
/** The provider behind this selector is unreachable. */
error?: boolean;
forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
@@ -29,12 +36,14 @@
class: className = '',
currentModel = null,
disabled = false,
error = false,
forceForegroundText = false,
onModelChange,
useGlobalSelection = false
}: Props = $props();
let sheetOpen = $state(false);
let showAddBackend = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -63,6 +72,13 @@
// let the sheet finish closing before the dialog takes focus
setTimeout(() => uiStore.openModelsManager(), 0);
}
function handleAddBackend() {
sheetOpen = false;
// let the sheet finish closing before the dialog takes focus
setTimeout(() => (showAddBackend = true), 0);
}
</script>
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
@@ -72,7 +88,13 @@
Loading models…
</div>
{:else if ms.options.length === 0 && ms.isMultiModel}
<span class="text-xs text-muted-foreground">No models yet.</span>
<button
class="cursor-pointer text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={handleAddBackend}
type="button"
>
No models yet. Add a backend to get started.
</button>
{:else}
{@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
@@ -91,13 +113,15 @@
<button
class={[
`relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-foreground',
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
sheetOpen && 'text-foreground'
]}
disabled={disabled || ms.updating}
@@ -176,6 +200,8 @@
favorites={ms.favoriteItems}
groups={ms.groupedFilteredOptions}
loaded={ms.loadedItems}
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
{showOrgName}
@@ -200,13 +226,15 @@
<button
class={[
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground'
]}
disabled={disabled || ms.updating}
onclick={() => ms.handleOpenChange(true)}
@@ -228,3 +256,8 @@
{/if}
{/if}
</div>
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -12,9 +12,25 @@ export interface OrgGroup {
items: ModelItem[];
}
/** One remote backend's section on the remote view. */
export interface ProviderGroup {
backendId: string;
/** Models the provider lists without any search filtering. */
catalog: number;
error: string | null;
/** Rows to render, capped by the display limit. */
items: ModelItem[];
loading: boolean;
/** Rows left after the search filter, before the cap. */
matched: number;
name: string;
}
export interface GroupedModelOptions {
available: OrgGroup[];
loaded: ModelItem[];
/** Remote backends, one section each. */
providers: ProviderGroup[];
}
function matchesModality(option: ModelOption, term: string): boolean {
@@ -133,5 +149,48 @@ export function groupModelOptions(
available.push({ items, orgName: orgName || null });
}
return { available, loaded };
return { available, loaded, providers: [] };
}
/**
* Remote backends as sections, one per backend, in the given order. Each section
* keeps at most `limit` rows; `matched` carries the full count so the caller can
* offer the rest.
*/
export function groupProviderOptions(
options: ModelOption[],
providers: {
backendId: string;
catalog: number;
error: string | null;
loading: boolean;
name: string;
}[],
limit = Infinity,
recentIds: readonly string[] = []
): ProviderGroup[] {
const byBackend = new SvelteMap<string, ModelItem[]>();
const rank = new SvelteMap<string, number>();
recentIds.forEach((id, index) => rank.set(id, index));
const rankOf = (id: string) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
// recently used models lead their section, the rest keep the backend's order
const byRecency = (items: ModelItem[]) =>
rank.size === 0 ? items : [...items].sort((a, b) => rankOf(a.option.id) - rankOf(b.option.id));
for (let i = 0; i < options.length; i++) {
const option = options[i];
const backendId = option.backendId ?? '';
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
byBackend.get(backendId)!.push({ flatIndex: i, option });
}
return providers.map((provider) => {
const items = byRecency(byBackend.get(provider.backendId) ?? []);
return { ...provider, items: items.slice(0, limit), matched: items.length };
});
}
+32 -12
View File
@@ -1,15 +1,29 @@
import type {
BackendCapabilities,
BackendCompat,
BackendPreset,
BackendProtocol
} from '$lib/types';
import type { BackendCapabilities, BackendCompat, BackendPreset } from '$lib/types';
/** Request/response shape a backend speaks. */
export const BackendProtocol = {
COMPAT: 'llama.cpp',
OPENAI: 'openai'
} as const;
export type BackendProtocol = (typeof BackendProtocol)[keyof typeof BackendProtocol];
/** Field carrying the output token cap, per OpenAI dialect. */
export const MaxTokensField = {
CHAT_COMPLETION: 'max_completion_tokens',
COMPLETION: 'max_tokens'
} as const;
export type MaxTokensField = (typeof MaxTokensField)[keyof typeof MaxTokensField];
/** Prefix for generated ids of user-added backends. */
export const BACKEND_ID_PREFIX = 'backend';
/** Protocols a configured backend can speak, in display order. */
export const BACKEND_PROTOCOLS: readonly BackendProtocol[] = ['llama.cpp', 'openai'];
export const BACKEND_PROTOCOLS: readonly BackendProtocol[] = [
BackendProtocol.COMPAT,
BackendProtocol.OPENAI
];
/** Chat completions path used when a backend does not override it. */
export const DEFAULT_BACKEND_CHAT_PATH = '/v1/chat/completions';
@@ -45,15 +59,21 @@ const COMPATIBLE_CAPABILITIES: BackendCapabilities = {
/** Capabilities per backend protocol. */
export const BACKEND_CAPABILITIES: Record<BackendProtocol, BackendCapabilities> = {
'llama.cpp': LLAMA_CPP_CAPABILITIES,
openai: COMPATIBLE_CAPABILITIES
[BackendProtocol.COMPAT]: LLAMA_CPP_CAPABILITIES,
[BackendProtocol.OPENAI]: COMPATIBLE_CAPABILITIES
};
/** Default wire quirks per protocol. */
export const BACKEND_COMPAT: Record<BackendProtocol, BackendCompat> = {
// llama-server reports its own timings, so it needs no usage chunk
'llama.cpp': { maxTokensField: 'max_tokens', supportsUsageInStreaming: false },
openai: { maxTokensField: 'max_tokens', supportsUsageInStreaming: true }
[BackendProtocol.COMPAT]: {
maxTokensField: MaxTokensField.COMPLETION,
supportsUsageInStreaming: false
},
[BackendProtocol.OPENAI]: {
maxTokensField: MaxTokensField.COMPLETION,
supportsUsageInStreaming: true
}
};
/**
@@ -84,7 +104,7 @@ export const BACKEND_PRESETS: readonly BackendPreset[] = [
iconUrl: '/backend-presets/huggingface.svg',
id: 'huggingface',
name: 'Hugging Face',
protocol: 'openai'
protocol: BackendProtocol.OPENAI
},
{
baseUrl: 'https://openrouter.ai/api',
@@ -2,6 +2,7 @@
/** What a repo group of the table folds, which decides its label. */
export const ModelGroupKind = {
PROVIDERS: 'providers',
QUANTS: 'quants',
VARIANTS: 'variants'
} as const;
@@ -18,6 +19,15 @@ export const ModelsTableGroupKind = {
export type ModelsTableGroupKind = (typeof ModelsTableGroupKind)[keyof typeof ModelsTableGroupKind];
/** Kinds a provider block carries on top of the manager's own sections. */
export const ModelsTableProviderKind = {
COMPAT: 'compat',
PROVIDER: 'provider'
} as const;
export type ModelsTableProviderKind =
(typeof ModelsTableProviderKind)[keyof typeof ModelsTableProviderKind];
/** Header label of each manager section. */
export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
[ModelsTableGroupKind.FAVORITES]: 'Favorites',
@@ -34,3 +44,12 @@ export const ModelsTableSortKey = {
} as const;
export type ModelsTableSortKey = (typeof ModelsTableSortKey)[keyof typeof ModelsTableSortKey];
/** Panel the models dialog shows. */
export const MODELS_DIALOG_VIEW = {
DISCOVER: 'discover',
MANAGE: 'manage',
PROVIDERS: 'providers'
} as const;
export type ModelsDialogView = (typeof MODELS_DIALOG_VIEW)[keyof typeof MODELS_DIALOG_VIEW];
@@ -2,19 +2,34 @@ import type { ModelItem } from '$lib/components/app/navigation/utils';
import {
filterModelOptions,
groupFavoriteOptions,
groupModelOptions
groupModelOptions,
groupProviderOptions
} from '$lib/components/app/navigation/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { modelsStore, serverStore, uiStore } from '$lib/stores';
import {
CHAT_INPUT_FOCUS_SELECTOR,
LOCAL_BACKEND_ID,
REMOTE_PROVIDER_MODEL_LIMIT
} from '$lib/constants';
import { backendsModelsStore, backendsStore, modelsStore, serverStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
import { rawModelId } from '$lib/utils/model-option-id';
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
/** Groups of the favorites tab, which lists favorites only. */
const EMPTY_GROUPS = { available: [], loaded: [], providers: [] };
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
useGlobalSelection?: () => boolean;
onModelChange?: () =>
| ((modelId: string, modelName: string) => Promise<boolean> | boolean | void)
| ((
modelId: string,
modelName: string,
backendId?: string
) => Promise<boolean> | boolean | void)
| undefined;
onOpenChange?: (open: boolean) => void;
}
@@ -34,10 +49,14 @@ export interface UseModelsSelectorReturn {
readonly loadedItems: ModelItem[];
readonly filteredOptions: ModelOption[];
readonly isEmpty: boolean;
readonly isProviderView: boolean;
readonly groupedFilteredOptions: ReturnType<typeof groupModelOptions>;
readonly isLoadingModel: boolean;
readonly searchTerm: string;
closeProvider(): void;
openProvider(backendId: string): void;
setSearchTerm(value: string): void;
showBackendModels(backendId: string): Promise<void>;
handleSelect(modelId: string): Promise<void>;
handleOpenChange(open: boolean): void;
isFavorite(model: string): boolean;
@@ -52,21 +71,36 @@ export interface UseModelsSelectorReturn {
* duplicating store derivations, selection handling, and model loading.
*/
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
let isLoadingModel = $state(false);
let searchTerm = $state('');
/**
* Current view: the favorites of every backend, the local server's models, or
* the remote backends'. Favorites are the default while there is at least one.
*/
/** Remote backend drilled into from its section; null while browsing. */
let providerViewId = $state<string | null>(null);
const options = $derived(
const isProviderView = $derived(providerViewId !== null);
const isLocalOption = (option: ModelOption) => option.backendId === LOCAL_BACKEND_ID;
// every enabled backend's models are one list: favorites, then the local
// server, then one section per remote provider
const allOptions = $derived(
modelsStore.models.filter((option) => {
const modelProps = modelsStore.props.getModelProps(option.model);
return modelProps?.ui !== false;
})
);
const options = $derived(
providerViewId ? allOptions.filter((option) => option.backendId === providerViewId) : allOptions
);
const loading = $derived(modelsStore.loading);
const updating = $derived(modelsStore.updating);
const activeId = $derived(modelsStore.selectedModelId);
// a lone llama.cpp server without a router has nothing to choose from
const isMultiModel = $derived(serverStore.isRouterMode);
// Router mode and external backends both expose a selectable model list; only
// a lone llama.cpp server without a router has nothing to choose from.
const isMultiModel = $derived(
serverStore.isRouterMode ||
backendsStore.enabled.some((backend) => backend.id !== LOCAL_BACKEND_ID)
);
const isRouter = $derived(serverStore.isRouterMode);
const serverModel = $derived(modelsStore.singleModelName);
const currentModel = $derived(opts.currentModel());
@@ -74,28 +108,54 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
const isHighlightedCurrentModelActive = $derived.by(() => {
if (!isRouter || !currentModel) return false;
const currentOption = options.find((option) => option.model === currentModel);
const currentOption = allOptions.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false;
});
const isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
return options.some((option) => option.model === currentModel);
return allOptions.some((option) => option.model === currentModel);
});
const visibleOptions = $derived(options.filter((option) => !modelsStore.isHidden(option.id)));
let isLoadingModel = $state(false);
let searchTerm = $state('');
const visibleOptions = $derived(allOptions.filter((option) => !modelsStore.isHidden(option.id)));
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
// favorites span every backend, so they come from the full option list
const favoriteItems = $derived(
groupFavoriteOptions(
filterModelOptions(visibleOptions, searchTerm),
modelsStore.favoriteModelIds
)
);
const loadedItems = $derived(
filterModelOptions(visibleOptions, searchTerm)
.map((option, flatIndex) => ({ flatIndex, option }))
.filter(({ option }) => modelsStore.isModelLoaded(option.model))
const remoteProviders = $derived(
backendsStore.enabled
.filter((backend) => backend.id !== LOCAL_BACKEND_ID)
.map((backend) => {
const state = backendsModelsStore.get(backend.id);
return {
backendId: backend.id,
catalog: state.models.length,
error: state.error,
loading: state.loading,
name: backend.name
};
})
);
// loaded models lead the list, from any llama-compat backend
const isLoadedLlamaCompat = (option: ModelOption) =>
modelsStore.isModelLoaded(option.model) &&
getBackendCapabilities(getBackend(option.backendId)).loadUnload;
const loadedItems = $derived.by(() => {
if (isProviderView) return [];
return filterModelOptions(visibleOptions, searchTerm)
.map((option, flatIndex) => ({ flatIndex, option }))
.filter(({ option }) => isLoadedLlamaCompat(option));
});
const loadedIds = $derived(new SvelteSet(loadedItems.map((item) => item.option.id)));
// loaded models and favorites are listed once, at the top: the sections skip both
const sectionOptions = $derived(
@@ -103,9 +163,28 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
(option) => !modelsStore.favoriteModelIds.has(option.model) && !loadedIds.has(option.id)
)
);
const groupedFilteredOptions = $derived(
groupModelOptions(sectionOptions, (model) => modelsStore.isModelLoaded(model))
const providerSections = $derived(
groupProviderOptions(
sectionOptions,
remoteProviders,
// a drill-in or a search reaches every model, the sections stay short
providerViewId || searchTerm ? Infinity : REMOTE_PROVIDER_MODEL_LIMIT,
modelsStore.recentModelIds
)
);
const groupedFilteredOptions = $derived.by(() => {
if (isProviderView) {
const sections = providerSections.filter((section) => section.backendId === providerViewId);
return { ...EMPTY_GROUPS, providers: sections };
}
const local = groupModelOptions(sectionOptions.filter(isLocalOption), (m) =>
modelsStore.isModelLoaded(m)
);
return { ...local, providers: providerSections };
});
const isEmpty = $derived(
filteredOptions.length === 0 && favoriteItems.length === 0 && loadedItems.length === 0
);
@@ -135,6 +214,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
searchTerm = '';
providerViewId = null;
if (open && isRouter) {
modelsStore.props.fetchModalitiesForLoadedModels();
@@ -143,15 +223,37 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
opts.onOpenChange?.(open);
}
/**
* Switch the rendered view. Views are display only: the backend that serves
* requests follows the selected model, not the view.
*/
/** Drill into one remote backend's full model list. */
function openProvider(backendId: string) {
providerViewId = backendId;
searchTerm = '';
}
function closeProvider() {
providerViewId = null;
searchTerm = '';
}
/** Refresh a backend's models, e.g. right after it was added. */
async function showBackendModels(backendId: string): Promise<void> {
await backendsModelsStore.ensureLoaded(backendId);
}
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
// favorites live above the tabs and may belong to another backend, so the
// lookup spans every enabled backend
const option = allOptions.find((opt) => opt.id === modelId);
if (!option) return;
let shouldCloseMenu = true;
if (onModelChange) {
const result = await onModelChange(option.id, option.model);
const result = await onModelChange(rawModelId(option.id), option.model, option.backendId);
if (result === false) {
shouldCloseMenu = false;
@@ -171,7 +273,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
// only the built-in server loads on request, and only in router mode
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
const canLoadHere = option.backendId === LOCAL_BACKEND_ID && isRouter;
if (!onModelChange && canLoadHere && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore.status
@@ -183,6 +287,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
// External backend: the selection is backend-scoped, so it wins over
// the conversation's model, which may belong to another backend.
if (!serverStore.capabilities.props) {
const selected = activeId ? allOptions.find((option) => option.id === activeId) : undefined;
if (selected) return selected;
return currentModel
? allOptions.find((option) => option.model === currentModel)
: undefined;
}
const displayModel = serverModel || currentModel;
if (displayModel) {
@@ -207,11 +323,11 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
};
}
return options.find((option) => option.model === currentModel);
return allOptions.find((option) => option.model === currentModel);
}
if (activeId) {
return options.find((option) => option.id === activeId);
return allOptions.find((option) => option.id === activeId);
}
return undefined;
@@ -222,6 +338,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return activeId;
},
closeProvider,
get emptyMessage() {
return emptyMessage;
},
@@ -267,6 +385,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return isMultiModel;
},
get isProviderView() {
return isProviderView;
},
get isRouter() {
return isRouter;
},
@@ -279,6 +401,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return loading;
},
openProvider,
get options() {
return options;
},
@@ -295,6 +419,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
searchTerm = value;
},
showBackendModels,
get updating() {
return updating;
}
@@ -6,9 +6,9 @@
* consumed by the backends settings UI and the per-backend model cache.
*/
import { API_MODELS, LOCAL_BACKEND_ID } from '$lib/constants';
import { API_MODELS, BackendProtocol, LOCAL_BACKEND_ID } from '$lib/constants';
import { ModelsService } from '$lib/services/models.service';
import type { ApiModelsListResponse, Backend, BackendProtocol, ModelOption } from '$lib/types';
import type { ApiModelsListResponse, Backend, ModelOption } from '$lib/types';
import { isAbortError } from '$lib/utils/abort';
import { apiUrl } from '$lib/utils/api-base';
import { getAuthHeadersForBackend } from '$lib/utils/api-headers';
@@ -49,7 +49,7 @@ export class BackendsService {
static async detectProtocol(backend: Backend): Promise<BackendProbe> {
const base = backend.baseUrl.trim().replace(/\/+$/, '');
if (!base) return { authRequired: false, protocol: 'openai' };
if (!base) return { authRequired: false, protocol: BackendProtocol.OPENAI };
try {
// llama-server answers /props with its build and generation defaults; a
@@ -62,10 +62,10 @@ export class BackendsService {
// a llama-server behind a key refuses before it says anything else, while
// an OpenAI-compatible endpoint has no /props to guard in the first place
if (response.status === 401) {
return { authRequired: true, protocol: 'llama.cpp' };
return { authRequired: true, protocol: BackendProtocol.COMPAT };
}
if (!response.ok) return { authRequired: false, protocol: 'openai' };
if (!response.ok) return { authRequired: false, protocol: BackendProtocol.OPENAI };
const body = (await response.json()) as Record<string, unknown>;
const isLlamaCpp =
@@ -73,10 +73,10 @@ export class BackendsService {
return {
authRequired: false,
protocol: isLlamaCpp ? 'llama.cpp' : 'openai'
protocol: isLlamaCpp ? BackendProtocol.COMPAT : BackendProtocol.OPENAI
};
} catch {
return { authRequired: false, protocol: 'openai' };
return { authRequired: false, protocol: BackendProtocol.OPENAI };
}
}
+4 -3
View File
@@ -8,11 +8,12 @@
import { openaiAdapter } from './openai';
import type { ChatProtocolAdapter } from './types';
import type { Backend, BackendProtocol } from '$lib/types';
import { BackendProtocol } from '$lib/constants';
import type { Backend } from '$lib/types';
const ADAPTERS: Record<BackendProtocol, ChatProtocolAdapter> = {
'llama.cpp': openaiAdapter,
openai: openaiAdapter
[BackendProtocol.COMPAT]: openaiAdapter,
[BackendProtocol.OPENAI]: openaiAdapter
};
export function getProtocolAdapter(backend?: Backend): ChatProtocolAdapter {
@@ -5,7 +5,7 @@
*/
import type { ChatProtocolAdapter, ChatStreamEvent, ChatStreamReader } from './types';
import { HEADERS } from '$lib/constants';
import { BackendProtocol, HEADERS, MaxTokensField } from '$lib/constants';
import type { Backend } from '$lib/types';
import type { ApiChatCompletionStreamChunk } from '$lib/types/api';
import { getBackendCompat } from '$lib/utils/backend';
@@ -60,7 +60,7 @@ function buildChatRequest(
body: Record<string, unknown>,
backend: Backend
): Record<string, unknown> {
if (backend.protocol === 'llama.cpp') return body;
if (backend.protocol === BackendProtocol.COMPAT) return body;
const compat = getBackendCompat(backend);
const request: Record<string, unknown> = { ...body };
@@ -83,7 +83,10 @@ function buildChatRequest(
// newer OpenAI models require max_completion_tokens, most compatible
// endpoints only understand max_tokens
if (compat.maxTokensField === 'max_completion_tokens' && request.max_tokens !== undefined) {
if (
compat.maxTokensField === MaxTokensField.CHAT_COMPLETION &&
request.max_tokens !== undefined
) {
request.max_completion_tokens = request.max_tokens;
delete request.max_tokens;
}
@@ -9,6 +9,7 @@
import { browser } from '$app/environment';
import {
BackendProtocol,
FAVORITE_MODELS_LOCALSTORAGE_KEY,
HIDDEN_MODELS_LOCALSTORAGE_KEY,
LOCAL_BACKEND_ID,
@@ -649,7 +650,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
// local props describe the server the UI is served from; keep them while
// an external backend is active instead of dropping and refetching
if (backend.protocol === 'llama.cpp') {
if (backend.protocol === BackendProtocol.COMPAT) {
serverStore.restoreLocalState();
} else {
serverStore.cacheLocalState();
@@ -665,7 +666,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return;
}
if (backend.protocol === 'llama.cpp' && !serverStore.props) {
if (backend.protocol === BackendProtocol.COMPAT && !serverStore.props) {
// first visit to the local tab in this session
await serverStore.fetch({ background: true });
}
@@ -675,7 +676,11 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
// the local router rows carry the load statuses; the startup prefetch
// already returned them, so a tab switch rebuilds the list from memory
if (backend.protocol === 'llama.cpp' && this.routerModels.length === 0 && cached.raw) {
if (
backend.protocol === BackendProtocol.COMPAT &&
this.routerModels.length === 0 &&
cached.raw
) {
this.routerModels = cached.raw.data;
this.activeModels = this.buildModelOptions(cached.raw);
}
+2 -2
View File
@@ -6,7 +6,7 @@
* PropsService for the /props fetch.
*/
import { BACKEND_CAPABILITIES, LOCAL_BACKEND_ID } from '$lib/constants';
import { BACKEND_CAPABILITIES, BackendProtocol, LOCAL_BACKEND_ID } from '$lib/constants';
import { ServerRole } from '$lib/enums';
import { PropsService } from '$lib/services/props.service';
import type { BackendCapabilities } from '$lib/types';
@@ -38,7 +38,7 @@ class ServerStore {
get capabilities(): BackendCapabilities {
const backend = getBackend();
return backend ? getBackendCapabilities(backend) : BACKEND_CAPABILITIES['llama.cpp'];
return backend ? getBackendCapabilities(backend) : BACKEND_CAPABILITIES[BackendProtocol.COMPAT];
}
get contextSize(): number | null {
+4 -3
View File
@@ -7,8 +7,9 @@
* protocol.
*/
/** Request/response shape a backend speaks. */
export type BackendProtocol = 'llama.cpp' | 'openai';
import type { BackendProtocol, MaxTokensField } from '$lib/constants';
export type { BackendProtocol };
/**
* Wire-level quirks of a backend's protocol. Capabilities gate llama.cpp
@@ -16,7 +17,7 @@ export type BackendProtocol = 'llama.cpp' | 'openai';
*/
export interface BackendCompat {
/** Field carrying the output token cap. */
maxTokensField: 'max_completion_tokens' | 'max_tokens';
maxTokensField: MaxTokensField;
/** Whether the endpoint accepts stream_options.include_usage. */
supportsUsageInStreaming: boolean;
}
+17 -12
View File
@@ -12,19 +12,15 @@ import {
BACKEND_ID_PREFIX,
BACKEND_PRESETS,
BACKEND_PROTOCOLS,
BackendProtocol,
DEFAULT_BACKEND_CHAT_PATH,
DEFAULT_BACKEND_MODELS_PATH,
FAVICON_SERVICE_URL,
LOCAL_BACKEND_ID,
MaxTokensField,
MODEL_CONTEXT_LENGTH_FIELDS
} from '$lib/constants';
import type {
Backend,
BackendCapabilities,
BackendCompat,
BackendPreset,
BackendProtocol
} from '$lib/types';
import type { Backend, BackendCapabilities, BackendCompat, BackendPreset } from '$lib/types';
/** Absolute chat completions URL for a backend. */
export function backendChatUrl(backend: Backend): string {
@@ -84,12 +80,18 @@ function normalizeBaseUrl(url: string): string | null {
* (unknown model, early startup) gets the plain compatible defaults.
*/
export function getBackendCapabilities(backend?: Backend): BackendCapabilities {
return BACKEND_CAPABILITIES[backend?.protocol ?? 'openai'] ?? BACKEND_CAPABILITIES.openai;
return (
BACKEND_CAPABILITIES[backend?.protocol ?? BackendProtocol.OPENAI] ??
BACKEND_CAPABILITIES[BackendProtocol.OPENAI]
);
}
/** Wire quirks for a backend: protocol defaults overridden by the backend. */
export function getBackendCompat(backend: Backend): BackendCompat {
return { ...(BACKEND_COMPAT[backend.protocol] ?? BACKEND_COMPAT.openai), ...backend.compat };
return {
...(BACKEND_COMPAT[backend.protocol] ?? BACKEND_COMPAT[BackendProtocol.OPENAI]),
...backend.compat
};
}
/** The built-in backend pointing at the server that serves this UI. */
@@ -100,7 +102,7 @@ export function createLocalBackend(apiKey?: string, enabled = true): Backend {
enabled,
id: LOCAL_BACKEND_ID,
name: 'Local',
protocol: 'llama.cpp'
protocol: BackendProtocol.COMPAT
};
}
@@ -155,7 +157,7 @@ function parseBackendEntry(entry: unknown, index: number): Backend | null {
const protocol = BACKEND_PROTOCOLS.includes(raw.protocol as BackendProtocol)
? (raw.protocol as BackendProtocol)
: 'openai';
: BackendProtocol.OPENAI;
const id =
typeof raw.id === 'string' && raw.id.trim()
? raw.id.trim()
@@ -190,7 +192,10 @@ function parseBackendCompat(
const defaults = BACKEND_COMPAT[protocol] ?? BACKEND_COMPAT.openai;
const overrides: Partial<BackendCompat> = {};
if (entry.maxTokensField === 'max_tokens' || entry.maxTokensField === 'max_completion_tokens') {
if (
entry.maxTokensField === MaxTokensField.COMPLETION ||
entry.maxTokensField === MaxTokensField.CHAT_COMPLETION
) {
overrides.maxTokensField = entry.maxTokensField;
}
@@ -103,7 +103,8 @@
orgName: 'intel'
}
],
loaded: loadedModels
loaded: loadedModels,
providers: []
};
function handleSelect(modelId: string) {
@@ -137,7 +138,8 @@
currentModel={null}
groups={{
available: [],
loaded: [loadedModels[0]]
loaded: [loadedModels[0]],
providers: []
}}
onSelect={handleSelect}
/>
@@ -152,7 +154,8 @@
favorites={favoriteModels}
groups={{
available: [],
loaded: []
loaded: [],
providers: []
}}
onSelect={handleSelect}
/>