Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions core/gallery/backend_resolve.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
30 changes: 30 additions & 0 deletions core/gallery/gallery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
}

Expand Down
3 changes: 3 additions & 0 deletions core/gallery/metadata_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
80 changes: 80 additions & 0 deletions core/http/react-ui/e2e/models-gallery.spec.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
2 changes: 1 addition & 1 deletion core/http/react-ui/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion core/http/react-ui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 29 additions & 18 deletions core/http/react-ui/src/components/ModelSelector.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<select
className={`model-selector ${className}`}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={loading}
>
{loading && <option>Loading models...</option>}
{!loading && models.length === 0 && <option>No models available</option>}
{models.map(model => (
<option key={model.id} value={model.id}>{model.id}</option>
))}
</select>
<SearchableSelect
value={value || ''}
onChange={onChange}
options={modelNames}
placeholder={isLoading ? 'Loading models...' : (modelNames.length === 0 ? 'No models available' : 'Select model...')}
searchPlaceholder={searchPlaceholder || 'Search models...'}
disabled={isDisabled}
className={className}
style={style}
/>
)
}
Loading
Loading