diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte index 879f8d9d03..a5afc3bfeb 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte @@ -24,6 +24,7 @@ import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte'; import { modelsStore, settingsStore } from '$lib/stores'; import { modelLoadFraction } from '$lib/utils'; + import { rawModelId } from '$lib/utils/model-option-id'; interface Props { class?: string; @@ -141,12 +142,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() { diff --git a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts index 6019cd4041..6f70f7da53 100644 --- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts +++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts @@ -3,6 +3,7 @@ import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants'; import { backendsModelsStore, backendsStore, modelsStore, serverStore } from '$lib/stores'; import type { Backend } from '$lib/types'; import type { ModelOption } from '$lib/types/models'; +import { rawModelId } from '$lib/utils/model-option-id'; import { onMount } from 'svelte'; export interface UseModelsSelectorOptions { @@ -145,7 +146,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele let shouldCloseMenu = true; if (onModelChange) { - const result = await onModelChange(option.id, option.model); + const result = await onModelChange(rawModelId(option.id), option.model); if (result === false) { shouldCloseMenu = false; diff --git a/tools/ui/src/lib/stores/backendsModels.svelte.ts b/tools/ui/src/lib/stores/backendsModels.svelte.ts index 460bb901bb..6d0d7f5054 100644 --- a/tools/ui/src/lib/stores/backendsModels.svelte.ts +++ b/tools/ui/src/lib/stores/backendsModels.svelte.ts @@ -56,7 +56,7 @@ class BackendsModelsStore { return this.states[backendId] ?? EMPTY_STATE; } - /** Prefetch every enabled external backend, skipping the active one. */ + /** Prefetch every enabled backend's model list. */ async loadAll(): Promise { const enabled = backendsStore.enabled; const ids = new Set(enabled.map((backend) => backend.id)); @@ -67,11 +67,7 @@ class BackendsModelsStore { } } - await Promise.all( - enabled - .filter((backend) => backend.id !== backendsStore.active.id) - .map((backend) => this.ensureLoaded(backend.id)) - ); + await Promise.all(enabled.map((backend) => this.ensureLoaded(backend.id))); } } diff --git a/tools/ui/src/lib/stores/models/index.svelte.ts b/tools/ui/src/lib/stores/models/index.svelte.ts index 13bb902b77..0008e04047 100644 --- a/tools/ui/src/lib/stores/models/index.svelte.ts +++ b/tools/ui/src/lib/stores/models/index.svelte.ts @@ -18,14 +18,15 @@ import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte'; import { serverStore } from '$lib/stores/server.svelte'; import { getConversationModel } from '$lib/utils/conversation-utils'; +import { backendIdFromModelId, qualifyModelId, rawModelId } from '$lib/utils/model-option-id'; import { SvelteSet } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; class ModelsStore implements ModelPropsHost, ModelStatusHost { + activeModels = $state([]); error = $state(null); favoriteModelIds = $state>(this.loadFavoritesFromStorage()); loading = $state(false); - models = $state([]); routerModels = $state([]); selectedModelId = $state(null); selectedModelName = $state(null); @@ -86,6 +87,39 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { .map((m) => m.id); } + /** + * Every selectable model across enabled backends. The active backend's + * models come from {@link activeModels}; the rest come from the background + * prefetch cache. Ids are backend-qualified so the same model name on two + * backends stays distinct. + */ + get models(): ModelOption[] { + const activeBackendId = backendsStore.active.id; + const merged: ModelOption[] = []; + + for (const option of this.activeModels) { + merged.push({ + ...option, + backendId: activeBackendId, + id: qualifyModelId(activeBackendId, option.id) + }); + } + + for (const backend of backendsStore.enabled) { + if (backend.id === activeBackendId) continue; + + for (const option of backendsModelsStore.get(backend.id).models) { + merged.push({ + ...option, + backendId: backend.id, + id: qualifyModelId(backend.id, option.id) + }); + } + } + + return merged; + } + get props() { return this._props; } @@ -195,7 +229,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { async fetch(force = false): Promise { if (this.inflightFetch) return this.inflightFetch; - if (this.models.length > 0 && !force) return; + if (this.activeModels.length > 0 && !force) return; this.inflightFetch = this.runFetch(); try { @@ -219,7 +253,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { this.routerModels = response.data; // keep the selector options in sync: a downloaded / deleted model shows // up here too, not only in the router model rows - this.models = this.buildModelOptions(response); + this.activeModels = this.buildModelOptions(response); await this.props.fetchModalitiesForLoadedModels(); const visible = this.getVisibleModels(); @@ -290,9 +324,19 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { async selectModelById(modelId: string): Promise { if (!modelId || this.updating) return; + const backendId = backendIdFromModelId(modelId) ?? backendsStore.active.id; + const rawId = rawModelId(modelId); + + // a model from another backend makes that backend active first + if (backendId !== backendsStore.active.id) { + backendsStore.setActive(backendId); + await backendsModelsStore.ensureLoaded(backendId); + await this.switchBackend(); + } + if (this.selectedModelId === modelId) return; - const option = this.models.find((model) => model.id === modelId); + const option = this.activeModels.find((model) => model.id === rawId); if (!option) throw new Error('Selected model is not available'); @@ -300,7 +344,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { this.error = null; try { - this.selectedModelId = option.id; + this.selectedModelId = modelId; this.selectedModelName = option.model; } finally { this.updating = false; @@ -314,8 +358,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { const option = this.models.find((model) => model.model === modelName); if (option) { - this.selectedModelId = option.id; - this.selectedModelName = option.model; + void this.selectModelById(option.id); } } @@ -361,12 +404,12 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { const cached = backend.baseUrl.trim() ? backendsModelsStore.get(backend.id) : null; if (cached?.loaded) { - this.models = cached.models; + this.activeModels = cached.models; this.loading = false; await serverStore.fetch(); - if (this.models.length > 0) { + if (this.activeModels.length > 0) { await this.ensureFirstModelSelected(); } @@ -457,7 +500,9 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { * Filter to models visible in the UI (ui !== false). */ private getVisibleModels(): ModelOption[] { - return this.models.filter((option) => this.props.getModelProps(option.model)?.ui !== false); + return this.activeModels.filter( + (option) => this.props.getModelProps(option.model)?.ui !== false + ); } private loadFavoritesFromStorage(): Set { @@ -487,7 +532,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { const response = await ModelsService.list(); this.routerModels = response.data; - this.models = this.buildModelOptions(response); + this.activeModels = this.buildModelOptions(response); await this.props.fetchModalitiesForLoadedModels(); @@ -497,7 +542,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { this.selectModelById(visible[0].id); } } else { - this.models = await this.fetchModelModeInternal(); + this.activeModels = await this.fetchModelModeInternal(); // external backends expose a selectable list; pick a default so the // first send and title generation have a model to target @@ -506,7 +551,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { } } } catch (error) { - this.models = []; + this.activeModels = []; this.error = error instanceof Error ? error.message : 'Failed to load models'; throw error; diff --git a/tools/ui/src/lib/stores/models/props.svelte.ts b/tools/ui/src/lib/stores/models/props.svelte.ts index e388f46ddc..f7c20513a2 100644 --- a/tools/ui/src/lib/stores/models/props.svelte.ts +++ b/tools/ui/src/lib/stores/models/props.svelte.ts @@ -28,7 +28,7 @@ import { SvelteSet } from 'svelte/reactivity'; */ export interface ModelPropsHost { /** Model rows the manager mirrors fetched modalities onto. */ - models: ModelOption[]; + activeModels: ModelOption[]; readonly selectedModelName: string | null; readonly loadedModelIds: string[]; isModelLoaded(modelId: string): boolean; @@ -123,7 +123,7 @@ export class ModelPropsManager { try { const results = await Promise.all(propsPromises); - this.host.models = this.host.models.map((model) => { + this.host.activeModels = this.host.activeModels.map((model) => { const modelIndex = loadedModelIds.indexOf(model.model); if (modelIndex === -1) return model; @@ -195,7 +195,7 @@ export class ModelPropsManager { return this.buildModalities(serverStore.props.modalities); } - const model = this.host.models.find((m) => m.model === modelId || m.id === modelId); + const model = this.host.activeModels.find((m) => m.model === modelId || m.id === modelId); if (model?.modalities) { return model.modalities; @@ -255,7 +255,7 @@ export class ModelPropsManager { if (!props?.modalities) return; - this.host.models = this.host.models.map((model) => + this.host.activeModels = this.host.activeModels.map((model) => model.model === modelId ? { ...model, modalities: this.buildModalities(props.modalities!) } : model diff --git a/tools/ui/src/lib/types/models.d.ts b/tools/ui/src/lib/types/models.d.ts index ca53ca8805..8ec0f09ec0 100644 --- a/tools/ui/src/lib/types/models.d.ts +++ b/tools/ui/src/lib/types/models.d.ts @@ -16,6 +16,8 @@ export interface ModelOption { id: string; name: string; model: string; + /** Backend that serves this model; set on the aggregated option list. */ + backendId?: string; description?: string; capabilities: string[]; modalities?: ModelModalities; diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index f40d882029..6c41198b45 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -127,6 +127,9 @@ export { // Model name utilities export { isValidModelName, normalizeModelName, orgOf } from './model-names'; +// Backend-qualified model option ids +export { backendIdFromModelId, qualifyModelId, rawModelId } from './model-option-id'; + // Sidecar token utilities export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars'; diff --git a/tools/ui/src/lib/utils/model-option-id.ts b/tools/ui/src/lib/utils/model-option-id.ts new file mode 100644 index 0000000000..b856cb39dc --- /dev/null +++ b/tools/ui/src/lib/utils/model-option-id.ts @@ -0,0 +1,28 @@ +/** + * Backend-qualified model option ids. + * + * The selector lists models from every enabled backend, so a bare model id can + * collide. Option ids are qualified as `::`; selection + * strips the prefix to find the row in the active backend's list. + */ + +const MODEL_OPTION_ID_SEPARATOR = '::'; + +/** Prefix a raw model id with the backend that serves it. */ +export function qualifyModelId(backendId: string, modelId: string): string { + return `${backendId}${MODEL_OPTION_ID_SEPARATOR}${modelId}`; +} + +/** Backend id of a qualified model option id, or null when unqualified. */ +export function backendIdFromModelId(qualifiedId: string): string | null { + const index = qualifiedId.indexOf(MODEL_OPTION_ID_SEPARATOR); + + return index === -1 ? null : qualifiedId.slice(0, index); +} + +/** Strip the backend prefix from a qualified model option id. */ +export function rawModelId(qualifiedId: string): string { + const index = qualifiedId.indexOf(MODEL_OPTION_ID_SEPARATOR); + + return index === -1 ? qualifiedId : qualifiedId.slice(index + MODEL_OPTION_ID_SEPARATOR.length); +}