package config import ( "io" "net/http" "os" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "gopkg.in/yaml.v3" ) var _ = Describe("Test cases for config related functions", func() { Context("ModelID", func() { It("returns Name when set", func() { c := ModelConfig{Name: "my-name"} c.Model = "my-model" Expect(c.ModelID()).To(Equal("my-name")) }) It("falls back to Model when Name is empty", func() { c := ModelConfig{} c.Model = "my-model" Expect(c.ModelID()).To(Equal("my-model")) }) It("returns empty string when both are empty", func() { c := ModelConfig{} Expect(c.ModelID()).To(Equal("")) }) }) Context("Test Read configuration functions", func() { It("Test Validate", func() { tmp, err := os.CreateTemp("", "config.yaml") Expect(err).To(BeNil()) defer os.Remove(tmp.Name()) _, err = tmp.WriteString( `backend: "../foo-bar" name: "foo" parameters: model: "foo-bar" known_usecases: - chat - COMPLETION `) Expect(err).ToNot(HaveOccurred()) configs, err := readModelConfigsFromFile(tmp.Name()) config := configs[0] Expect(err).To(BeNil()) Expect(config).ToNot(BeNil()) valid, err := config.Validate() Expect(err).To(HaveOccurred()) Expect(valid).To(BeFalse()) Expect(config.KnownUsecases).ToNot(BeNil()) }) It("Test Validate", func() { tmp, err := os.CreateTemp("", "config.yaml") Expect(err).To(BeNil()) defer os.Remove(tmp.Name()) _, err = tmp.WriteString( `name: bar-baz backend: "foo-bar" parameters: model: "foo-bar"`) Expect(err).ToNot(HaveOccurred()) configs, err := readModelConfigsFromFile(tmp.Name()) config := configs[0] Expect(err).To(BeNil()) Expect(config).ToNot(BeNil()) // two configs in config.yaml Expect(config.Name).To(Equal("bar-baz")) valid, err := config.Validate() Expect(err).To(BeNil()) Expect(valid).To(BeTrue()) // llama-cpp configs can't mix the score usecase with // chat/completion/embeddings — Score bypasses the slot loop // and would race the llama_context. (token_classify is exempt: // it runs on the privacy-filter backend, not llama-cpp, so the // token_classify combinations below stay valid.) scoreFlag := FLAG_SCORE | FLAG_CHAT conflicting := ModelConfig{ Name: "router-but-also-chat", Backend: "llama-cpp", KnownUsecases: &scoreFlag, } valid, err = conflicting.Validate() Expect(valid).To(BeFalse()) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("score is incompatible")) scoreOnly := FLAG_SCORE dedicated := ModelConfig{ Name: "router-only", Backend: "llama-cpp", KnownUsecases: &scoreOnly, } valid, err = dedicated.Validate() Expect(valid).To(BeTrue()) Expect(err).NotTo(HaveOccurred()) tcAndChat := FLAG_TOKEN_CLASSIFY | FLAG_CHAT tcCombined := ModelConfig{ Name: "ner-and-chat", Backend: "llama-cpp", KnownUsecases: &tcAndChat, } valid, err = tcCombined.Validate() Expect(valid).To(BeTrue()) Expect(err).NotTo(HaveOccurred()) tcAndEmbeddings := FLAG_TOKEN_CLASSIFY | FLAG_EMBEDDINGS tcWithEmbeddings := ModelConfig{ Name: "pii-ner", Backend: "llama-cpp", KnownUsecases: &tcAndEmbeddings, } valid, err = tcWithEmbeddings.Validate() Expect(valid).To(BeTrue()) Expect(err).NotTo(HaveOccurred()) // Cloud-proxy: api_key_env and api_key_file are mutually // exclusive — picking both is a config bug we catch at // load/save rather than at backend-load time. bothKeys := ModelConfig{ Name: "both-keys", Backend: "cloud-proxy", Proxy: ProxyConfig{ UpstreamURL: "https://example.com/v1", APIKeyEnv: "OPENAI_KEY", APIKeyFile: "/run/secrets/openai", }, } valid, err = bothKeys.Validate() Expect(valid).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("mutually exclusive"))) // Translate mode requires a provider — without one, the // backend has no way to pick a wire format. translateNoProvider := ModelConfig{ Name: "translate-no-provider", Backend: "cloud-proxy", Proxy: ProxyConfig{UpstreamURL: "https://example.com/v1", Mode: ProxyModeTranslate}, } valid, err = translateNoProvider.Validate() Expect(valid).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("translate mode requires provider"))) // Unknown mode is rejected. badMode := ModelConfig{ Name: "bad-mode", Backend: "cloud-proxy", Proxy: ProxyConfig{UpstreamURL: "https://example.com/v1", Mode: "rewrite"}, } valid, err = badMode.Validate() Expect(valid).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("unknown mode"))) // Passthrough (default) with one key source is happy. passthroughOK := ModelConfig{ Name: "passthrough-ok", Backend: "cloud-proxy", Proxy: ProxyConfig{UpstreamURL: "https://example.com/v1", APIKeyEnv: "OPENAI_KEY"}, } valid, err = passthroughOK.Validate() Expect(valid).To(BeTrue()) Expect(err).NotTo(HaveOccurred()) // router.score_normalization: load-time rejection of an // unknown value. The classifier consumes it lazily, so // without this validation a YAML typo wouldn't surface // until the first router request panicked deep in // NewScoreClassifier. badNorm := ModelConfig{ Name: "bad-norm", Router: RouterConfig{ ScoreNormalization: "men", // typo of "mean" }, } valid, err = badNorm.Validate() Expect(valid).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("unknown score_normalization"))) // Accepted values pass. for _, mode := range []string{"", ScoreNormalizationRaw, ScoreNormalizationMean} { goodNorm := ModelConfig{ Name: "good-norm-" + mode, Router: RouterConfig{ScoreNormalization: mode}, } valid, err = goodNorm.Validate() Expect(valid).To(BeTrue(), "score_normalization=%q should be accepted", mode) Expect(err).NotTo(HaveOccurred()) } // router.classifier_system_template: parse-time rejection // of malformed Go templates. Same reasoning as above — // without this the parse error wouldn't surface until // the first router request panicked in NewScoreClassifier. badTmpl := ModelConfig{ Name: "bad-tmpl", Router: RouterConfig{ ClassifierSystemTemplate: "Routes: {{range .Policies", }, } valid, err = badTmpl.Validate() Expect(valid).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("classifier_system_template parse error"))) // Well-formed template passes. goodTmpl := ModelConfig{ Name: "good-tmpl", Router: RouterConfig{ ClassifierSystemTemplate: `Routes: {{range .Policies}}{{.Label}} {{end}}`, }, } valid, err = goodTmpl.Validate() Expect(valid).To(BeTrue()) Expect(err).NotTo(HaveOccurred()) // download https://raw.githubusercontent.com/mudler/LocalAI/v2.25.0/embedded/models/hermes-2-pro-mistral.yaml httpClient := http.Client{} resp, err := httpClient.Get("https://raw.githubusercontent.com/mudler/LocalAI/v2.25.0/embedded/models/hermes-2-pro-mistral.yaml") Expect(err).To(BeNil()) defer resp.Body.Close() tmp, err = os.CreateTemp("", "config.yaml") Expect(err).To(BeNil()) defer os.Remove(tmp.Name()) _, err = io.Copy(tmp, resp.Body) Expect(err).To(BeNil()) configs, err = readModelConfigsFromFile(tmp.Name()) config = configs[0] Expect(err).To(BeNil()) Expect(config).ToNot(BeNil()) // two configs in config.yaml Expect(config.Name).To(Equal("hermes-2-pro-mistral")) valid, err = config.Validate() Expect(err).To(BeNil()) Expect(valid).To(BeTrue()) }) }) It("Properly handles backend usecase matching", func() { a := ModelConfig{ Name: "a", } Expect(a.HasUsecases(FLAG_ANY)).To(BeTrue()) // FLAG_ANY just means the config _exists_ essentially. b := ModelConfig{ Name: "b", Backend: "stablediffusion", } Expect(b.HasUsecases(FLAG_ANY)).To(BeTrue()) Expect(b.HasUsecases(FLAG_IMAGE)).To(BeTrue()) Expect(b.HasUsecases(FLAG_CHAT)).To(BeFalse()) c := ModelConfig{ Name: "c", Backend: "llama-cpp", TemplateConfig: TemplateConfig{ Chat: "chat", }, } Expect(c.HasUsecases(FLAG_ANY)).To(BeTrue()) Expect(c.HasUsecases(FLAG_IMAGE)).To(BeFalse()) Expect(c.HasUsecases(FLAG_COMPLETION)).To(BeFalse()) Expect(c.HasUsecases(FLAG_CHAT)).To(BeTrue()) d := ModelConfig{ Name: "d", Backend: "llama-cpp", TemplateConfig: TemplateConfig{ Chat: "chat", Completion: "completion", }, } Expect(d.HasUsecases(FLAG_ANY)).To(BeTrue()) Expect(d.HasUsecases(FLAG_IMAGE)).To(BeFalse()) Expect(d.HasUsecases(FLAG_COMPLETION)).To(BeTrue()) Expect(d.HasUsecases(FLAG_CHAT)).To(BeTrue()) trueValue := true e := ModelConfig{ Name: "e", Backend: "llama-cpp", TemplateConfig: TemplateConfig{ Completion: "completion", }, Embeddings: &trueValue, } Expect(e.HasUsecases(FLAG_ANY)).To(BeTrue()) Expect(e.HasUsecases(FLAG_IMAGE)).To(BeFalse()) Expect(e.HasUsecases(FLAG_COMPLETION)).To(BeTrue()) Expect(e.HasUsecases(FLAG_CHAT)).To(BeFalse()) Expect(e.HasUsecases(FLAG_EMBEDDINGS)).To(BeTrue()) // Router models are chat dispatchers: no chat template of their // own, but invoked through the chat endpoint, so they default to // chat-capable. r := ModelConfig{ Name: "r", Router: RouterConfig{ Candidates: []RouterCandidate{{Model: "downstream", Labels: []string{"general"}}}, }, } Expect(r.HasUsecases(FLAG_ANY)).To(BeTrue()) Expect(r.HasUsecases(FLAG_CHAT)).To(BeTrue()) f := ModelConfig{ Name: "f", Backend: "piper", } Expect(f.HasUsecases(FLAG_ANY)).To(BeTrue()) Expect(f.HasUsecases(FLAG_TTS)).To(BeTrue()) Expect(f.HasUsecases(FLAG_CHAT)).To(BeFalse()) g := ModelConfig{ Name: "g", Backend: "whisper", } Expect(g.HasUsecases(FLAG_ANY)).To(BeTrue()) Expect(g.HasUsecases(FLAG_TRANSCRIPT)).To(BeTrue()) Expect(g.HasUsecases(FLAG_TTS)).To(BeFalse()) h := ModelConfig{ Name: "h", Backend: "transformers-musicgen", } Expect(h.HasUsecases(FLAG_ANY)).To(BeTrue()) Expect(h.HasUsecases(FLAG_TRANSCRIPT)).To(BeFalse()) Expect(h.HasUsecases(FLAG_TTS)).To(BeTrue()) Expect(h.HasUsecases(FLAG_SOUND_GENERATION)).To(BeTrue()) knownUsecases := FLAG_CHAT | FLAG_COMPLETION i := ModelConfig{ Name: "i", Backend: "whisper", // Earlier test checks parsing, this just needs to set final values KnownUsecases: &knownUsecases, } Expect(i.HasUsecases(FLAG_ANY)).To(BeTrue()) Expect(i.HasUsecases(FLAG_TRANSCRIPT)).To(BeTrue()) Expect(i.HasUsecases(FLAG_TTS)).To(BeFalse()) Expect(i.HasUsecases(FLAG_COMPLETION)).To(BeTrue()) Expect(i.HasUsecases(FLAG_CHAT)).To(BeTrue()) // Declared `known_usecases: [score]` is authoritative — the // guessing heuristic must NOT add chat on top, even though the // inherited chatml template would otherwise satisfy the chat // heuristic. A score-only declaration means "this model is // reserved for the router classifier"; surfacing it as a chat // model defeats the reservation. (Operators who do want both // may declare both — the combination is supported.) scoreReserved := FLAG_SCORE j := ModelConfig{ Name: "arch-router", Backend: "llama-cpp", KnownUsecases: &scoreReserved, TemplateConfig: TemplateConfig{ Chat: "inherited from chatml", ChatMessage: "inherited from chatml", Completion: "inherited from chatml", }, } Expect(j.HasUsecases(FLAG_SCORE)).To(BeTrue()) Expect(j.HasUsecases(FLAG_CHAT)).To(BeFalse()) Expect(j.HasUsecases(FLAG_COMPLETION)).To(BeFalse()) Expect(j.HasUsecases(FLAG_EMBEDDINGS)).To(BeFalse()) // Declared `known_usecases: [token_classify]` is likewise // authoritative — a PII NER model is reserved for the redactor's // NER tier and must not surface as chat or as a general embeddings // model, even though it loads with embeddings enabled (its // TOKEN_CLS head produces BIOES logits, not reusable embeddings). tcReserved := FLAG_TOKEN_CLASSIFY embTrue := true k := ModelConfig{ Name: "privacy-filter", Backend: "llama-cpp", KnownUsecases: &tcReserved, Embeddings: &embTrue, TemplateConfig: TemplateConfig{ Chat: "inherited from chatml", ChatMessage: "inherited from chatml", }, } Expect(k.HasUsecases(FLAG_TOKEN_CLASSIFY)).To(BeTrue()) Expect(k.HasUsecases(FLAG_CHAT)).To(BeFalse()) Expect(k.HasUsecases(FLAG_EMBEDDINGS)).To(BeFalse()) }) It("Test Validate with invalid MCP config", func() { tmp, err := os.CreateTemp("", "config.yaml") Expect(err).To(BeNil()) defer os.Remove(tmp.Name()) _, err = tmp.WriteString( `name: test-mcp backend: "llama-cpp" mcp: stdio: | { "mcpServers": { "ddg": { "command": "/docker/docker", "args": ["run", "-i"] } "weather": { "command": "/docker/docker", "args": ["run", "-i"] } } }`) Expect(err).ToNot(HaveOccurred()) configs, err := readModelConfigsFromFile(tmp.Name()) config := configs[0] Expect(err).To(BeNil()) Expect(config).ToNot(BeNil()) valid, err := config.Validate() Expect(err).To(HaveOccurred()) Expect(valid).To(BeFalse()) Expect(err.Error()).To(ContainSubstring("invalid MCP configuration")) }) It("Test Validate with valid MCP config", func() { tmp, err := os.CreateTemp("", "config.yaml") Expect(err).To(BeNil()) defer os.Remove(tmp.Name()) _, err = tmp.WriteString( `name: test-mcp-valid backend: "llama-cpp" mcp: stdio: | { "mcpServers": { "ddg": { "command": "/docker/docker", "args": ["run", "-i"] }, "weather": { "command": "/docker/docker", "args": ["run", "-i"] } } }`) Expect(err).ToNot(HaveOccurred()) configs, err := readModelConfigsFromFile(tmp.Name()) config := configs[0] Expect(err).To(BeNil()) Expect(config).ToNot(BeNil()) valid, err := config.Validate() Expect(err).To(BeNil()) Expect(valid).To(BeTrue()) }) It("Test Validate rejects unmarshalable engine_args", func() { // chan values cannot be JSON-marshalled. A valid YAML config could // not produce one, but a Go caller stuffing a bad value would, and // silently dropping it would change runtime behaviour. cfg := &ModelConfig{ Backend: "vllm", LLMConfig: LLMConfig{ EngineArgs: map[string]any{ "speculative_config": make(chan int), }, }, } valid, err := cfg.Validate() Expect(valid).To(BeFalse()) Expect(err).ToNot(BeNil()) Expect(err.Error()).To(ContainSubstring("engine_args is not JSON-serialisable")) }) It("Test Validate accepts well-formed engine_args", func() { cfg := &ModelConfig{ Backend: "vllm", LLMConfig: LLMConfig{ EngineArgs: map[string]any{ "data_parallel_size": 8, "speculative_config": map[string]any{ "method": "ngram", "num_speculative_tokens": 4, }, }, }, } valid, err := cfg.Validate() Expect(err).To(BeNil()) Expect(valid).To(BeTrue()) }) Context("ConcurrencyGroups", func() { It("returns nil when no groups are configured", func() { cfg := &ModelConfig{Name: "no-groups"} Expect(cfg.GetConcurrencyGroups()).To(BeNil()) }) It("returns nil when all entries are blank", func() { cfg := &ModelConfig{ Name: "blanks", ConcurrencyGroups: []string{"", " ", "\t"}, } Expect(cfg.GetConcurrencyGroups()).To(BeNil()) }) It("trims whitespace, drops empty entries, and dedupes", func() { cfg := &ModelConfig{ Name: "messy", ConcurrencyGroups: []string{" vram-heavy ", "", "vram-heavy", "vision", " vision "}, } Expect(cfg.GetConcurrencyGroups()).To(Equal([]string{"vram-heavy", "vision"})) }) It("returns a defensive copy", func() { cfg := &ModelConfig{ Name: "copy", ConcurrencyGroups: []string{"heavy"}, } got := cfg.GetConcurrencyGroups() got[0] = "tampered" Expect(cfg.GetConcurrencyGroups()).To(Equal([]string{"heavy"})) }) It("parses concurrency_groups from YAML", func() { tmp, err := os.CreateTemp("", "concgroups.yaml") Expect(err).To(BeNil()) defer func() { _ = os.Remove(tmp.Name()) }() _, err = tmp.WriteString( `name: heavy-a backend: llama-cpp parameters: model: heavy-a.gguf concurrency_groups: - vram-heavy - "120b" `) Expect(err).ToNot(HaveOccurred()) configs, err := readModelConfigsFromFile(tmp.Name()) Expect(err).To(BeNil()) Expect(configs).To(HaveLen(1)) Expect(configs[0].ConcurrencyGroups).To(Equal([]string{"vram-heavy", "120b"})) Expect(configs[0].GetConcurrencyGroups()).To(Equal([]string{"vram-heavy", "120b"})) }) }) // When templating is delegated to the backend (use_tokenizer_template), // the backend also owns tool-call grammar generation and parsing. A // LocalAI-generated grammar sent alongside would override the backend's // native (name-first) tool pipeline and make it stream the tool-call JSON // back as plain content (issue #10052). SetDefaults must therefore couple // the two: tokenizer template implies grammar generation is disabled. Context("use_tokenizer_template couples with grammar disable (issue #10052)", func() { It("disables Go grammar generation when the tokenizer template is used", func() { cfg := &ModelConfig{ TemplateConfig: TemplateConfig{UseTokenizerTemplate: true}, } Expect(cfg.FunctionsConfig.GrammarConfig.NoGrammar).To(BeFalse()) cfg.SetDefaults() Expect(cfg.FunctionsConfig.GrammarConfig.NoGrammar).To(BeTrue(), "use_tokenizer_template must imply grammar.disable so tools go to the backend's native pipeline") }) It("leaves grammar generation enabled when the tokenizer template is not used", func() { cfg := &ModelConfig{} cfg.SetDefaults() Expect(cfg.FunctionsConfig.GrammarConfig.NoGrammar).To(BeFalse(), "models that template in Go still rely on the Go-generated grammar") }) }) // The default top_k=40 is llama.cpp's sampling default and is WRONG for // backends whose native default differs. mlx_lm's intended default is // top_k=0 (disabled) and mlx does not remap 0->40, so injecting 40 silently // changes sampling for mlx clients that omit top_k (issue #6632). Gate the // injection on backend family: keep 40 for the llama.cpp family and for the // empty/auto backend (the GGUF auto-detect path resolves to llama.cpp), but // leave TopK nil for the mlx family so the wire value is 0. Context("TopK default is backend-gated (issue #6632)", func() { It("injects top_k=40 for the llama.cpp backend", func() { cfg := &ModelConfig{} cfg.Backend = "llama-cpp" cfg.SetDefaults() Expect(cfg.TopK).NotTo(BeNil(), "llama.cpp must keep its top_k=40 default") Expect(*cfg.TopK).To(Equal(40)) }) It("injects top_k=40 for the empty/auto backend (GGUF auto-detect)", func() { cfg := &ModelConfig{} cfg.SetDefaults() Expect(cfg.TopK).NotTo(BeNil(), "empty backend resolves to llama.cpp; default unchanged") Expect(*cfg.TopK).To(Equal(40)) }) It("leaves TopK nil for the mlx backend", func() { cfg := &ModelConfig{} cfg.Backend = "mlx" cfg.SetDefaults() Expect(cfg.TopK).To(BeNil(), "mlx_lm's intended default is top_k=0 (disabled); LocalAI must not inject 40") }) It("leaves TopK nil for the mlx-vlm backend", func() { cfg := &ModelConfig{} cfg.Backend = "mlx-vlm" cfg.SetDefaults() Expect(cfg.TopK).To(BeNil()) }) It("leaves TopK nil for the mlx-distributed backend", func() { cfg := &ModelConfig{} cfg.Backend = "mlx-distributed" cfg.SetDefaults() Expect(cfg.TopK).To(BeNil()) }) It("respects an explicit top_k even for the mlx backend", func() { explicit := 7 cfg := &ModelConfig{} cfg.Backend = "mlx" cfg.TopK = &explicit cfg.SetDefaults() Expect(cfg.TopK).NotTo(BeNil()) Expect(*cfg.TopK).To(Equal(7)) }) }) }) var _ = Describe("PII config accessors", func() { It("PIIDetectors returns a fresh copy of the consumer's detector list", func() { cfg := &ModelConfig{PII: PIIConfig{Detectors: []string{"a", "b"}}} got := cfg.PIIDetectors() Expect(got).To(Equal([]string{"a", "b"})) got[0] = "mutated" Expect(cfg.PII.Detectors[0]).To(Equal("a"), "accessor must not alias the underlying slice") }) It("PIIDetectors is nil when none are configured", func() { Expect((&ModelConfig{}).PIIDetectors()).To(BeNil()) }) It("exposes the detector model's pii_detection policy", func() { cfg := &ModelConfig{PIIDetection: PIIDetectionConfig{ MinScore: 0.5, DefaultAction: "mask", EntityActions: map[string]string{"PASSWORD": "block", "EMAIL": "mask"}, }} Expect(cfg.PIIDetectionMinScore()).To(BeNumerically("~", 0.5, 1e-6)) Expect(cfg.PIIDetectionDefaultAction()).To(Equal("mask")) ea := cfg.PIIDetectionEntityActions() Expect(ea).To(HaveKeyWithValue("PASSWORD", "block")) ea["PASSWORD"] = "mutated" Expect(cfg.PIIDetection.EntityActions["PASSWORD"]).To(Equal("block"), "accessor must return a fresh map") }) It("unmarshals pii.detectors and pii_detection from YAML", func() { var cfg ModelConfig raw := []byte("name: consumer\npii:\n enabled: true\n detectors: [pf]\npii_detection:\n min_score: 0.4\n default_action: mask\n entity_actions:\n PASSWORD: block\n") Expect(yaml.Unmarshal(raw, &cfg)).To(Succeed()) Expect(cfg.PIIDetectors()).To(Equal([]string{"pf"})) Expect(cfg.PIIDetectionDefaultAction()).To(Equal("mask")) Expect(cfg.PIIDetectionEntityActions()).To(HaveKeyWithValue("PASSWORD", "block")) }) }) var _ = Describe("GGUF importer chat-default guard (reservedNonChatModel)", func() { mk := func(flags ModelConfigUsecase) *ModelConfig { return &ModelConfig{Backend: "llama-cpp", KnownUsecases: &flags} } It("treats declared score / token_classify models as reserved (no chat defaults)", func() { Expect(reservedNonChatModel(mk(FLAG_SCORE))).To(BeTrue()) Expect(reservedNonChatModel(mk(FLAG_TOKEN_CLASSIFY))).To(BeTrue()) // embeddings declared alongside token_classify (the PII NER shape) is // still reserved. Expect(reservedNonChatModel(mk(FLAG_TOKEN_CLASSIFY | FLAG_EMBEDDINGS))).To(BeTrue()) }) It("does not reserve ordinary or undeclared models", func() { Expect(reservedNonChatModel(mk(FLAG_CHAT))).To(BeFalse()) Expect(reservedNonChatModel(mk(FLAG_EMBEDDINGS))).To(BeFalse()) Expect(reservedNonChatModel(&ModelConfig{Backend: "llama-cpp"})).To(BeFalse()) }) It("keeps a token_classify GGUF config valid by withholding FLAG_CHAT", func() { // The privacy-filter import shape: the GGUF importer appends FLAG_CHAT // to a templateless model, which the next sync folds into // KnownUsecases. token_classify+chat is a VALID combination // (token_classify runs on the privacy-filter backend, not llama-cpp, // so the score/chat conflict check does not apply to it), but the // importer must still not paint a declared-reserved model as chat // — that would surface it in every chat picker. reserved := []string{"token_classify"} withChat := append(append([]string{}, reserved...), "FLAG_CHAT") // What the importer would produce WITHOUT the guard: valid (the // score/chat conflict check is score-specific), just undesirable // defaults. combined := &ModelConfig{Backend: "llama-cpp", KnownUsecaseStrings: withChat} combined.syncKnownUsecasesFromString() valid, err := combined.Validate() Expect(valid).To(BeTrue()) Expect(err).NotTo(HaveOccurred()) // With the guard (FLAG_CHAT withheld): the declaration survives and the // config validates. good := &ModelConfig{Backend: "llama-cpp", KnownUsecaseStrings: reserved} good.syncKnownUsecasesFromString() Expect(reservedNonChatModel(good)).To(BeTrue()) valid, err = good.Validate() Expect(valid).To(BeTrue()) Expect(err).NotTo(HaveOccurred()) Expect(good.HasUsecases(FLAG_TOKEN_CLASSIFY)).To(BeTrue()) }) }) var _ = Describe("PIIFilterApplies (Middleware admin list scoping)", func() { withUsecases := func(backend string, flags ModelConfigUsecase) *ModelConfig { return &ModelConfig{Name: "m", Backend: backend, KnownUsecases: &flags} } It("includes chat-capable models and cloud-proxy models", func() { Expect(withUsecases("llama-cpp", FLAG_CHAT).PIIFilterApplies()).To(BeTrue()) // cloud-proxy is always covered (MITM / proxy chat path), regardless // of declared usecases. Expect((&ModelConfig{Name: "claude", Backend: "cloud-proxy"}).PIIFilterApplies()).To(BeTrue()) }) It("excludes the detector and score models themselves", func() { // token_classify detectors are the filters, not consumers; score // classifiers are internal primitives. Both short-circuit // HasUsecases(FLAG_CHAT) to false. Expect(withUsecases("llama-cpp", FLAG_TOKEN_CLASSIFY).PIIFilterApplies()).To(BeFalse()) Expect(withUsecases("llama-cpp", FLAG_SCORE).PIIFilterApplies()).To(BeFalse()) }) It("includes embedding and completion models (their request text is filtered)", func() { // Phase 4 wired PII onto /v1/embeddings, /v1/completions and /v1/edits, // so those usecases are now coverable. emb := withUsecases("llama-cpp", FLAG_EMBEDDINGS) t := true emb.Embeddings = &t Expect(emb.PIIFilterApplies()).To(BeTrue()) Expect(withUsecases("llama-cpp", FLAG_COMPLETION).PIIFilterApplies()).To(BeTrue()) }) It("excludes models with no text-accepting, PII-covered endpoint", func() { // VAD / audio-in models carry no coverable usecase. Expect((&ModelConfig{Name: "vad", Backend: "silero-vad"}).PIIFilterApplies()).To(BeFalse()) Expect(withUsecases("whisper", FLAG_TRANSCRIPT).PIIFilterApplies()).To(BeFalse()) }) }) var _ = Describe("pattern detector config", func() { patternCfg := func() *ModelConfig { c := &ModelConfig{Name: "secret-filter", Backend: "pattern"} c.PIIDetection.Builtins = []string{"anthropic_api_key"} c.PIIDetection.Patterns = []PIIPattern{{Name: "INTERNAL", Match: `tok-[A-Za-z0-9]{20,}`}} return c } It("IsPatternDetector keys off builtins/patterns", func() { Expect(patternCfg().IsPatternDetector()).To(BeTrue()) Expect((&ModelConfig{Name: "ner", Backend: "llama-cpp"}).IsPatternDetector()).To(BeFalse()) }) It("Validate accepts a well-formed pattern detector (no model file needed)", func() { ok, err := patternCfg().Validate() Expect(err).NotTo(HaveOccurred()) Expect(ok).To(BeTrue()) }) It("Validate rejects an unknown built-in", func() { c := &ModelConfig{Name: "x", Backend: "pattern"} c.PIIDetection.Builtins = []string{"does_not_exist"} _, err := c.Validate() Expect(err).To(MatchError(ContainSubstring("unknown built-in"))) }) It("Validate rejects an unanchored custom pattern", func() { c := &ModelConfig{Name: "x", Backend: "pattern"} c.PIIDetection.Patterns = []PIIPattern{{Name: "EMAILish", Match: `[\w.]+@[\w.]+\.\w+`}} _, err := c.Validate() Expect(err).To(MatchError(ContainSubstring("pattern \"EMAILish\""))) }) }) var _ = Describe("ModelConfig alias", func() { It("reports IsAlias when alias is set", func() { c := ModelConfig{Name: "gpt-4", Alias: "my-llama-3"} Expect(c.IsAlias()).To(BeTrue()) Expect(ModelConfig{Name: "real"}.IsAlias()).To(BeFalse()) }) It("validates a minimal alias config", func() { c := ModelConfig{Name: "gpt-4", Alias: "my-llama-3"} ok, err := c.Validate() Expect(err).ToNot(HaveOccurred()) Expect(ok).To(BeTrue()) }) It("rejects an alias pointing to itself", func() { c := ModelConfig{Name: "loop", Alias: "loop"} ok, err := c.Validate() Expect(ok).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("itself"))) }) It("rejects an alias that also sets a backend", func() { c := ModelConfig{Name: "gpt-4", Alias: "my-llama-3", Backend: "llama-cpp"} ok, err := c.Validate() Expect(ok).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("pure redirect"))) }) })