Files
LocalAI/core/services/modeladmin/config_test.go
Richard Palethorpe 3fa7b2955c feat(pii): NER tier engine — privacy-filter.cpp backend + NER-centric PII filter (#10360)
Squashed feat/pii-ner-tier-engine rebased onto master (was 45 commits; see
backup/pii-ner-tier-engine-prerebase). Net change:

- privacy-filter.cpp: standalone GGML engine for the openai-privacy-filter
  PII/NER token classifier, wired as a LocalAI gRPC backend (CPU/CUDA/Vulkan).
  TokenClassify moves off the patched llama.cpp path onto this backend.
- PII filter reworked to be NER-centric (encoder/NER detection tier scanning
  whole conversations as one document), with a recreated bounded restricted-
  regex secret-matching pattern detector tier alongside it (per-model
  pii_detection.builtins / .patterns + core/services/routing/piipattern).
- Detection labelled by source (ner vs pattern); backend trace / confidence /
  debug observability; analyze/redact exposed as a synchronous API.
- Instance-wide default detector policy + per-usecase default-on; request
  filtering extended to completions, embeddings, edits & Ollama.
- React UI: NER-centric PII editor, detector-models table, pattern/builtins
  editor, middleware default-policy UI.
- Gallery: privacy-filter-multilingual token-classify model + NER install
  filter; token_classify known_usecase; batch sized to context for NER models.
  privacy-filter backend registered in the backend gallery (cpu/vulkan/cuda-13
  meta + image entries with a capabilities map) matching its CI matrix jobs,
  and an /import-model auto-detect importer (PrivacyFilterImporter, narrow
  privacy-filter GGUF detection) replacing the prior pref-only registration.

Reconciled against master's independent evolution:

- Dropped master's PIIPatternOverrides feature (global-pattern runtime
  overrides + /api/pii/patterns API + runtime_settings.json persistence). The
  per-model NER + pattern-detector design supersedes it; it was built on the
  global redactor pattern set this branch replaced.
- Reverted the llama.cpp Score carry-patch (0006-server-task-type-score):
  removed the patch and restored master's grpc-server.cpp Score RPC (direct
  llama_decode, slot-loop bypass) and LLAMA_VERSION pin, plus master's
  model_config validation forbidding score + chat/completion/embeddings on
  llama-cpp. token_classify is unaffected (it runs on the privacy-filter
  backend, not llama-cpp).

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-06-18 11:45:22 +01:00

216 lines
7.4 KiB
Go

