fix(routing): keep embedder fingerprint stable across probed reasoning

A chat completion sent to an embedding model whose config sets
use_tokenizer_template runs the thinking probe, and persistProbedReasoning
writes the probed reasoning slots back into the shared model config.
EmbedderFingerprint hashed the whole config, so that single request moved
the fingerprint and every knn router built on the embedder failed with
ErrLiveEmbeddingMismatch (HTTP 503) until LocalAI was restarted.

Reasoning settings never change the embedding space, so leave them out of
the hash.

Docs-Not-Needed: internal fingerprint computation, no user-facing config, flag or API change

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
(cherry picked from commit 46163dc1d4)
This commit is contained in:
Stefan Walcz authored and Ettore Di Giacinto committed 2026-10-13 08:06:49 +00:00
1 parent 2676dcafe8
commit bdd66e4b17
2 files changed
+30 -1

No files matched your search

+9 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/routing/corpus"
"github.com/mudler/LocalAI/pkg/reasoning"
"gopkg.in/yaml.v3"
)
@@ -119,7 +120,14 @@ func (a *Application) EmbedderFingerprint(modelName string) (string, error) {
if cfg == nil {
return "", fmt.Errorf("embedding model %q not available", modelName)
}
raw, err := yaml.Marshal(cfg)
// Reasoning never changes the embedding space, but a chat request against
// this model may persist probed reasoning slots into the shared config
// (persistProbedReasoning). Hashing them would move the fingerprint on
// such a request and leave every knn router built on this embedder failing
// with ErrLiveEmbeddingMismatch until restart.
hashed := *cfg
hashed.ReasoningConfig = reasoning.Config{}
raw, err := yaml.Marshal(&hashed)
if err != nil {
return "", fmt.Errorf("fingerprint embedding model %q config: %w", modelName, err)
}
+21
View File
@@ -137,6 +137,27 @@ var _ = Describe("router_factories lazy config resolution", func() {
Expect(updated).NotTo(Equal(replaced))
})
It("stays stable when a chat request persists probed reasoning slots", func() {
// A chat completion sent to an embedding model whose config sets
// use_tokenizer_template runs the thinking probe, and
// persistProbedReasoning writes the probed reasoning slots back into
// the shared config. Reasoning never changes the embedding space, so
// the fingerprint must not move — otherwise every knn router built
// on this embedder fails with ErrLiveEmbeddingMismatch until restart.
writeCfg("emb-probe", "llama-cpp")
before, err := app.EmbedderFingerprint("emb-probe")
Expect(err).NotTo(HaveOccurred())
disable, prefill := true, false
app.backendLoader.UpdateModelConfig("emb-probe", func(c *config.ModelConfig) {
c.ReasoningConfig.DisableReasoning = &disable
c.ReasoningConfig.DisableReasoningTagPrefill = &prefill
})
after, err := app.EmbedderFingerprint("emb-probe")
Expect(err).NotTo(HaveOccurred())
Expect(after).To(Equal(before))
})
It("rejects an unknown model", func() {
_, err := app.EmbedderFingerprint("missing")
Expect(err).To(HaveOccurred())