Skip to content

Commit 387fbaf

Browse files
committed
fix(router): score classifier production-readiness
Conversation trimming runs through the classifier model's chat template and trims by exact token count, sized to the model's n_batch which is now scaled to context so long probes can't crash the backend. Missing chat_message templates are a hard error at router build time. Router- facing factories (Embedder/Scorer/Reranker/TokenCounter) re-resolve ModelConfig per call so a model installed post-startup doesn't bind a stub Backend="" config and silently fall into the loader's auto- iterate path. New 'vector_store' backend trace recorded inside localVectorStore on every Search/Insert — including the backend-load-failure path that previously vanished into an xlog.Warn — with outcome tagging (hit/miss/empty_store/backend_load_error/find_error/insert_error/ok). Companion cleanup drops misleading similarity:0 and input_tokens_count:0 from non-hit and text-mode traces. Gallery local-store-development aliases to 'local-store' so the master image satisfies pkg/model.LocalStoreBackend lookups from the embedding cache. Misc: llama-cpp TokenizeString reads the correct 'prompt' JSON key (the original bug); ModelTokenize nil-guard; non-fatal mitm proxy startup; PII 'route_local' renamed to 'allow' with docs/UI in sync; model-editor footer no longer eats the edit area on small screens; several config-editor template/dropdown/section fixes. Tests: e2e router specs (casual/code-hint + long-conversation trim), vector_store trace specs, lazy-factory specs, gallery dev-alias resolution, Playwright trace badge + scroll regression. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 8c42695 commit 387fbaf

83 files changed

Lines changed: 2208 additions & 380 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-extra.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ jobs:
563563
- name: Run e2e-backends smoke
564564
env:
565565
BACKEND_IMAGE: quay.io/go-skynet/local-ai-backends:master-cpu-llama-cpp
566-
BACKEND_TEST_CAPS: health,load,predict,stream,logprobs,logit_bias
566+
BACKEND_TEST_CAPS: health,load,predict,stream,logprobs,logit_bias,tokenize
567567
run: |
568568
make test-extra-backend
569569
# Realtime e2e with sherpa-onnx driving VAD + STT + TTS against a mocked LLM.