package modeladmin
import (
"context"
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/system"
)
// newTestService stands up a ConfigService backed by a tmp dir so the file IO
// is real but isolated. The model loader is loaded against the same tmp path
// so GetModelConfig works.
func newTestService() (*ConfigService, string) {
dir := GinkgoT().TempDir()
loader := config.NewModelConfigLoader(dir)
appConfig := &config.ApplicationConfig{
SystemState: &system.SystemState{Model: system.Model{ModelsPath: dir}},
}
return NewConfigService(loader, appConfig), dir
}
// writeModelYAML creates a model YAML on disk and reloads the loader so the
// new entry is visible.
func writeModelYAML(svc *ConfigService, dir, name string, body map[string]any) {
body["name"] = name
data, err := yaml.Marshal(body)
Expect(err).ToNot(HaveOccurred())
path := filepath.Join(dir, name+".yaml")
Expect(os.WriteFile(path, data, 0644)).To(Succeed())
Expect(svc.Loader.LoadModelConfigsFromPath(dir, svc.AppConfig.ToConfigLoaderOptions()...)).To(Succeed())
}
var _ = Describe("ConfigService", func() {
var (
svc *ConfigService
dir string
ctx context.Context
)
BeforeEach(func() {
svc, dir = newTestService()
ctx = context.Background()
})
Describe("GetConfig", func() {
It("round-trips YAML from disk and exposes the parsed JSON", func() {
writeModelYAML(svc, dir, "qwen", map[string]any{"backend": "llama-cpp", "context_size": 4096})
view, err := svc.GetConfig(ctx, "qwen")
Expect(err).ToNot(HaveOccurred())
Expect(view.Name).To(Equal("qwen"))
Expect(view.JSON).To(HaveKeyWithValue("backend", "llama-cpp"))
})
It("returns ErrNotFound for an unknown model", func() {
_, err := svc.GetConfig(ctx, "missing")
Expect(err).To(MatchError(ErrNotFound))
})
It("returns ErrNameRequired when name is empty", func() {
_, err := svc.GetConfig(ctx, "")
Expect(err).To(MatchError(ErrNameRequired))
})
})
Describe("PatchConfig", func() {
It("deep-merges the patch and preserves untouched siblings", func() {
writeModelYAML(svc, dir, "qwen", map[string]any{
"backend": "llama-cpp",
"context_size": 4096,
"parameters": map[string]any{"temperature": 0.7, "top_p": 0.9},
})
updated, err := svc.PatchConfig(ctx, "qwen", map[string]any{
"context_size": 8192,
"parameters": map[string]any{"temperature": 0.5},
})
Expect(err).ToNot(HaveOccurred())
Expect(updated.Name).To(Equal("qwen"))
raw, err := os.ReadFile(filepath.Join(dir, "qwen.yaml"))
Expect(err).ToNot(HaveOccurred())
var got map[string]any
Expect(yaml.Unmarshal(raw, &got)).To(Succeed())
Expect(got).To(HaveKeyWithValue("context_size", 8192))
params, ok := got["parameters"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(params).To(HaveKeyWithValue("temperature", 0.5))
// top_p must still be there: deep-merge should NOT clobber siblings.
Expect(params).To(HaveKeyWithValue("top_p", 0.9))
})
It("returns ErrNotFound for an unknown model", func() {
_, err := svc.PatchConfig(ctx, "ghost", map[string]any{"x": 1})
Expect(err).To(MatchError(ErrNotFound))
})
It("rejects an empty patch with ErrEmptyBody", func() {
writeModelYAML(svc, dir, "qwen", map[string]any{"backend": "llama-cpp"})
_, err := svc.PatchConfig(ctx, "qwen", map[string]any{})
Expect(err).To(MatchError(ErrEmptyBody))
})
It("replaces a map field wholesale so deleted entries do not survive", func() {
// A detector model with a populated entity_actions map. The editor
// removes SSN and re-sends the remaining map; a naive deep-merge
// would re-add SSN (it only adds/overrides keys, never deletes).
writeModelYAML(svc, dir, "ner", map[string]any{
"backend": "llama-cpp",
"known_usecases": []any{"token_classify"},
"pii_detection": map[string]any{
"default_action": "mask",
"entity_actions": map[string]any{"SSN": "block", "EMAIL": "mask"},
},
})
_, err := svc.PatchConfig(ctx, "ner", map[string]any{
"pii_detection": map[string]any{
"default_action": "mask",
"entity_actions": map[string]any{"EMAIL": "mask"},
},
})
Expect(err).ToNot(HaveOccurred())
raw, err := os.ReadFile(filepath.Join(dir, "ner.yaml"))
Expect(err).ToNot(HaveOccurred())
var got map[string]any
Expect(yaml.Unmarshal(raw, &got)).To(Succeed())
pii := got["pii_detection"].(map[string]any)
ea := pii["entity_actions"].(map[string]any)
Expect(ea).To(HaveKeyWithValue("EMAIL", "mask"))
Expect(ea).NotTo(HaveKey("SSN"), "deleted map entry must not survive the patch")
// The scalar sibling in the same nested block is still preserved.
Expect(pii).To(HaveKeyWithValue("default_action", "mask"))
})
It("drops a map field entirely when the patch empties it", func() {
writeModelYAML(svc, dir, "ner", map[string]any{
"backend": "llama-cpp",
"known_usecases": []any{"token_classify"},
"pii_detection": map[string]any{
"default_action": "mask",
"entity_actions": map[string]any{"SSN": "block"},
},
})
_, err := svc.PatchConfig(ctx, "ner", map[string]any{
"pii_detection": map[string]any{
"entity_actions": map[string]any{},
},
})
Expect(err).ToNot(HaveOccurred())
raw, err := os.ReadFile(filepath.Join(dir, "ner.yaml"))
Expect(err).ToNot(HaveOccurred())
var got map[string]any
Expect(yaml.Unmarshal(raw, &got)).To(Succeed())
pii := got["pii_detection"].(map[string]any)
Expect(pii).NotTo(HaveKey("entity_actions"))
})
})
Describe("EditYAML", func() {
It("renames the on-disk file and reindexes the loader", func() {
writeModelYAML(svc, dir, "old-name", map[string]any{"backend": "llama-cpp"})
body := []byte("name: new-name\nbackend: llama-cpp\n")
result, err := svc.EditYAML(ctx, "old-name", body, nil)
Expect(err).ToNot(HaveOccurred())
Expect(result.Renamed).To(BeTrue())
Expect(result.OldName).To(Equal("old-name"))
Expect(result.NewName).To(Equal("new-name"))
_, err = os.Stat(filepath.Join(dir, "old-name.yaml"))
Expect(os.IsNotExist(err)).To(BeTrue(), "old YAML should be removed")
_, err = os.Stat(filepath.Join(dir, "new-name.yaml"))
Expect(err).ToNot(HaveOccurred(), "new YAML should exist")
_, ok := svc.Loader.GetModelConfig("new-name")
Expect(ok).To(BeTrue(), "loader should have the renamed model")
_, ok = svc.Loader.GetModelConfig("old-name")
Expect(ok).To(BeFalse(), "loader should not retain the old name")
})
It("refuses a rename that would clobber an existing model", func() {
writeModelYAML(svc, dir, "alpha", map[string]any{"backend": "llama-cpp"})
writeModelYAML(svc, dir, "beta", map[string]any{"backend": "llama-cpp"})
body := []byte("name: beta\nbackend: llama-cpp\n")
_, err := svc.EditYAML(ctx, "alpha", body, nil)
Expect(err).To(MatchError(ErrConflict))
})
It("rejects path-separator characters in the new name", func() {
writeModelYAML(svc, dir, "alpha", map[string]any{"backend": "llama-cpp"})
body := []byte("name: ../escape\nbackend: llama-cpp\n")
_, err := svc.EditYAML(ctx, "alpha", body, nil)
Expect(err).To(MatchError(ErrPathSeparator))
})
It("returns ErrEmptyBody when the body is nil", func() {
writeModelYAML(svc, dir, "alpha", map[string]any{"backend": "llama-cpp"})
_, err := svc.EditYAML(ctx, "alpha", nil, nil)
Expect(err).To(MatchError(ErrEmptyBody))
})
})
})