Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/secscan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ jobs:
steps:
- name: Checkout Source
uses: actions/checkout@v6
if: ${{ github.actor != 'dependabot[bot]' }}
if: ${{ !github.repository.fork && github.actor != 'dependabot[bot]' }}
- name: Run Gosec Security Scanner
if: ${{ github.actor != 'dependabot[bot]' }}
if: ${{ !github.repository.fork && github.actor != 'dependabot[bot]' }}
uses: securego/gosec@v2.27.1
with:
# we let the report trigger content trigger a failure using the GitHub Security features.
args: '-no-fail -fmt sarif -out results.sarif ./...'
- name: Upload SARIF file
if: ${{ github.actor != 'dependabot[bot]' }}
if: ${{ !github.repository.fork && github.actor != 'dependabot[bot]' }}
uses: github/codeql-action/upload-sarif@v4
with:
# Path to SARIF file relative to the root of the repository
Expand Down
18 changes: 16 additions & 2 deletions core/templates/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,29 @@ const (
)

type Evaluator struct {
cache *templateCache
cache *templateCache
loader *TemplateLoader
}

// NewEvaluator returns an Evaluator rooted at modelPath, which is also used
// as the templates directory (same physical folder — a separate TemplateLoader
// then filters to .tmpl files only). This keeps compatibility with existing
// deployments where models and templates live side-by-side, while giving
// templates a dedicated, testable loader surface.
func NewEvaluator(modelPath string) *Evaluator {
return &Evaluator{
cache: newTemplateCache(modelPath),
cache: newTemplateCache(modelPath),
loader: NewTemplateLoader(modelPath),
}
}

// TemplateLoader exposes the underlying TemplateLoader owned by the Evaluator.
// Useful when a caller wants to enumerate available templates (e.g. for the
// model editor UI) without having to construct a separate loader.
func (e *Evaluator) TemplateLoader() *TemplateLoader {
return e.loader
}

func (e *Evaluator) EvaluateTemplateForPrompt(templateType TemplateType, config config.ModelConfig, in PromptTemplateData) (string, error) {
template := ""

Expand Down
146 changes: 146 additions & 0 deletions core/templates/loader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package templates

import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"

"github.com/mudler/LocalAI/pkg/utils"
)

// templateFileSuffixes are file extensions that identify a template file on disk.
var templateFileSuffixes = []string{".tmpl"}

// isTemplateFile reports whether a filename looks like a prompt template,
// optionally suffixed by one of templateFileSuffixes. We deliberately do NOT
// treat embedded model artifacts (e.g. gguf/bin/yaml) as templates — that is
// the job of pkg/model.ModelLoader. Splitting this decision into a dedicated
// loader keeps package responsibilities disjoint, which was the motivation for
// the "Split ModelLoader and TemplateLoader" TODO in pkg/model/loader.go.
func isTemplateFile(name string) bool {
lower := strings.ToLower(name)
for _, s := range templateFileSuffixes {
if strings.HasSuffix(lower, s) {
return true
}
}
return false
}

// TemplateLoader owns on-disk discovery of prompt template files. It is a
// peer to pkg/model.ModelLoader, but restricted to .tmpl files — which is why
// it lives in core/templates and never touches model binaries.
type TemplateLoader struct {
mu sync.RWMutex
templatesPath string
// cache of basename -> absolute path discovered on the last ListTemplates
// call. A nil map means "no scan has happened yet"; callers typically
// only read it through ListTemplates.
known map[string]string
}

// NewTemplateLoader returns a loader rooted at templatesPath. The path is
// permitted to be the same directory as the model path; TemplateLoader uses
// suffix-based filtering to only pick up template files within it.
func NewTemplateLoader(templatesPath string) *TemplateLoader {
return &TemplateLoader{
templatesPath: templatesPath,
known: nil,
}
}

// ListTemplates returns the basenames of all template files currently
// available under the loader's root directory. Hidden files (leading dot)
// are skipped. The result is cached in-memory; call Invalidate to force a
// re-scan (e.g. after a user uploads a new .tmpl via the model editor).
func (tl *TemplateLoader) ListTemplates() ([]string, error) {
tl.mu.RLock()
if tl.known != nil {
names := make([]string, 0, len(tl.known))
for n := range tl.known {
names = append(names, n)
}
tl.mu.RUnlock()
return names, nil
}
tl.mu.RUnlock()

return tl.scanAndCache()
}

// Resolve returns the absolute path for a template basename (with or without
// the .tmpl suffix) if and only if it exists on disk and lives inside
// templatesPath. The second result is false when no such file is present.
func (tl *TemplateLoader) Resolve(name string) (string, bool) {
// Normalize: drop .tmpl suffix if present so callers can pass either
// "chatml" or "chatml.tmpl".
base := strings.TrimSuffix(name, ".tmpl")
candidate := filepath.Join(tl.templatesPath, base+".tmpl")

if err := utils.VerifyPath(filepath.Base(candidate), tl.templatesPath); err != nil {
return "", false
}
if _, err := os.Stat(candidate); err != nil {
return "", false
}
return candidate, true
}

// Invalidate clears the internal cache, forcing the next ListTemplates call
// to read the filesystem. Safe to call from a hooks handler after model
// edits that may have added/removed template files.
func (tl *TemplateLoader) Invalidate() {
tl.mu.Lock()
tl.known = nil
tl.mu.Unlock()
}

func (tl *TemplateLoader) scanAndCache() ([]string, error) {
tl.mu.Lock()
defer tl.mu.Unlock()

// Double-check after acquiring the write lock — another goroutine may
// have populated the cache while we were waiting.
if tl.known != nil {
names := make([]string, 0, len(tl.known))
for n := range tl.known {
names = append(names, n)
}
return names, nil
}

entries, err := os.ReadDir(tl.templatesPath)
if err != nil {
return nil, fmt.Errorf("reading templates dir %q: %w", tl.templatesPath, err)
}

names := make([]string, 0)
known := make(map[string]string)
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
// Skip dotfiles; a model editor may drop e.g. ".DS_Store" or swap
// files that should never surface as templates.
if strings.HasPrefix(name, ".") {
continue
}
if !isTemplateFile(name) {
continue
}
abs, err := filepath.Abs(filepath.Join(tl.templatesPath, name))
if err != nil {
continue
}
// Use the "bare" name (without .tmpl) as the lookup key, matching
// the "chatml", "llama3" convention used in model YAMLs.
bare := strings.TrimSuffix(name, filepath.Ext(name))
known[bare] = abs
names = append(names, bare)
}
tl.known = known
return names, nil
}