diff --git a/core/gallery/backend_resolve.go b/core/gallery/backend_resolve.go
new file mode 100644
index 000000000..64a89c504
--- /dev/null
+++ b/core/gallery/backend_resolve.go
@@ -0,0 +1,173 @@
+package gallery
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/downloader"
+ "github.com/mudler/LocalAI/pkg/xsync"
+ "github.com/mudler/xlog"
+ "gopkg.in/yaml.v3"
+)
+
+// modelConfigCacheEntry holds a cached parsed config_file map from a URL-referenced model config.
+type modelConfigCacheEntry struct {
+ configMap map[string]interface{}
+ lastUpdated time.Time
+}
+
+func (e modelConfigCacheEntry) hasExpired() bool {
+ return e.lastUpdated.Before(time.Now().Add(-1 * time.Hour))
+}
+
+// modelConfigCache caches parsed model config maps keyed by URL.
+var modelConfigCache = xsync.NewSyncedMap[string, modelConfigCacheEntry]()
+
+// resolveBackend determines the backend for a GalleryModel by checking (in priority order):
+// 1. Overrides["backend"] — highest priority, same as install-time merge
+// 2. Inline ConfigFile["backend"] — for models with inline config maps
+// 3. URL-referenced config file — fetched, parsed, and cached
+//
+// The model's URL should already be resolved (local override applied) before calling this.
+func resolveBackend(m *GalleryModel, basePath string) string {
+ // 1. Overrides take priority (matches install-time mergo.WithOverride behavior)
+ if b, ok := m.Overrides["backend"].(string); ok && b != "" {
+ return b
+ }
+
+ // 2. Inline config_file map
+ if b, ok := m.ConfigFile["backend"].(string); ok && b != "" {
+ return b
+ }
+
+ // 3. Fetch and parse the URL-referenced config
+ if m.URL != "" {
+ configMap := fetchModelConfigMap(m.URL, basePath)
+ if b, ok := configMap["backend"].(string); ok && b != "" {
+ return b
+ }
+ }
+
+ return ""
+}
+
+// fetchModelConfigMap fetches a model config URL, parses the config_file YAML string
+// inside it, and returns the result as a map. Results are cached for 1 hour.
+// Local file:// URLs skip the cache so edits are picked up immediately.
+func fetchModelConfigMap(modelURL, basePath string) map[string]interface{} {
+ // Check cache (skip for file:// URLs so local edits are picked up immediately)
+ isLocal := strings.HasPrefix(modelURL, downloader.LocalPrefix)
+ if !isLocal && modelConfigCache.Exists(modelURL) {
+ entry := modelConfigCache.Get(modelURL)
+ if !entry.hasExpired() {
+ return entry.configMap
+ }
+ modelConfigCache.Delete(modelURL)
+ }
+
+ // Reuse existing gallery config fetcher
+ modelConfig, err := GetGalleryConfigFromURL[ModelConfig](modelURL, basePath)
+ if err != nil {
+ xlog.Debug("Failed to fetch model config for backend resolution", "url", modelURL, "error", err)
+ // Cache the failure for remote URLs to avoid repeated fetch attempts
+ if !isLocal {
+ modelConfigCache.Set(modelURL, modelConfigCacheEntry{
+ configMap: map[string]interface{}{},
+ lastUpdated: time.Now(),
+ })
+ }
+ return map[string]interface{}{}
+ }
+
+ // Parse the config_file YAML string into a map
+ configMap := make(map[string]interface{})
+ if modelConfig.ConfigFile != "" {
+ if err := yaml.Unmarshal([]byte(modelConfig.ConfigFile), &configMap); err != nil {
+ xlog.Debug("Failed to parse config_file for backend resolution", "url", modelURL, "error", err)
+ }
+ }
+
+ // Cache for remote URLs
+ if !isLocal {
+ modelConfigCache.Set(modelURL, modelConfigCacheEntry{
+ configMap: configMap,
+ lastUpdated: time.Now(),
+ })
+ }
+
+ return configMap
+}
+
+// prefetchModelConfigs fetches model config URLs in parallel to warm the cache.
+// This avoids sequential HTTP requests on cold start (~50 unique gallery files).
+func prefetchModelConfigs(urls []string, basePath string) {
+ const maxConcurrency = 10
+ sem := make(chan struct{}, maxConcurrency)
+ var wg sync.WaitGroup
+ for _, url := range urls {
+ wg.Add(1)
+ go func(u string) {
+ defer wg.Done()
+ sem <- struct{}{}
+ defer func() { <-sem }()
+ fetchModelConfigMap(u, basePath)
+ }(url)
+ }
+ wg.Wait()
+}
+
+// resolveModelURLLocally attempts to resolve a github: model URL to a local file://
+// path when the gallery itself was loaded from a local path. This supports development
+// workflows where new model files are added locally before being pushed to GitHub.
+//
+// For example, if the gallery was loaded from file:///path/to/gallery/index.yaml
+// and a model references github:mudler/LocalAI/gallery/foo.yaml@master, this will
+// check if /path/to/gallery/foo.yaml exists locally and return file:///path/to/gallery/foo.yaml.
+//
+// This is applied to model.URL in AvailableGalleryModels so that both listing (backend
+// resolution) and installation use the same resolved URL.
+func resolveModelURLLocally(modelURL, galleryURL string) string {
+ galleryDir := localGalleryDir(galleryURL)
+ if galleryDir == "" {
+ return modelURL
+ }
+
+ // Only handle github: URLs
+ if !strings.HasPrefix(modelURL, downloader.GithubURI) && !strings.HasPrefix(modelURL, downloader.GithubURI2) {
+ return modelURL
+ }
+
+ // Extract the filename from the github URL
+ // Format: github:org/repo/path/to/file.yaml@branch
+ raw := strings.TrimPrefix(modelURL, downloader.GithubURI2)
+ raw = strings.TrimPrefix(raw, downloader.GithubURI)
+ // Remove @branch suffix
+ if idx := strings.LastIndex(raw, "@"); idx >= 0 {
+ raw = raw[:idx]
+ }
+ filename := filepath.Base(raw)
+
+ localPath := filepath.Join(galleryDir, filename)
+ if _, err := os.Stat(localPath); err == nil {
+ return downloader.LocalPrefix + localPath
+ }
+
+ return modelURL
+}
+
+// localGalleryDir returns the directory of a gallery URL if it's local, or "" if remote.
+func localGalleryDir(galleryURL string) string {
+ if strings.HasPrefix(galleryURL, downloader.LocalPrefix) {
+ return filepath.Dir(strings.TrimPrefix(galleryURL, downloader.LocalPrefix))
+ }
+ // Plain path (no scheme) that exists on disk
+ if !strings.Contains(galleryURL, "://") && !strings.HasPrefix(galleryURL, downloader.GithubURI) {
+ if info, err := os.Stat(galleryURL); err == nil && !info.IsDir() {
+ return filepath.Dir(galleryURL)
+ }
+ }
+ return ""
+}
diff --git a/core/gallery/gallery.go b/core/gallery/gallery.go
index 035bfcde8..d79b3e569 100644
--- a/core/gallery/gallery.go
+++ b/core/gallery/gallery.go
@@ -218,6 +218,36 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
if err != nil {
return nil, err
}
+
+ // Resolve model URLs locally (for local galleries) and collect unique
+ // URLs that need fetching for backend resolution.
+ uniqueURLs := map[string]struct{}{}
+ for _, m := range galleryModels {
+ if m.URL != "" {
+ m.URL = resolveModelURLLocally(m.URL, gallery.URL)
+ }
+ if m.Backend == "" && m.URL != "" {
+ uniqueURLs[m.URL] = struct{}{}
+ }
+ }
+
+ // Pre-warm cache with parallel fetches to avoid sequential HTTP
+ // requests on cold start (~50 unique gallery config files).
+ if len(uniqueURLs) > 0 {
+ urls := make([]string, 0, len(uniqueURLs))
+ for u := range uniqueURLs {
+ urls = append(urls, u)
+ }
+ prefetchModelConfigs(urls, systemState.Model.ModelsPath)
+ }
+
+ // Resolve backends from warm cache.
+ for _, m := range galleryModels {
+ if m.Backend == "" {
+ m.Backend = resolveBackend(m, systemState.Model.ModelsPath)
+ }
+ }
+
models = append(models, galleryModels...)
}
diff --git a/core/gallery/metadata_type.go b/core/gallery/metadata_type.go
index 066cf83a6..717345a83 100644
--- a/core/gallery/metadata_type.go
+++ b/core/gallery/metadata_type.go
@@ -19,4 +19,7 @@ type Metadata struct {
Gallery config.Gallery `json:"gallery,omitempty" yaml:"gallery,omitempty"`
// Installed is used to indicate if the model is installed or not
Installed bool `json:"installed,omitempty" yaml:"installed,omitempty"`
+ // Backend is the resolved backend engine for this model (e.g. "llama-cpp").
+ // Populated at load time from overrides, inline config, or the URL-referenced config file.
+ Backend string `json:"backend,omitempty" yaml:"backend,omitempty"`
}
diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js
new file mode 100644
index 000000000..ed5be1e56
--- /dev/null
+++ b/core/http/react-ui/e2e/models-gallery.spec.js
@@ -0,0 +1,80 @@
+import { test, expect } from '@playwright/test'
+
+const MOCK_MODELS_RESPONSE = {
+ models: [
+ { name: 'llama-model', description: 'A llama model', backend: 'llama-cpp', installed: false, tags: ['llm'] },
+ { name: 'whisper-model', description: 'A whisper model', backend: 'whisper', installed: true, tags: ['stt'] },
+ { name: 'stablediffusion-model', description: 'An image model', backend: 'stablediffusion', installed: false, tags: ['sd'] },
+ { name: 'unknown-model', description: 'No backend', backend: '', installed: false, tags: [] },
+ ],
+ allBackends: ['llama-cpp', 'stablediffusion', 'whisper'],
+ allTags: ['llm', 'sd', 'stt'],
+ availableModels: 4,
+ installedModels: 1,
+ totalPages: 1,
+ currentPage: 1,
+}
+
+test.describe('Models Gallery - Backend Features', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.route('**/api/models*', (route) => {
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify(MOCK_MODELS_RESPONSE),
+ })
+ })
+ await page.goto('/app/models')
+ // Wait for the table to render
+ await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible({ timeout: 10_000 })
+ })
+
+ test('backend column header is visible', async ({ page }) => {
+ await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible()
+ })
+
+ test('backend badges shown in table rows', async ({ page }) => {
+ const table = page.locator('table')
+ await expect(table.locator('.badge', { hasText: 'llama-cpp' })).toBeVisible()
+ await expect(table.locator('.badge', { hasText: /^whisper$/ })).toBeVisible()
+ })
+
+ test('backend dropdown is visible', async ({ page }) => {
+ await expect(page.locator('button', { hasText: 'All Backends' })).toBeVisible()
+ })
+
+ test('clicking backend dropdown opens searchable panel', async ({ page }) => {
+ await page.locator('button', { hasText: 'All Backends' }).click()
+ await expect(page.locator('input[placeholder="Search backends..."]')).toBeVisible()
+ })
+
+ test('typing in search filters dropdown options', async ({ page }) => {
+ await page.locator('button', { hasText: 'All Backends' }).click()
+ const searchInput = page.locator('input[placeholder="Search backends..."]')
+ await searchInput.fill('llama')
+
+ // llama-cpp option should be visible, whisper should not
+ const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..') .locator('..')
+ await expect(dropdown.locator('text=llama-cpp')).toBeVisible()
+ await expect(dropdown.locator('text=whisper')).not.toBeVisible()
+ })
+
+ test('selecting a backend updates the dropdown label', async ({ page }) => {
+ await page.locator('button', { hasText: 'All Backends' }).click()
+ // Click the llama-cpp option within the dropdown (not the table badge)
+ const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..').locator('..')
+ await dropdown.locator('text=llama-cpp').click()
+
+ // The dropdown button should now show the selected backend instead of "All Backends"
+ await expect(page.locator('button span', { hasText: 'llama-cpp' })).toBeVisible()
+ })
+
+ test('expanded row shows backend in detail', async ({ page }) => {
+ // Click the first model row to expand it
+ await page.locator('tr', { hasText: 'llama-model' }).click()
+
+ // The detail view should show Backend label and value
+ const detail = page.locator('td[colspan="8"]')
+ await expect(detail.locator('text=Backend')).toBeVisible()
+ await expect(detail.locator('text=llama-cpp')).toBeVisible()
+ })
+})
diff --git a/core/http/react-ui/playwright.config.js b/core/http/react-ui/playwright.config.js
index e804fc089..f2856f418 100644
--- a/core/http/react-ui/playwright.config.js
+++ b/core/http/react-ui/playwright.config.js
@@ -16,7 +16,7 @@ export default defineConfig({
},
],
webServer: process.env.PLAYWRIGHT_EXTERNAL_SERVER ? undefined : {
- command: '../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=8089',
+ command: '../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=8089 > /tmp/ui-test-server.log 2>&1',
port: 8089,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css
index 375575c10..d0f44789b 100644
--- a/core/http/react-ui/src/App.css
+++ b/core/http/react-ui/src/App.css
@@ -1828,7 +1828,7 @@
.chat-mcp-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
- right: 0;
+ left: 0;
z-index: 100;
min-width: 240px;
max-height: 320px;
diff --git a/core/http/react-ui/src/components/ModelSelector.jsx b/core/http/react-ui/src/components/ModelSelector.jsx
index b932e80ba..1974a4927 100644
--- a/core/http/react-ui/src/components/ModelSelector.jsx
+++ b/core/http/react-ui/src/components/ModelSelector.jsx
@@ -1,27 +1,38 @@
-import { useEffect } from 'react'
+import { useEffect, useMemo } from 'react'
import { useModels } from '../hooks/useModels'
+import SearchableSelect from './SearchableSelect'
-export default function ModelSelector({ value, onChange, capability, className = '' }) {
- const { models, loading } = useModels(capability)
+export default function ModelSelector({
+ value, onChange, capability, className = '',
+ options: externalOptions, loading: externalLoading,
+ disabled: externalDisabled, searchPlaceholder, style,
+}) {
+ // Skip capability fetch when external options are provided (capability will be undefined)
+ const { models: hookModels, loading: hookLoading } = useModels(externalOptions ? undefined : capability)
+
+ const modelNames = useMemo(
+ () => externalOptions || hookModels.map(m => m.id),
+ [externalOptions, hookModels]
+ )
+ const isLoading = externalOptions ? (externalLoading || false) : hookLoading
+ const isDisabled = isLoading || (externalDisabled || false)
useEffect(() => {
- if (!value && models.length > 0) {
- onChange(models[0].id)
+ if (modelNames.length > 0 && (!value || !modelNames.includes(value))) {
+ onChange(modelNames[0])
}
- }, [models, value, onChange])
+ }, [modelNames, value, onChange])
return (
-
+