backend/cpp/llama-cpp/grpc-server.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3458,7 +3458,7 @@ class BackendServiceImpl final : public backend::Backend::Service {
34583458
if (body.count("prompt") != 0) {
34593459
const bool add_special = json_value(body, "add_special", false);
34603460

3461-
llama_tokens tokens = tokenize_mixed(ctx_server.impl->vocab, body.at("content"), add_special, true);
3461+
llama_tokens tokens = tokenize_mixed(ctx_server.impl->vocab, body.at("prompt"), add_special, true);
34623462

34633463

34643464
for (const auto& token : tokens) {

backend/index.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,7 @@
15571557
- localai/localai-backends:master-metal-darwin-arm64-kitten-tts
15581558
- !!merge <<: *local-store
15591559
name: "local-store-development"
1560+
alias: "local-store"
15601561
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-local-store"
15611562
mirrors:
15621563
- localai/localai-backends:master-cpu-local-store
@@ -1567,6 +1568,7 @@
15671568
- localai/localai-backends:latest-metal-darwin-arm64-local-store
15681569
- !!merge <<: *local-store
15691570
name: "metal-local-store-development"
1571+
alias: "local-store"
15701572
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-local-store"
15711573
mirrors:
15721574
- localai/localai-backends:master-metal-darwin-arm64-local-store

core/application/mitm.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,29 @@ import (
1111
"github.com/mudler/xlog"
1212
)
1313

14+
// startMITMIfConfigured brings up the cloudproxy MITM listener when an
15+
// address is configured, treating any startup failure as non-fatal.
16+
//
17+
// The listener is opt-in middleware whose address is persisted in runtime
18+
// settings (/api/settings → runtime_settings.json) and replayed on every
19+
// boot. A bad value — e.g. a host the process can't bind, like a LAN IP
20+
// inside a container — must NOT abort the whole server: doing so crash-loops
21+
// with no way out, because the Settings UI used to correct the address can't
22+
// load if startup never completes. So on failure we log loudly and carry on;
23+
// the admin fixes the address via /api/settings, which calls RestartMITM.
24+
func startMITMIfConfigured(app *Application, options *config.ApplicationConfig) {
25+
if options.MITMListen == "" {
26+
return
27+
}
28+
if err := startMITMProxy(app, options); err != nil {
29+
xlog.Error("mitm: cloudproxy listener failed to start — continuing without it",
30+
"listen", options.MITMListen,
31+
"error", err,
32+
"hint", "fix the address via Settings (e.g. \":8082\" to bind all interfaces) and the listener will restart",
33+
)
34+
}
35+
}
36+
1437
func startMITMProxy(app *Application, options *config.ApplicationConfig) error {
1538
app.mitmMutex.Lock()
1639
defer app.mitmMutex.Unlock()

core/application/mitm_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package application
2+
3+
import (
4+
"github.com/mudler/LocalAI/core/config"
5+
"github.com/mudler/LocalAI/pkg/system"
6+
7+
. "github.com/onsi/ginkgo/v2"
8+
. "github.com/onsi/gomega"
9+
)
10+
11+
// minimal Application wired enough for startMITMProxy: an empty model
12+
// config loader (no host claims), CA written under a temp DataPath.
13+
func newMITMTestApp(dataPath string) (*Application, *config.ApplicationConfig) {
14+
state, err := system.GetSystemState()
15+
Expect(err).NotTo(HaveOccurred())
16+
state.Model.ModelsPath = dataPath
17+
opts := config.NewApplicationConfig(
18+
config.WithSystemState(state),
19+
config.WithDataPath(dataPath),
20+
)
21+
return newApplication(opts), opts
22+
}
23+
24+
var _ = Describe("startMITMIfConfigured", func() {
25+
It("does nothing when no listen address is configured", func() {
26+
app, opts := newMITMTestApp(GinkgoT().TempDir())
27+
opts.MITMListen = ""
28+
29+
Expect(func() { startMITMIfConfigured(app, opts) }).NotTo(Panic())
30+
Expect(app.mitmServer.Load()).To(BeNil(), "no listener should be stored when disabled")
31+
})
32+
33+
// Regression: a persisted-but-unbindable MITM address (e.g. a LAN host
34+
// inside a container) must not abort startup. startMITMIfConfigured
35+
// swallows the bind error so the rest of LocalAI still comes up and the
36+
// admin can fix the address via the Settings UI.
37+
It("logs and continues when the listen address cannot be bound", func() {
38+
app, opts := newMITMTestApp(GinkgoT().TempDir())
39+
// 192.0.2.1 is TEST-NET-1 (RFC 5737): guaranteed not assigned to any
40+
// local interface, so bind fails deterministically without DNS.
41+
opts.MITMListen = "192.0.2.1:8082"
42+
43+
Expect(func() { startMITMIfConfigured(app, opts) }).NotTo(Panic())
44+
Expect(app.mitmServer.Load()).To(BeNil(), "failed listener must not be stored")
45+
})
46+
47+
It("starts and stores the listener on a bindable address", func() {
48+
app, opts := newMITMTestApp(GinkgoT().TempDir())
49+
opts.MITMListen = "127.0.0.1:0" // OS-assigned free port
50+
51+
startMITMIfConfigured(app, opts)
52+
53+
srv := app.mitmServer.Load()
54+
Expect(srv).NotTo(BeNil(), "listener should be stored on success")
55+
DeferCleanup(srv.Stop)
56+
Expect(srv.Addr()).NotTo(BeEmpty())
57+
})
58+
})
Lines changed: 83 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,120 @@
11
package application
22

33
import (
4+
"context"
5+
"fmt"
6+
47
"github.com/mudler/LocalAI/core/backend"
58
"github.com/mudler/LocalAI/core/config"
69
)
710

8-
// adapterConfig resolves a model name to its runtime ModelConfig, or
9-
// nil when the name is unknown. Shared by the router-facing factories
10-
// below and by ModelConfigLookup.
11+
// adapterConfig resolves a model name to its runtime ModelConfig, or nil when
12+
// unknown. LoadModelConfigFileByNameDefaultOptions never returns nil — for an
13+
// unknown name it returns a defaults-filled stub with an empty Name (the YAML
14+
// `name:` field is required by Validate), which is how we tell the two apart.
1115
func (a *Application) adapterConfig(modelName string) *config.ModelConfig {
1216
cfg, err := a.backendLoader.LoadModelConfigFileByNameDefaultOptions(modelName, a.applicationConfig)
13-
if err != nil || cfg == nil {
17+
if err != nil || cfg == nil || cfg.Name == "" {
1418
return nil
1519
}
1620
return cfg
1721
}
1822

19-
// ModelConfigLookup is the lookup function the router middleware's
20-
// classifier validator uses to confirm classifier_model declares
21-
// FLAG_SCORE before binding it.
23+
// ModelConfigLookup is the lookup the router middleware's classifier validator
24+
// uses to confirm classifier_model declares FLAG_SCORE before binding it.
2225
func (a *Application) ModelConfigLookup() func(modelName string) *config.ModelConfig {
2326
return a.adapterConfig
2427
}
2528

26-
// Scorer returns a backend.Scorer bound to the named model, or nil
27-
// when the model is unknown. Used as a method value (app.Scorer) by
28-
// router.ClassifierDeps — no factory-of-factory wrapper needed.
29+
// The router-facing factories below (Scorer, Embedder, Reranker, TokenCounter)
30+
// bind a model NAME at construction and re-resolve the CONFIG on every call.
31+
// Capturing the config at construction would bake in whatever state
32+
// adapterConfig saw first — including a stub returned before the YAML reached
33+
// bcl.configs (e.g. /import-model or gallery install racing startup). The
34+
// classifier registry caches factories by router-config fingerprint, so a
35+
// once-stale capture stays stale until the router config is edited.
36+
2937
func (a *Application) Scorer(modelName string) backend.Scorer {
30-
cfg := a.adapterConfig(modelName)
38+
if a.adapterConfig(modelName) == nil {
39+
return nil
40+
}
41+
return &lazyScorer{app: a, modelName: modelName}
42+
}
43+
44+
type lazyScorer struct {
45+
app *Application
46+
modelName string
47+
}
48+
49+
func (l *lazyScorer) Score(ctx context.Context, prompt string, candidates []string) ([]backend.CandidateScore, error) {
50+
cfg := l.app.adapterConfig(l.modelName)
3151
if cfg == nil {
52+
return nil, fmt.Errorf("scorer: model %q no longer available", l.modelName)
53+
}
54+
return backend.NewScorer(l.app.modelLoader, *cfg, l.app.applicationConfig).Score(ctx, prompt, candidates)
55+
}
56+
57+
// TokenCounter returns a func so the middleware's literal field type accepts
58+
// it as a method value without importing core/http/middleware from here.
59+
func (a *Application) TokenCounter(modelName string) func(string) (int, error) {
60+
if a.adapterConfig(modelName) == nil {
3261
return nil
3362
}
34-
return backend.NewScorer(a.modelLoader, *cfg, a.applicationConfig)
63+
return func(text string) (int, error) {
64+
cfg := a.adapterConfig(modelName)
65+
if cfg == nil {
66+
return 0, fmt.Errorf("token counter: model %q no longer available", modelName)
67+
}
68+
resp, err := backend.ModelTokenize(text, a.modelLoader, *cfg, a.applicationConfig)
69+
if err != nil {
70+
return 0, err
71+
}
72+
return len(resp.Tokens), nil
73+
}
3574
}
3675

37-
// Reranker returns a backend.Reranker bound to the named model, or
38-
// nil when unknown. The reranker model's `type:` (e.g. "colbert")
39-
// selects the scoring head inside the rerankers backend.
4076
func (a *Application) Reranker(modelName string) backend.Reranker {
41-
cfg := a.adapterConfig(modelName)
42-
if cfg == nil {
77+
if a.adapterConfig(modelName) == nil {
4378
return nil
4479
}
45-
return backend.NewReranker(a.modelLoader, *cfg, a.applicationConfig)
80+
return &lazyReranker{app: a, modelName: modelName}
4681
}
4782

48-
// Embedder returns a backend.Embedder bound to the named model, or
49-
// nil when unknown. Used by the router's L2 embedding cache.
50-
func (a *Application) Embedder(modelName string) backend.Embedder {
51-
cfg := a.adapterConfig(modelName)
83+
type lazyReranker struct {
84+
app *Application
85+
modelName string
86+
}
87+
88+
func (l *lazyReranker) Rerank(ctx context.Context, query string, documents []string) ([]backend.RerankResult, error) {
89+
cfg := l.app.adapterConfig(l.modelName)
5290
if cfg == nil {
91+
return nil, fmt.Errorf("reranker: model %q no longer available", l.modelName)
92+
}
93+
return backend.NewReranker(l.app.modelLoader, *cfg, l.app.applicationConfig).Rerank(ctx, query, documents)
94+
}
95+
96+
func (a *Application) Embedder(modelName string) backend.Embedder {
97+
if a.adapterConfig(modelName) == nil {
5398
return nil
5499
}
55-
return backend.NewEmbedder(a.modelLoader, *cfg, a.applicationConfig)
100+
return &lazyEmbedder{app: a, modelName: modelName}
101+
}
102+
103+
type lazyEmbedder struct {
104+
app *Application
105+
modelName string
106+
}
107+
108+
func (l *lazyEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
109+
cfg := l.app.adapterConfig(l.modelName)
110+
if cfg == nil {
111+
return nil, fmt.Errorf("embedder: model %q no longer available", l.modelName)
112+
}
113+
return backend.NewEmbedder(l.app.modelLoader, *cfg, l.app.applicationConfig).Embed(ctx, text)
56114
}
57115

58-
// VectorStore returns a backend.VectorStore for the named collection,
59-
// or nil when the name is empty. Each router model gets its own
60-
// backend process via the model loader's cache keyed by storeName.
116+
// VectorStore takes a store name, not a model name — no adapterConfig, no
117+
// staleness to avoid.
61118
func (a *Application) VectorStore(storeName string) backend.VectorStore {
62119
return backend.NewVectorStore(a.modelLoader, a.applicationConfig, storeName)
63120
}

0 commit comments

Comments
 (0)