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>
This commit is contained in:
Richard Palethorpe
2026-06-18 11:45:22 +01:00
committed by GitHub
parent c133ca39dc
commit 3fa7b2955c
134 changed files with 6671 additions and 4223 deletions

View File

@@ -123,6 +123,10 @@ var RouteFeatureRegistry = []RouteFeature{
{"GET", "/api/fine-tuning/jobs/:id/download", FeatureFineTuning},
{"POST", "/api/fine-tuning/datasets", FeatureFineTuning},
// PII analyze/redact service (the events log stays admin-gated in-handler)
{"POST", "/api/pii/analyze", FeaturePIIFilter},
{"POST", "/api/pii/redact", FeaturePIIFilter},
// Quantization
{"POST", "/api/quantization/jobs", FeatureQuantization},
{"GET", "/api/quantization/jobs", FeatureQuantization},
@@ -181,5 +185,6 @@ func APIFeatureMetas() []FeatureMeta {
{FeatureFaceRecognition, "Face Recognition", true},
{FeatureVoiceRecognition, "Voice Recognition", true},
{FeatureAudioTransform, "Audio Transform", true},
{FeaturePIIFilter, "PII Analyze / Redact", true},
}
}

View File

@@ -56,6 +56,10 @@ const (
FeatureFaceRecognition = "face_recognition"
FeatureVoiceRecognition = "voice_recognition"
FeatureAudioTransform = "audio_transform"
// FeaturePIIFilter gates the synchronous PII analyze/redact service
// (POST /api/pii/{analyze,redact}). Default ON like the other API
// features; the admin-only events log is gated separately in-handler.
FeaturePIIFilter = "pii_filter"
)
// AgentFeatures lists agent-related features (default OFF).
@@ -71,6 +75,7 @@ var APIFeatures = []string{
FeatureVAD, FeatureDetection, FeatureVideo, FeatureEmbeddings, FeatureSound,
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
FeaturePIIFilter,
}
// AllFeatures lists all known features (used by UI and validation).

View File

@@ -10,13 +10,11 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
openaiEndpoint "github.com/mudler/LocalAI/core/http/endpoints/openai"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/cloudproxy"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
@@ -30,7 +28,7 @@ import (
// @Param request body schema.AnthropicRequest true "query params"
// @Success 200 {object} schema.AnthropicResponse "Response"
// @Router /v1/messages [post]
func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient, piiRedactor *pii.Redactor, piiEvents pii.EventStore) echo.HandlerFunc {
func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient) echo.HandlerFunc {
return func(c echo.Context) error {
id := uuid.New().String()
@@ -53,7 +51,7 @@ func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evalu
// Cloud-proxy bail. Same shape as the OpenAI chat endpoint —
// forwards via the cloud-proxy gRPC backend.
if cfg.IsCloudProxyBackendPassthrough() {
return forwardCloudProxyAnthropicViaBackend(c, cfg, input, piiRedactor, piiEvents, ml, appConfig)
return forwardCloudProxyAnthropicViaBackend(c, cfg, input, ml, appConfig)
}
// Convert Anthropic messages to OpenAI format for internal processing
@@ -141,7 +139,7 @@ func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evalu
xlog.Debug("Anthropic Messages - Prompt (after templating)", "prompt", predInput)
if input.Stream {
return handleAnthropicStream(c, id, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, mcpExecutor, evaluator, piiRedactor, piiEvents)
return handleAnthropicStream(c, id, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, mcpExecutor, evaluator)
}
return handleAnthropicNonStream(c, id, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, mcpExecutor, evaluator)
@@ -330,36 +328,13 @@ func handleAnthropicNonStream(c echo.Context, id string, input *schema.Anthropic
return sendAnthropicError(c, 500, "api_error", "MCP iteration limit reached")
}
func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool, mcpExecutor mcpTools.ToolExecutor, evaluator *templates.Evaluator, piiRedactor *pii.Redactor, piiEvents pii.EventStore) error {
func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool, mcpExecutor mcpTools.ToolExecutor, evaluator *templates.Evaluator) error {
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
// Per-stream PII filter — same gating as the OpenAI chat path. The
// filter is wire-format-agnostic; we feed it the text portion of
// each text_delta and emit only what's safe to send. The filter
// holds back a tail of size MaxPatternLength-1 so a pattern split
// across chunk boundaries still gets masked. When PII is disabled
// for this model the filter is nil and emits flow unchanged.
var streamPIIFilter *pii.StreamFilter
if piiRedactor != nil && cfg.PIIIsEnabled() {
correlationID := c.Request().Header.Get("x-request-id")
userID := ""
if u := auth.GetUser(c); u != nil {
userID = u.ID
}
var overrides map[string]pii.Action
if raw := cfg.PIIPatternOverrides(); len(raw) > 0 {
overrides = make(map[string]pii.Action, len(raw))
for ovid, action := range raw {
switch pii.Action(action) {
case pii.ActionMask, pii.ActionBlock, pii.ActionAllow:
overrides[ovid] = pii.Action(action)
}
}
}
streamPIIFilter = pii.NewStreamFilter(piiRedactor, overrides, piiEvents, correlationID, userID)
}
// Response/output PII redaction is out of scope for now — redaction
// runs request-side only (the NER middleware).
// Send message_start event
messageStart := schema.AnthropicStreamEvent{
@@ -440,7 +415,6 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
if len(toolCalls) > toolCallsEmitted {
if !inToolCall && currentBlockIndex == 0 {
drainStreamPIIToText(c, streamPIIFilter, intPtr(currentBlockIndex))
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: intPtr(currentBlockIndex),
@@ -481,20 +455,14 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
}
if !inToolCall && token != "" {
out := token
if streamPIIFilter != nil {
out = streamPIIFilter.Push(token)
}
if out != "" {
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: intPtr(0),
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: out,
},
})
}
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: intPtr(0),
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: token,
},
})
}
return true
}
@@ -532,20 +500,14 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
// didn't already stream it (autoparser clears raw text, so
// accumulatedContent will be empty in that case).
if deltaContent != "" && !inToolCall && accumulatedContent == "" {
out := deltaContent
if streamPIIFilter != nil {
out = streamPIIFilter.Push(deltaContent)
}
if out != "" {
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: intPtr(0),
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: out,
},
})
}
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: intPtr(0),
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: deltaContent,
},
})
}
// Emit tool_use blocks from ChatDeltas
@@ -553,7 +515,6 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
collectedToolCalls = deltaToolCalls
if !inToolCall && currentBlockIndex == 0 {
drainStreamPIIToText(c, streamPIIFilter, intPtr(currentBlockIndex))
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: intPtr(currentBlockIndex),
@@ -657,9 +618,7 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
if !shouldUseFn && cfg.FunctionsConfig.AutomaticToolParsingFallback && accumulatedContent != "" && toolCallsEmitted == 0 {
parsed := functions.ParseFunctionCall(accumulatedContent, cfg.FunctionsConfig)
if len(parsed) > 0 {
// Close the text content block (after flushing any
// residual the streaming PII filter held back).
drainStreamPIIToText(c, streamPIIFilter, intPtr(currentBlockIndex))
// Close the text content block.
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: intPtr(currentBlockIndex),
@@ -699,12 +658,8 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
}
}
// No MCP tools to execute, close stream. drainStreamPIIToText
// flushes any residual the streaming PII filter held back as
// part of its trailing pattern-window before we close the
// text content block.
// No MCP tools to execute, close the text content block.
if !inToolCall {
drainStreamPIIToText(c, streamPIIFilter, intPtr(0))
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: intPtr(0),
@@ -752,30 +707,6 @@ func convertFuncsToOpenAITools(funcs functions.Functions) []functions.Tool {
func intPtr(i int) *int { return &i }
// drainStreamPIIToText flushes any residual the streaming PII filter
// has been holding back as part of its trailing pattern-window, and
// emits it as one final text_delta into the named block before the
// caller closes that block. Drain is idempotent: calling it twice on
// the same filter returns "" the second time. Safe to call with a nil
// filter (no-op).
func drainStreamPIIToText(c echo.Context, sf *pii.StreamFilter, index *int) {
if sf == nil {
return
}
residual := sf.Drain()
if residual == "" {
return
}
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: index,
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: residual,
},
})
}
func sendAnthropicSSE(c echo.Context, event schema.AnthropicStreamEvent) {
data, err := json.Marshal(event)
if err != nil {
@@ -973,17 +904,14 @@ func convertAnthropicTools(input *schema.AnthropicRequest, cfg *config.ModelConf
}
// forwardCloudProxyAnthropicViaBackend marshals the Anthropic request,
// constructs the streaming PII filter (when applicable), and hands the
// body off to the cloud-proxy gRPC backend. Model swap + upstream auth
// headers are applied inside the backend; the filter is built here
// because the auth/correlation context only exists in the echo handler.
func forwardCloudProxyAnthropicViaBackend(c echo.Context, cfg *config.ModelConfig, input *schema.AnthropicRequest, piiRedactor *pii.Redactor, piiEvents pii.EventStore, ml *model.ModelLoader, appConfig *config.ApplicationConfig) error {
// and hands the body off to the cloud-proxy gRPC backend. Model swap +
// upstream auth headers are applied inside the backend. Request-side PII
// redaction already ran in the middleware; the response is forwarded
// unmodified.
func forwardCloudProxyAnthropicViaBackend(c echo.Context, cfg *config.ModelConfig, input *schema.AnthropicRequest, ml *model.ModelLoader, appConfig *config.ApplicationConfig) error {
body, err := json.Marshal(input)
if err != nil {
return sendAnthropicError(c, 400, "invalid_request_error", "cloudproxy: marshal request: "+err.Error())
}
correlationID := c.Request().Header.Get("x-request-id")
streamFilter := cloudproxy.BuildStreamFilter(c, cfg, input.Stream, piiRedactor, piiEvents, correlationID)
return cloudproxy.ForwardViaBackend(c, cfg, body, streamFilter, ml, appConfig)
return cloudproxy.ForwardViaBackend(c, cfg, body, ml, appConfig)
}

View File

@@ -1,114 +0,0 @@
package anthropic
import (
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/services/routing/pii"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// drainStreamPIIToText is called from four sites in messages.go and is
// the load-bearing primitive for "the streaming filter has buffered
// some bytes that the request just ended on; flush them as a final
// text_delta event before closing the content block". A regression
// here would silently truncate the last few bytes of an assistant
// response on every PII-enabled stream — invisible without coverage.
// newTestFilter compiles the default patterns and returns a filter
// that holds back its trailing pattern-window; pushing a short string
// (shorter than holdLen) keeps the bytes inside Drain.
func newTestFilter() *pii.StreamFilter {
patterns, err := pii.Compile(pii.DefaultPatterns())
ExpectWithOffset(1, err).NotTo(HaveOccurred())
red := pii.NewRedactor(patterns)
return pii.NewStreamFilter(red, nil, nil, "", "")
}
// newTestContext builds a recording echo context — the recorder
// captures the SSE bytes drainStreamPIIToText writes.
func newTestContext() (echo.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("{}"))
rec := httptest.NewRecorder()
return echo.New().NewContext(req, rec), rec
}
var _ = Describe("drainStreamPIIToText", func() {
It("is a no-op when the filter is nil", func() {
c, rec := newTestContext()
drainStreamPIIToText(c, nil, intPtr(0))
Expect(rec.Body.Len()).To(Equal(0), "nil filter wrote %d bytes: %q", rec.Body.Len(), rec.Body.String())
})
It("emits nothing when the drain is empty", func() {
// A filter with nothing buffered should not emit a phantom event;
// otherwise every non-PII response would close with an empty
// text_delta that pollutes downstream parsers.
sf := newTestFilter()
c, rec := newTestContext()
drainStreamPIIToText(c, sf, intPtr(0))
Expect(rec.Body.Len()).To(Equal(0), "empty drain wrote %d bytes: %q", rec.Body.Len(), rec.Body.String())
})
It("flushes residual buffered bytes as a text_delta event", func() {
sf := newTestFilter()
// Push less than holdLen so all bytes are retained until Drain.
// "tail" is short enough that no pattern is plausible.
out := sf.Push("tail")
Expect(out).To(Equal(""), "Push of short text emitted %q; want all bytes held", out)
c, rec := newTestContext()
drainStreamPIIToText(c, sf, intPtr(2))
body := rec.Body.String()
// Wire format: "event: content_block_delta\ndata: {…}\n\n"
Expect(body).To(ContainSubstring("event: content_block_delta"))
Expect(body).To(ContainSubstring(`"type":"content_block_delta"`))
Expect(body).To(ContainSubstring(`"index":2`))
Expect(body).To(ContainSubstring(`"text":"tail"`))
Expect(body).To(ContainSubstring(`"type":"text_delta"`))
Expect(strings.HasSuffix(body, "\n\n")).To(BeTrue(), "SSE event missing trailing blank line: %q", body)
})
It("is idempotent across consecutive drains", func() {
// Two consecutive Drains: the filter returns "" the second time,
// so the second drainStreamPIIToText must emit nothing. The
// production path in messages.go has at least four call sites
// that may overlap (currentBlockIndex==0 emergency path + the
// unconditional drain near the end of the stream); without
// idempotence we'd duplicate the residual on the wire.
sf := newTestFilter()
sf.Push("tail")
c1, rec1 := newTestContext()
drainStreamPIIToText(c1, sf, intPtr(0))
first := rec1.Body.Len()
Expect(first).NotTo(Equal(0), "first drain emitted nothing")
c2, rec2 := newTestContext()
drainStreamPIIToText(c2, sf, intPtr(0))
Expect(rec2.Body.Len()).To(Equal(0), "second drain wrote %d bytes; want idempotent no-op: %q", rec2.Body.Len(), rec2.Body.String())
})
It("masks redacted residual instead of leaking it", func() {
// The held tail must travel through the redactor on Drain. If
// the bytes happen to form a complete pattern at end-of-stream,
// the residual emit must contain the mask placeholder, not the
// raw value.
sf := newTestFilter()
// "alice@example.com" is 17 bytes. holdLen for default patterns
// is well above 17, so this stays buffered until Drain, which
// then redacts it.
out := sf.Push("alice@example.com")
Expect(out).To(Equal(""), "Push emitted bytes early: %q", out)
c, rec := newTestContext()
drainStreamPIIToText(c, sf, intPtr(0))
body := rec.Body.String()
Expect(body).NotTo(ContainSubstring("alice@example.com"), "raw email leaked in residual emit: %q", body)
Expect(body).To(ContainSubstring("[REDACTED:email]"), "residual emit missing mask placeholder: %q", body)
})
})

View File

@@ -100,15 +100,15 @@ var instructionDefs = []instructionDef{
},
{
Name: "pii-filtering",
Description: "Inspect and tune the regex PII filter applied to chat requests",
Description: "Inspect the NER-based PII filter applied to chat requests",
Tags: []string{"pii"},
Intro: "GET /api/pii/patterns lists the active pattern set with each one's action (mask, block, allow). GET /api/pii/events returns recent redaction events filtered by correlation_id / user_id / pattern_id (admin or local-user only). POST /api/pii/test dry-runs the redactor against an admin-supplied string. POST /api/pii/decide is the programmatic decision oracle for external routers: send `{text}`, receive `{findings, suggested_action, redacted_preview}` without LocalAI mutating, recording, or acting on the call — caller composes the action with its own policy. Default patterns: email, phone, SSN, credit card (Luhn), IPv4, common API key prefixes (sk-, pk-, ghp_, github_pat_). PII is per-model: by default it is OFF for non-proxy backends and ON for backends starting with proxy-* (cloud passthroughs). Opt in with `pii: { enabled: true }` in a model's YAML; use `pii: { patterns: [{id, action}] }` to upgrade or downgrade individual actions for that model. Override global default actions via --pii-config pii.yaml; --disable-pii turns the filter off entirely.",
Intro: "PII redaction is NER-based and request-side. A consuming model opts in with `pii: { enabled: true, detectors: [<model>] }` where each detector is a token-classification (token_classify) model. The detection policy lives on the detector model itself in a `pii_detection:` block: `{ min_score, default_action (mask|block|allow), entity_actions: { GROUP: action } }`. Multiple detectors union their hits; overlapping spans resolve to the strongest action (block > mask > allow). PII defaults OFF for non-proxy backends and ON for proxy-* (cloud passthroughs). Besides the inline path, two synchronous service endpoints expose the same engine without an inference request: POST /api/pii/analyze returns the detected entity spans (entity_type, source ner|pattern, start/end, score, action) without mutating the text, and POST /api/pii/redact applies the policy — returning redacted_text, or 400 (type pii_blocked) with the offending entities when a block action fires. Both take `{ text, detectors:[<model>...] }` (or `model` to inherit a consuming model's detectors), require the pii_filter feature (any authenticated user), and record audit events with an `origin` of pii_analyze / pii_redact. GET /api/pii/events returns recent redaction events filtered by correlation_id / user_id / pattern_id / origin (middleware|proxy|pii_analyze|pii_redact); events carry `<source>:<GROUP>` ids — e.g. `ner:EMAIL` for the neural detector, `pattern:ANTHROPIC_KEY` for the regex pattern tier — and an 8-char hash prefix, never the matched value (admin or local-user only). The legacy regex pattern tier and its endpoints (/api/pii/patterns, /test, /decide) were removed.",
},
{
Name: "middleware-admin",
Description: "Inspect and configure the routing-module middleware (PII filter and routing)",
Tags: []string{"middleware", "pii", "router"},
Intro: "GET /api/middleware/status is the single round-trip the /app/middleware admin page reads to render the current state: active PII patterns and their actions, every model's resolved enabled/override state, recent event count, and the active routing models with their classifier configurations. Admin-only (the synthetic local user is admin in no-auth mode). PUT /api/pii/patterns/:id changes a pattern's action in-process — TRANSIENT, lost on restart. To persist, edit --pii-config YAML. GET /api/router/decisions returns the routing decision log filtered by correlation_id / user_id / router_model. The same surface is exposed as MCP tools (`get_middleware_status`, `set_pii_pattern_action`, `get_router_decisions`) for agent-driven configuration.",
Intro: "GET /api/middleware/status is the single round-trip the /app/middleware admin page reads to render the current state: every model's resolved PII enabled state and the NER detector models it references, recent event count, and the active routing models with their classifier configurations. Admin-only (the synthetic local user is admin in no-auth mode). PII detection policy is edited on each detector model's `pii_detection:` block via the model-config tools/UI — there is no global pattern set to mutate. GET /api/router/decisions returns the routing decision log filtered by correlation_id / user_id / router_model. The same surface is exposed as MCP tools (`get_middleware_status`, `get_pii_events`, `get_router_decisions`) for agent-driven inspection.",
},
{
Name: "intelligent-routing",

View File

@@ -25,6 +25,10 @@ var knownPrefOnlyBackends = []schema.KnownBackend{
// Text LLM
// ds4: antirez/ds4 - single-model DeepSeek V4 Flash engine; auto-detected via DS4Importer
{Name: "ds4", Modality: "text", AutoDetect: false, Description: "antirez/ds4 DeepSeek V4 Flash engine (auto-detected; pref-only fallback)"},
// privacy-filter is now auto-detected via PrivacyFilterImporter (see
// core/gallery/importers/privacy-filter.go); the importer registry entry
// supersedes any pref-only line here, which the /backends/known merge would
// dedupe away.
{Name: "sglang", Modality: "text", AutoDetect: false, Description: "SGLang runtime (preference-only)"},
{Name: "tinygrad", Modality: "text", AutoDetect: false, Description: "tinygrad runtime (preference-only)"},
{Name: "trl", Modality: "text", AutoDetect: false, Description: "Transformers Reinforcement Learning (preference-only)"},

View File

@@ -88,7 +88,20 @@ var _ = Describe("Backend Endpoints", func() {
}
Expect(names).To(ContainElements(
"llama-cpp", "mlx", "vllm", "transformers", "diffusers",
"privacy-filter",
))
// privacy-filter is auto-detected via PrivacyFilterImporter, so it
// surfaces from the importer registry (AutoDetect=true) rather than
// the curated pref-only slice.
byName := map[string]schema.KnownBackend{}
for _, b := range payload {
byName[b.Name] = b
}
pf, ok := byName["privacy-filter"]
Expect(ok).To(BeTrue(), "privacy-filter must be present")
Expect(pf.AutoDetect).To(BeTrue(), "privacy-filter is auto-detected via its importer")
Expect(pf.Modality).To(Equal("text"))
})
It("includes drop-in llama-cpp replacements with AutoDetect=false", func() {

View File

@@ -126,6 +126,8 @@ func AutocompleteEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, a
filterFn = config.BuildUsecaseFilterFn(config.FLAG_TRANSCRIPT)
case "score": // router classifier usecase (FLAG_SCORE); not in UsecaseInfoMap
filterFn = config.BuildUsecaseFilterFn(config.FLAG_SCORE)
case config.UsecaseTokenClassify: // PII NER detector usecase (FLAG_TOKEN_CLASSIFY)
filterFn = config.BuildUsecaseFilterFn(config.FLAG_TOKEN_CLASSIFY)
default:
filterFn = config.NoFilterFn
}

View File

@@ -65,7 +65,7 @@ func MCPEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
// the per-model PII config and is kept for backward compatibility.
// The request-side middleware on the main chat route handles
// filtering for the standard /v1/chat/completions path.
chatHandler := openai.ChatEndpoint(cl, ml, evaluator, appConfig, natsClient, nil, nil, nil)
chatHandler := openai.ChatEndpoint(cl, ml, evaluator, appConfig, natsClient, nil)
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenAIRequest)

View File

@@ -0,0 +1,248 @@
package localai
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/routing/pii"
)
// ErrNoDetectors is returned by RunPIIScan when neither an explicit detector
// list nor a model's effective PII policy resolve to anything to scan with —
// including a model that has PII disabled, or one that is enabled but names
// no detectors while no instance-wide default is set. The handler maps it to
// 400: the truthful answer is "the middleware would scan nothing", and
// surfacing that loudly beats implying a clean scan happened.
var ErrNoDetectors = errors.New("no PII detectors specified")
// ErrUnknownDetector is returned when a named detector model cannot be
// resolved. Wrapped (errors.Is) so the handler can map it to 400 — a bad
// detector name is a client error, distinct from a detector that resolved but
// failed at scan time (mapped to 502, fail-closed).
var ErrUnknownDetector = errors.New("unknown PII detector")
// RunPIIScan resolves the requested detectors and runs the shared NER/pattern
// redaction pipeline over text. It is the engine behind both /api/pii/analyze
// and /api/pii/redact, kept free of echo so the resolution + scan logic is
// unit-testable with a fake resolver.
//
// Detector selection mirrors the inline chat middleware (middleware.go):
// explicit names take precedence; otherwise the consuming model's effective
// policy is resolved through policy (Application.ResolvePIIPolicy — the
// model's own pii.detectors, else the instance-wide PIIDefaultDetectors, and
// nothing when the model has PII disabled), so the model path answers "what
// would the middleware do with this text?" with the same inputs the
// middleware uses. A nil policy falls back to the model's raw pii.detectors
// (unit tests). Unknown names fail closed (ErrUnknownDetector) rather than
// silently scanning with fewer detectors than asked for.
func RunPIIScan(ctx context.Context, resolver pii.NERDetectorResolver, cl *config.ModelConfigLoader, policy pii.PolicyResolver, names []string, model, text string) (pii.Result, error) {
if len(names) == 0 && model != "" && cl != nil {
if cfg, ok := cl.GetModelConfig(model); ok {
if policy != nil {
if enabled, detectors := policy(&cfg); enabled {
names = detectors
}
} else {
names = cfg.PIIDetectors()
}
}
}
if len(names) == 0 {
return pii.Result{}, ErrNoDetectors
}
cfgs := make([]pii.NERConfig, 0, len(names))
for _, name := range names {
nc, ok := resolver(name)
if !ok {
return pii.Result{}, fmt.Errorf("%w: %q", ErrUnknownDetector, name)
}
cfgs = append(cfgs, nc)
}
return pii.RedactNER(ctx, text, cfgs)
}
// piiEntities maps redaction spans to API entities. Each span's Pattern is the
// synthetic "<source>:<GROUP>" id (e.g. "ner:EMAIL"); it is split back into
// the entity type and its source tier. hash_prefix is included only when
// revealHash is set (admin + reveal) — the raw matched value is never exposed.
func piiEntities(spans []pii.Span, revealHash bool) []schema.PIIEntity {
out := make([]schema.PIIEntity, 0, len(spans))
for _, s := range spans {
source, group := splitPatternID(s.Pattern)
e := schema.PIIEntity{
EntityType: group,
Source: source,
Start: s.Start,
End: s.End,
Score: s.Score,
Action: string(s.Action),
}
if revealHash {
e.HashPrefix = s.HashPrefix
}
out = append(out, e)
}
return out
}
// splitPatternID splits "ner:EMAIL" into ("ner", "EMAIL"). A value with no
// colon is returned as (group, "") inverted to ("", value) so the group is
// never lost.
func splitPatternID(patternID string) (source, group string) {
if i := strings.IndexByte(patternID, ':'); i >= 0 {
return patternID[:i], patternID[i+1:]
}
return "", patternID
}
// recordPIIEvents persists one audit event per span, tagged with the calling
// API as its Origin so /api/pii/events can be filtered to this surface. Mirrors
// the per-span recording the chat middleware does. Best-effort: a store error
// is logged by the store layer, not surfaced to the caller.
func recordPIIEvents(store pii.EventStore, spans []pii.Span, origin pii.Origin, correlationID, userID string) {
if store == nil {
return
}
for _, s := range spans {
_ = store.Record(context.Background(), pii.PIIEvent{
ID: pii.NewEventID(),
Kind: pii.KindPII,
Origin: origin,
CorrelationID: correlationID,
UserID: userID,
Direction: pii.DirectionIn,
PatternID: s.Pattern,
ByteOffset: s.Start,
Length: s.End - s.Start,
HashPrefix: s.HashPrefix,
Action: s.Action,
Score: s.Score,
CreatedAt: time.Now().UTC(),
})
}
}
// piiScanError maps a RunPIIScan error to an HTTP response. Selection/naming
// errors are client errors (400); a detector that resolved but failed at scan
// time is a fail-closed dependency error (502) — the text is never returned
// unredacted.
func piiScanError(c echo.Context, err error) error {
if errors.Is(err, ErrNoDetectors) || errors.Is(err, ErrUnknownDetector) {
return c.JSON(http.StatusBadRequest, map[string]any{
"error": map[string]string{"message": err.Error(), "type": "invalid_request"},
})
}
return c.JSON(http.StatusBadGateway, map[string]any{
"error": map[string]string{"message": err.Error(), "type": "pii_detector_error"},
})
}
// piiViewer resolves the request's user (the authenticated user, or the
// synthetic local admin in single-user mode) so the handlers can attribute
// events and gate the admin-only hash reveal.
func piiViewer(c echo.Context, app *application.Application) *auth.User {
if u := auth.GetUser(c); u != nil {
return u
}
return app.FallbackUser()
}
// PIIAnalyzeEndpoint scans text and returns the detected PII entities without
// mutating it. Always 200 (detection, not enforcement); Blocked reports
// whether the redact endpoint would reject the same text.
// @Summary Detect PII entities in a string (no mutation).
// @Description Runs the configured PII detectors (NER and/or pattern tiers) over the supplied text and returns the matched entity spans with the policy action that would fire. Detection only — the text is not modified and no block is enforced. Select detectors explicitly via `detectors`, or pass a consuming `model` to use its effective policy: the model's own `pii.detectors`, else the instance-wide `pii_default_detectors`. A model with PII disabled, or enabled with nothing to scan with, is a 400. The raw matched value is never returned; admins may set `reveal:true` for the audit hash prefix.
// @Tags pii
// @Param request body schema.PIIAnalyzeRequest true "text + detector selection"
// @Success 200 {object} schema.PIIAnalyzeResponse "Detected entities"
// @Router /api/pii/analyze [post]
func PIIAnalyzeEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.PIIAnalyzeRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]any{
"error": map[string]string{"message": "invalid request body", "type": "invalid_request"},
})
}
viewer := piiViewer(c, app)
if viewer == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
correlationID := pii.NewEventID()
res, err := RunPIIScan(c.Request().Context(), app.PIINERResolver(), app.ModelConfigLoader(), app.PIIPolicyResolver(), req.Detectors, req.Model, req.Text)
if err != nil {
return piiScanError(c, err)
}
recordPIIEvents(app.PIIEvents(), res.Spans, pii.OriginAnalyzeAPI, correlationID, viewer.ID)
revealHash := req.Reveal && viewer.Role == auth.RoleAdmin
return c.JSON(http.StatusOK, schema.PIIAnalyzeResponse{
Entities: piiEntities(res.Spans, revealHash),
Blocked: res.Blocked,
CorrelationID: correlationID,
})
}
}
// PIIRedactEndpoint scans text and applies the configured mask/block/allow
// policy. Returns the redacted text (200), or 400 with type "pii_blocked" and
// the offending entities when a block action fires — never a redacted body in
// that case. Mirrors the inline middleware's block contract.
// @Summary Redact PII in a string by applying the configured policy.
// @Description Runs the configured PII detectors over the text and applies each detector model's policy: masked spans are replaced with `[REDACTED:<id>]`, allow spans pass through, and a single block action causes a 400 (type `pii_blocked`) carrying the offending entities — the text is never returned in that case. Select detectors via `detectors`, or a consuming `model`'s effective policy (its own `pii.detectors`, else the instance-wide `pii_default_detectors`; PII must be enabled on the model). Records audit events (origin `pii_redact`) visible at /api/pii/events.
// @Tags pii
// @Param request body schema.PIIAnalyzeRequest true "text + detector selection"
// @Success 200 {object} schema.PIIRedactResponse "Redacted text + entities"
// @Router /api/pii/redact [post]
func PIIRedactEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.PIIAnalyzeRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]any{
"error": map[string]string{"message": "invalid request body", "type": "invalid_request"},
})
}
viewer := piiViewer(c, app)
if viewer == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
correlationID := pii.NewEventID()
res, err := RunPIIScan(c.Request().Context(), app.PIINERResolver(), app.ModelConfigLoader(), app.PIIPolicyResolver(), req.Detectors, req.Model, req.Text)
if err != nil {
return piiScanError(c, err)
}
recordPIIEvents(app.PIIEvents(), res.Spans, pii.OriginRedactAPI, correlationID, viewer.ID)
revealHash := req.Reveal && viewer.Role == auth.RoleAdmin
entities := piiEntities(res.Spans, revealHash)
if res.Blocked {
// Fail closed: a block action returns no redacted text, only the
// reason and the offending entities — identical to the middleware.
return c.JSON(http.StatusBadRequest, map[string]any{
"error": map[string]string{"message": "text blocked by content policy (sensitive data detected)", "type": "pii_blocked"},
"entities": entities,
"correlation_id": correlationID,
})
}
return c.JSON(http.StatusOK, schema.PIIRedactResponse{
RedactedText: res.Redacted,
Entities: entities,
Blocked: false,
Masked: res.Masked,
CorrelationID: correlationID,
})
}
}

View File

@@ -1,79 +0,0 @@
package localai
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/routing/pii"
)
// PIIDecideEndpoint exposes the PII redactor as a decision oracle:
// scan the supplied text and return findings + the strongest action
// the configured pattern set would take, without rewriting the
// caller's request or recording an audit event.
//
// External routers (e.g. the localai-org/platform router) call this
// before dispatching to learn whether to mask the prompt in place,
// block the request, or pass it through. LocalAI's in-band PII
// middleware is the alternative path for direct-to-LocalAI clients —
// same Redactor, different framing.
//
// Takes the *pii.Redactor directly rather than the whole
// *application.Application so the handler stays unit-testable with a
// freshly-constructed redactor (mirrors the pattern in
// router_decide.go). The route-registration site is responsible for
// stubbing this endpoint when --disable-pii is set so callers get a
// 503 signalling "admin opted out" rather than a misleading allow.
//
// @Summary Scan text for PII and return findings + suggested action (decision oracle)
// @Tags pii
// @Accept json
// @Produce json
// @Param request body schema.PIIDecideRequest true "decide params"
// @Success 200 {object} schema.PIIDecideResponse
// @Failure 400 {object} map[string]string
// @Router /api/pii/decide [post]
func PIIDecideEndpoint(redactor *pii.Redactor) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.PIIDecideRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body: "+err.Error())
}
if req.Text == "" {
return echo.NewHTTPError(http.StatusBadRequest, "text is required")
}
res := redactor.Redact(req.Text)
findings := make([]schema.PIIFinding, len(res.Spans))
for i, s := range res.Spans {
findings[i] = schema.PIIFinding{
Start: s.Start,
End: s.End,
Pattern: s.Pattern,
HashPrefix: s.HashPrefix,
}
}
return c.JSON(http.StatusOK, schema.PIIDecideResponse{
Findings: findings,
SuggestedAction: suggestedAction(res),
RedactedPreview: res.Redacted,
})
}
}
// suggestedAction collapses the Redactor's Result flags onto a single
// wire-format action using the in-band ordering (block > mask >
// allow). "allow" covers both "nothing matched" and "matched but every
// span resolved to the allow action" — in both cases the caller may
// dispatch unchanged, with the Findings list reporting what was seen.
func suggestedAction(res pii.Result) string {
switch {
case res.Blocked:
return string(pii.ActionBlock)
case res.Masked:
return string(pii.ActionMask)
default:
return string(pii.ActionAllow)
}
}

View File

@@ -1,108 +0,0 @@
package localai_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/routing/pii"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// PIIDecideEndpoint exposes the redactor as a decision oracle. These
// specs pin the validation surface and the suggested_action mapping
// across the three actions (allow/mask/block). The redactor itself is
// covered in core/services/routing/pii/redactor_test.go.
var _ = Describe("PIIDecideEndpoint", func() {
var redactor *pii.Redactor
BeforeEach(func() {
patterns, err := pii.Compile(pii.DefaultPatterns())
Expect(err).NotTo(HaveOccurred())
redactor = pii.NewRedactor(patterns)
})
It("rejects requests with no text field", func() {
rec, _ := invokePIIDecide(redactor, `{}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(rec.Body.String()).To(ContainSubstring("text is required"))
})
It("rejects malformed JSON", func() {
rec, _ := invokePIIDecide(redactor, `not json`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("returns allow for clean text", func() {
rec, body := invokePIIDecide(redactor, `{"text":"hello world"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(body.SuggestedAction).To(Equal("allow"))
Expect(body.Findings).To(BeEmpty())
Expect(body.RedactedPreview).To(Equal("hello world"))
})
It("returns mask for text containing email (default action)", func() {
rec, body := invokePIIDecide(redactor, `{"text":"reach me at alice@example.com please"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(body.SuggestedAction).To(Equal("mask"))
Expect(body.Findings).To(HaveLen(1))
Expect(body.Findings[0].Pattern).To(Equal("email"))
Expect(body.Findings[0].HashPrefix).NotTo(BeEmpty())
Expect(body.RedactedPreview).To(ContainSubstring("[REDACTED:email]"))
Expect(body.RedactedPreview).NotTo(ContainSubstring("alice@example.com"))
})
It("returns block when an api_key_prefix is present (block beats mask)", func() {
// api_key_prefix defaults to ActionBlock per DefaultPatterns.
// Mix in an email so we also confirm the block-action wins
// over the mask-action via actionRank.
rec, body := invokePIIDecide(redactor, `{"text":"my key is sk-1234567890abcdefghij and email alice@example.com"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(body.SuggestedAction).To(Equal("block"))
Expect(len(body.Findings)).To(BeNumerically(">=", 1))
})
It("returns allow when a matched pattern's action is allow", func() {
// Downgrade the email pattern to allow for this test —
// exercises the allow branch of suggestedAction: a match is
// found, but the strongest action is allow so the suggestion
// is "allow" and the text is left intact.
Expect(redactor.SetAction("email", pii.ActionAllow)).To(Succeed())
rec, body := invokePIIDecide(redactor, `{"text":"contact alice@example.com"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(body.SuggestedAction).To(Equal("allow"))
Expect(body.Findings).To(HaveLen(1), "allow still reports the finding")
// allow leaves the original text intact.
Expect(body.RedactedPreview).To(ContainSubstring("alice@example.com"))
})
It("never leaks the matched value via HashPrefix", func() {
rec, body := invokePIIDecide(redactor, `{"text":"alice@example.com"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(body.Findings).To(HaveLen(1))
// HashPrefix is 8 hex chars of sha256 — definitely not the
// matched value, but stable so admins can correlate leaks.
Expect(body.Findings[0].HashPrefix).To(HaveLen(8))
Expect(body.Findings[0].HashPrefix).NotTo(ContainSubstring("alice"))
})
})
func invokePIIDecide(redactor *pii.Redactor, body string) (*httptest.ResponseRecorder, schema.PIIDecideResponse) {
e := echo.New()
e.POST("/api/pii/decide", localai.PIIDecideEndpoint(redactor))
req := httptest.NewRequest(http.MethodPost, "/api/pii/decide", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
var parsed schema.PIIDecideResponse
if rec.Code == http.StatusOK {
Expect(json.Unmarshal(rec.Body.Bytes(), &parsed)).To(Succeed())
}
return rec, parsed
}

View File

@@ -0,0 +1,258 @@
package localai_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// stubDetector is a fixed NER detector for the resolver-level unit tests.
type stubDetector struct {
ents []pii.NEREntity
err error
}
func (s stubDetector) Detect(_ context.Context, _ string) ([]pii.NEREntity, error) {
return s.ents, s.err
}
var _ = Describe("RunPIIScan (resolver + scan core)", func() {
ctx := context.Background()
resolver := func(name string) (pii.NERConfig, bool) {
if name != "det" {
return pii.NERConfig{}, false
}
return pii.NERConfig{
Detector: stubDetector{ents: []pii.NEREntity{{Group: "EMAIL", Start: 0, End: 5, Score: 0.9}}},
EntityActions: map[string]pii.Action{"EMAIL": pii.ActionMask},
Source: pii.SourceNER,
}, true
}
It("resolves named detectors and returns their spans", func() {
res, err := RunPIIScan(ctx, resolver, nil, nil, []string{"det"}, "", "jane@acme.io")
Expect(err).ToNot(HaveOccurred())
Expect(res.Spans).To(HaveLen(1))
Expect(res.Spans[0].Pattern).To(Equal("ner:EMAIL"))
Expect(res.Masked).To(BeTrue())
})
It("fails closed with ErrUnknownDetector for an unresolvable name", func() {
_, err := RunPIIScan(ctx, resolver, nil, nil, []string{"nope"}, "", "x")
Expect(errors.Is(err, ErrUnknownDetector)).To(BeTrue())
})
It("returns ErrNoDetectors when nothing is selected", func() {
_, err := RunPIIScan(ctx, resolver, nil, nil, nil, "", "x")
Expect(errors.Is(err, ErrNoDetectors)).To(BeTrue())
})
})
var _ = Describe("PII analyze/redact endpoints", func() {
var (
app *application.Application
e *echo.Echo
tmp string
cancel context.CancelFunc
)
BeforeEach(func() {
var err error
tmp, err = os.MkdirTemp("", "pii-api-test-*")
Expect(err).ToNot(HaveOccurred())
var ctx context.Context
ctx, cancel = context.WithCancel(context.Background())
modelsDir := filepath.Join(tmp, "models")
Expect(os.MkdirAll(modelsDir, 0o755)).To(Succeed())
st, err := system.GetSystemState(
system.WithModelPath(modelsDir),
system.WithBackendPath(filepath.Join(tmp, "backends")),
)
Expect(err).ToNot(HaveOccurred())
app, err = application.New(config.WithContext(ctx), config.WithSystemState(st))
Expect(err).ToNot(HaveOccurred())
// A pattern detector with two deterministic patterns: one blocks, one
// masks. No backend is loaded — the pattern tier runs in-process.
detYAML := `name: secret-filter
backend: pattern
pii_detection:
default_action: mask
patterns:
- name: SECRET
match: "sk-test-[A-Za-z0-9]+"
action: block
- name: TOKEN
match: "tok-[A-Za-z0-9]+"
action: mask
`
// A consuming model that opts into the detector, for the model-fallback path.
consumerYAML := `name: chatmodel
pii:
enabled: true
detectors: [secret-filter]
`
// PII-enabled but names no detectors: scanned only when the
// instance-wide default detectors are set, else a 400.
defaultsYAML := `name: defaultsmodel
pii:
enabled: true
`
// Lists detectors but never enables PII — the middleware ignores it,
// so the model path must too.
disabledYAML := `name: disabledmodel
pii:
detectors: [secret-filter]
`
detPath := filepath.Join(modelsDir, "secret-filter.yaml")
consumerPath := filepath.Join(modelsDir, "chatmodel.yaml")
defaultsPath := filepath.Join(modelsDir, "defaultsmodel.yaml")
disabledPath := filepath.Join(modelsDir, "disabledmodel.yaml")
Expect(os.WriteFile(detPath, []byte(detYAML), 0o644)).To(Succeed())
Expect(os.WriteFile(consumerPath, []byte(consumerYAML), 0o644)).To(Succeed())
Expect(os.WriteFile(defaultsPath, []byte(defaultsYAML), 0o644)).To(Succeed())
Expect(os.WriteFile(disabledPath, []byte(disabledYAML), 0o644)).To(Succeed())
Expect(app.ModelConfigLoader().ReadModelConfig(detPath)).To(Succeed())
Expect(app.ModelConfigLoader().ReadModelConfig(consumerPath)).To(Succeed())
Expect(app.ModelConfigLoader().ReadModelConfig(defaultsPath)).To(Succeed())
Expect(app.ModelConfigLoader().ReadModelConfig(disabledPath)).To(Succeed())
e = echo.New()
e.POST("/api/pii/analyze", PIIAnalyzeEndpoint(app))
e.POST("/api/pii/redact", PIIRedactEndpoint(app))
})
AfterEach(func() {
cancel()
Expect(os.RemoveAll(tmp)).To(Succeed())
})
post := func(path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
It("analyze reports a block-class entity without mutating text (200)", func() {
rec := post("/api/pii/analyze", `{"text":"my key sk-test-abc123 ok","detectors":["secret-filter"]}`)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp struct {
Entities []struct {
EntityType string `json:"entity_type"`
Source string `json:"source"`
Action string `json:"action"`
} `json:"entities"`
Blocked bool `json:"blocked"`
}
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.Blocked).To(BeTrue())
Expect(resp.Entities).To(HaveLen(1))
Expect(resp.Entities[0].EntityType).To(Equal("SECRET"))
Expect(resp.Entities[0].Source).To(Equal("pattern"))
Expect(resp.Entities[0].Action).To(Equal("block"))
})
It("redact masks a mask-class match and returns redacted text (200)", func() {
rec := post("/api/pii/redact", `{"text":"here is tok-xyz789 done","detectors":["secret-filter"]}`)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp struct {
RedactedText string `json:"redacted_text"`
Masked bool `json:"masked"`
Blocked bool `json:"blocked"`
}
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.Masked).To(BeTrue())
Expect(resp.Blocked).To(BeFalse())
Expect(resp.RedactedText).To(ContainSubstring("[REDACTED:pattern:TOKEN]"))
Expect(resp.RedactedText).ToNot(ContainSubstring("tok-xyz789"))
})
It("redact returns 400 pii_blocked for a block-class match", func() {
rec := post("/api/pii/redact", `{"text":"key sk-test-abc123","detectors":["secret-filter"]}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(rec.Body.String()).To(ContainSubstring("pii_blocked"))
// The raw secret must never appear in the block response.
Expect(rec.Body.String()).ToNot(ContainSubstring("sk-test-abc123"))
})
It("400s when no detector is selected", func() {
rec := post("/api/pii/redact", `{"text":"sk-test-abc123"}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(rec.Body.String()).To(ContainSubstring("invalid_request"))
})
It("resolves detectors from a consuming model via the model field", func() {
rec := post("/api/pii/analyze", `{"text":"tok-aaa111","model":"chatmodel"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp struct {
Entities []struct {
EntityType string `json:"entity_type"`
} `json:"entities"`
}
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.Entities).To(HaveLen(1))
Expect(resp.Entities[0].EntityType).To(Equal("TOKEN"))
})
It("400s for a PII-enabled model with no detectors and no instance default", func() {
rec := post("/api/pii/analyze", `{"text":"tok-aaa111","model":"defaultsmodel"}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(rec.Body.String()).To(ContainSubstring("invalid_request"))
})
It("falls back to the instance-wide default detectors for an enabled model", func() {
defaults := []string{"secret-filter"}
app.ApplicationConfig().ApplyRuntimeSettings(&config.RuntimeSettings{PIIDefaultDetectors: &defaults})
rec := post("/api/pii/analyze", `{"text":"tok-aaa111","model":"defaultsmodel"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp struct {
Entities []struct {
EntityType string `json:"entity_type"`
} `json:"entities"`
}
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.Entities).To(HaveLen(1))
Expect(resp.Entities[0].EntityType).To(Equal("TOKEN"))
})
It("400s for a model that lists detectors but has PII disabled, like the middleware", func() {
rec := post("/api/pii/analyze", `{"text":"tok-aaa111","model":"disabledmodel"}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(rec.Body.String()).To(ContainSubstring("invalid_request"))
})
It("records redact-API events with origin pii_redact", func() {
_ = post("/api/pii/redact", `{"text":"here is tok-xyz789 done","detectors":["secret-filter"]}`)
events, err := app.PIIEvents().List(context.Background(), pii.ListQuery{Origin: pii.OriginRedactAPI})
Expect(err).ToNot(HaveOccurred())
Expect(len(events)).To(BeNumerically(">=", 1))
Expect(events[0].PatternID).To(Equal("pattern:TOKEN"))
// Regression: API-recorded events must carry a real timestamp, not the
// zero value (the handler, unlike the middleware, originally omitted it).
Expect(events[0].CreatedAt.IsZero()).To(BeFalse())
})
})

View File

@@ -22,25 +22,31 @@ type stubClient struct{}
func (stubClient) GallerySearch(_ context.Context, _ localaitools.GallerySearchQuery) ([]gallery.Metadata, error) {
return []gallery.Metadata{{Name: "stub", Gallery: config.Gallery{Name: "stub-gallery"}}}, nil
}
func (stubClient) ListInstalledModels(_ context.Context, _ localaitools.Capability) ([]localaitools.InstalledModel, error) {
return []localaitools.InstalledModel{{Name: "stub"}}, nil
}
func (stubClient) ListGalleries(_ context.Context) ([]config.Gallery, error) {
return []config.Gallery{{Name: "stub-gallery", URL: "http://example"}}, nil
}
func (stubClient) GetJobStatus(_ context.Context, _ string) (*localaitools.JobStatus, error) {
return &localaitools.JobStatus{ID: "stub", Processed: true}, nil
}
func (stubClient) GetModelConfig(_ context.Context, _ string) (*localaitools.ModelConfigView, error) {
return &localaitools.ModelConfigView{Name: "stub"}, nil
}
func (stubClient) InstallModel(_ context.Context, _ localaitools.InstallModelRequest) (string, error) {
return "stub-job", nil
}
func (stubClient) ImportModelURI(_ context.Context, _ localaitools.ImportModelURIRequest) (*localaitools.ImportModelURIResponse, error) {
return &localaitools.ImportModelURIResponse{JobID: "stub-import"}, nil
}
func (stubClient) DeleteModel(_ context.Context, _ string) error { return nil }
func (stubClient) DeleteModel(_ context.Context, _ string) error { return nil }
func (stubClient) EditModelConfig(_ context.Context, _ string, _ map[string]any) error {
return nil
}
@@ -48,57 +54,61 @@ func (stubClient) ReloadModels(_ context.Context) error { return nil }
func (stubClient) ListBackends(_ context.Context) ([]localaitools.Backend, error) {
return []localaitools.Backend{{Name: "stub-backend", Installed: true}}, nil
}
func (stubClient) ListKnownBackends(_ context.Context) ([]schema.KnownBackend, error) {
return []schema.KnownBackend{}, nil
}
func (stubClient) InstallBackend(_ context.Context, _ localaitools.InstallBackendRequest) (string, error) {
return "stub-backend-job", nil
}
func (stubClient) UpgradeBackend(_ context.Context, _ string) (string, error) {
return "stub-upgrade-job", nil
}
func (stubClient) SystemInfo(_ context.Context) (*localaitools.SystemInfo, error) {
return &localaitools.SystemInfo{Version: "stub"}, nil
}
func (stubClient) ListNodes(_ context.Context) ([]localaitools.Node, error) {
return []localaitools.Node{}, nil
}
func (stubClient) VRAMEstimate(_ context.Context, _ localaitools.VRAMEstimateRequest) (*vram.EstimateResult, error) {
return &vram.EstimateResult{SizeDisplay: "stub"}, nil
}
func (stubClient) ToggleModelState(_ context.Context, _ string, _ modeladmin.Action) error { return nil }
func (stubClient) ToggleModelPinned(_ context.Context, _ string, _ modeladmin.Action) error { return nil }
func (stubClient) ToggleModelState(_ context.Context, _ string, _ modeladmin.Action) error {
return nil
}
func (stubClient) ToggleModelPinned(_ context.Context, _ string, _ modeladmin.Action) error {
return nil
}
func (stubClient) GetBranding(_ context.Context) (*localaitools.Branding, error) {
return &localaitools.Branding{InstanceName: "LocalAI"}, nil
}
func (stubClient) SetBranding(_ context.Context, _ localaitools.SetBrandingRequest) (*localaitools.Branding, error) {
return &localaitools.Branding{InstanceName: "LocalAI"}, nil
}
func (stubClient) GetUsageStats(_ context.Context, _ localaitools.UsageStatsQuery) (*localaitools.UsageStats, error) {
return &localaitools.UsageStats{Viewer: localaitools.UsageViewer{ID: "stub", Name: "stub"}, Period: "month"}, nil
}
func (stubClient) ListPIIPatterns(_ context.Context) ([]localaitools.PIIPattern, error) {
return nil, nil
}
func (stubClient) GetPIIEvents(_ context.Context, _ localaitools.PIIEventsQuery) ([]localaitools.PIIEvent, error) {
return nil, nil
}
func (stubClient) TestPIIRedaction(_ context.Context, req localaitools.PIIRedactTestRequest) (*localaitools.PIIRedactTestResult, error) {
return &localaitools.PIIRedactTestResult{Redacted: req.Text}, nil
}
func (stubClient) SetPIIPatternAction(_ context.Context, _ localaitools.PIIPatternActionUpdate) error {
return nil
}
func (stubClient) PersistPIIPatterns(_ context.Context) error { return nil }
func (stubClient) GetMiddlewareStatus(_ context.Context) (*localaitools.MiddlewareStatus, error) {
return &localaitools.MiddlewareStatus{
PII: localaitools.MiddlewarePIIStatus{
EnabledGlobally: true,
Patterns: []localaitools.PIIPattern{},
Models: []localaitools.MiddlewarePIIModel{},
},
}, nil
}
func (stubClient) GetRouterDecisions(_ context.Context, _ localaitools.RouterDecisionsQuery) ([]localaitools.RouterDecision, error) {
return []localaitools.RouterDecision{}, nil
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/cloudproxy"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/pkg/functions"
reason "github.com/mudler/LocalAI/pkg/reasoning"
@@ -130,7 +129,7 @@ func applyAutoparserOverride(
// @Param request body schema.OpenAIRequest true "query params"
// @Success 200 {object} schema.OpenAIResponse "Response"
// @Router /v1/chat/completions [post]
func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, startupOptions *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient, assistantHolder *mcpTools.LocalAIAssistantHolder, piiRedactor *pii.Redactor, piiEvents pii.EventStore) echo.HandlerFunc {
func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, startupOptions *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient, assistantHolder *mcpTools.LocalAIAssistantHolder) echo.HandlerFunc {
return func(c echo.Context) error {
var textContentToReturn string
id := uuid.New().String()
@@ -152,11 +151,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
// Cloud-proxy bail. Bypasses the local pipeline (templating,
// MCP injection, gRPC backend) and forwards via the cloud-
// proxy backend, which does the outbound HTTP. The streaming
// PII filter still runs because its input is per-token text
// extracted from the wire envelope, not the envelope itself.
// proxy backend, which does the outbound HTTP. Request-side PII
// redaction already ran in the middleware; the response is
// forwarded unmodified.
if config.IsCloudProxyBackendPassthrough() {
return forwardCloudProxyOpenAIViaBackend(c, config, input, piiRedactor, piiEvents, ml, startupOptions)
return forwardCloudProxyOpenAIViaBackend(c, config, input, ml, startupOptions)
}
funcs := input.Functions
@@ -327,7 +326,8 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
"message": map[string]any{
"type": "string",
"description": "The message to reply the user with",
}},
},
},
},
}
@@ -393,14 +393,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
c.Response().Header().Set("Connection", "keep-alive")
c.Response().Header().Set("X-Correlation-ID", id)
// Per-stream PII filter: when the resolved model has PII
// enabled, wrap the response content so values spanning
// chunk boundaries still get masked. Shared with the
// cloud-proxy bail below via cloudproxy.BuildStreamFilter
// so both paths apply the same per-model gate and override
// rules.
streamPIIFilter := cloudproxy.BuildStreamFilter(c, config, true, piiRedactor, piiEvents, id)
mcpStreamMaxIterations := 10
if config.Agent.MaxIterations > 0 {
mcpStreamMaxIterations = config.Agent.MaxIterations
@@ -476,30 +468,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
if (hasMCPToolsStream || config.FunctionsConfig.AutomaticToolParsingFallback) && haveContent {
collectedContent += rawContent
}
// Stream-side PII filter: feed the content delta
// through the buffered-emit filter. The filter
// holds back a tail to handle pattern boundaries
// across chunks, so a Push may legitimately
// return "" — drop the chunk in that case rather
// than emitting an empty Delta to the wire.
if streamPIIFilter != nil && haveContent {
filtered := streamPIIFilter.Push(rawContent)
if filtered == "" {
// Fully buffered — skip this chunk's
// content. Still emit non-content chunks
// (role, tool_calls). When this delta is
// content-only and we buffer it, drop the
// whole event to avoid a vestigial
// {"delta":{}} on the wire.
if ev.Choices[0].Delta.Role == "" && len(ev.Choices[0].Delta.ToolCalls) == 0 && ev.Choices[0].Delta.Reasoning == nil {
continue
}
// Mixed delta — strip content, keep the rest.
ev.Choices[0].Delta.Content = nil
} else {
ev.Choices[0].Delta.Content = filtered
}
}
respData, err := json.Marshal(ev)
if err != nil {
xlog.Debug("Failed to marshal response", "error", err)
@@ -644,31 +612,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
}
}
// Drain the per-stream PII filter before the stop chunk
// so any text held back by the buffered-emit invariant
// reaches the client as a regular content delta. We
// emit it as a chunk WITHOUT a finish_reason so the
// next "stop" chunk still terminates the stream.
if streamPIIFilter != nil {
residual := streamPIIFilter.Drain()
if residual != "" {
drainResp := &schema.OpenAIResponse{
ID: id,
Created: created,
Model: input.Model,
Choices: []schema.Choice{{
Delta: &schema.Message{Content: residual},
Index: 0,
}},
Object: "chat.completion.chunk",
}
if drainBytes, err := json.Marshal(drainResp); err == nil {
_, _ = fmt.Fprintf(c.Response().Writer, "data: %s\n\n", drainBytes)
c.Response().Flush()
}
}
}
// No MCP tools to execute, send final stop message
finishReason := FinishReasonStop
if toolsCalled && len(input.Tools) > 0 {
@@ -689,7 +632,8 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
FinishReason: &finishReason,
Index: 0,
Delta: &schema.Message{},
}},
},
},
Object: "chat.completion.chunk",
}
respData, _ := json.Marshal(resp)
@@ -1075,7 +1019,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
}
func handleQuestion(config *config.ModelConfig, funcResults []functions.FuncCallResults, result, prompt string) (string, error) {
if len(funcResults) == 0 && result != "" {
xlog.Debug("nothing function results but we had a message from the LLM")
@@ -1111,19 +1054,16 @@ func handleQuestion(config *config.ModelConfig, funcResults []functions.FuncCall
return "", nil
}
// forwardCloudProxyOpenAIViaBackend marshals the OpenAI request,
// constructs the streaming PII filter (when this model has PII
// enabled), and hands off to the cloud-proxy gRPC backend which does
// the outbound HTTP. The chat endpoint owns the body+filter
// construction because it's the only place the request lands as a
// parsed *schema.OpenAIRequest.
func forwardCloudProxyOpenAIViaBackend(c echo.Context, cfg *config.ModelConfig, input *schema.OpenAIRequest, piiRedactor *pii.Redactor, piiEvents pii.EventStore, ml *model.ModelLoader, appConfig *config.ApplicationConfig) error {
// forwardCloudProxyOpenAIViaBackend marshals the OpenAI request and
// hands off to the cloud-proxy gRPC backend which does the outbound
// HTTP. The chat endpoint owns the body construction because it's the
// only place the request lands as a parsed *schema.OpenAIRequest.
// Request-side PII redaction already ran in the middleware; the
// response is forwarded unmodified.
func forwardCloudProxyOpenAIViaBackend(c echo.Context, cfg *config.ModelConfig, input *schema.OpenAIRequest, ml *model.ModelLoader, appConfig *config.ApplicationConfig) error {
body, err := json.Marshal(input)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "cloudproxy: marshal request: "+err.Error())
}
correlationID := c.Response().Header().Get("X-Correlation-ID")
streamFilter := cloudproxy.BuildStreamFilter(c, cfg, input.Stream, piiRedactor, piiEvents, correlationID)
return cloudproxy.ForwardViaBackend(c, cfg, body, streamFilter, ml, appConfig)
return cloudproxy.ForwardViaBackend(c, cfg, body, ml, appConfig)
}

View File

@@ -9,12 +9,10 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
@@ -27,7 +25,7 @@ import (
// @Param request body schema.OpenAIRequest true "query params"
// @Success 200 {object} schema.OpenAIResponse "Response"
// @Router /v1/completions [post]
func CompletionEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, piiRedactor *pii.Redactor, piiEvents pii.EventStore) echo.HandlerFunc {
func CompletionEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
process := func(id string, s string, req *schema.OpenAIRequest, config *config.ModelConfig, loader *model.ModelLoader, responses chan schema.OpenAIResponse, extraUsage bool) error {
tokenCallback := func(s string, tokenUsage backend.TokenUsage) bool {
created := int(time.Now().Unix())
@@ -70,7 +68,6 @@ func CompletionEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eva
}
return func(c echo.Context) error {
created := int(time.Now().Unix())
// Handle Correlation
@@ -113,31 +110,8 @@ func CompletionEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eva
return errors.New("cannot handle more than 1 `PromptStrings` when Streaming")
}
// Per-stream PII filter — same gating as chat. /v1/completions
// has no chat-message structure, so request-side PII isn't
// wired here, but the response-side filter still catches PII
// trained into the model. Filter is nil when this model has
// PII disabled.
var streamPIIFilter *pii.StreamFilter
if piiRedactor != nil && config.PIIIsEnabled() {
correlationID := id
userID := ""
if u := auth.GetUser(c); u != nil {
userID = u.ID
}
var overrides map[string]pii.Action
if raw := config.PIIPatternOverrides(); len(raw) > 0 {
overrides = make(map[string]pii.Action, len(raw))
for ovid, action := range raw {
switch pii.Action(action) {
case pii.ActionMask, pii.ActionBlock, pii.ActionAllow:
overrides[ovid] = pii.Action(action)
}
}
}
streamPIIFilter = pii.NewStreamFilter(piiRedactor, overrides, piiEvents, correlationID, userID)
}
// Response/output PII redaction is out of scope for now —
// redaction runs request-side via the NER middleware only.
predInput := config.PromptStrings[0]
templatedInput, err := evaluator.EvaluateTemplateForPrompt(templates.CompletionPromptTemplate, *config, templates.PromptTemplateData{
@@ -179,19 +153,6 @@ func CompletionEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eva
// OpenAI streaming spec: intermediate chunks must NOT
// carry a `usage` field. Strip the tracking copy now.
ev.Usage = nil
// Run the per-chunk text through the streaming PII
// filter. The filter holds back a tail to handle
// pattern boundaries, so a Push may legitimately
// return "" — drop the chunk's text rather than
// emitting a 0-token delta. Choice.Text is the only
// content surface in /v1/completions chunks.
if streamPIIFilter != nil && ev.Choices[0].Text != "" {
filtered := streamPIIFilter.Push(ev.Choices[0].Text)
if filtered == "" {
continue
}
ev.Choices[0].Text = filtered
}
respData, err := json.Marshal(ev)
if err != nil {
xlog.Debug("Failed to marshal response", "error", err)
@@ -237,25 +198,6 @@ func CompletionEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eva
}
}
// Flush any residual the streaming PII filter held back as
// part of its trailing pattern-window. Emit it as one final
// text-bearing chunk before the synthetic stop chunk so the
// completion body remains a contiguous text stream.
if streamPIIFilter != nil {
if residual := streamPIIFilter.Drain(); residual != "" {
residualResp := schema.OpenAIResponse{
ID: id,
Created: created,
Model: input.Model,
Choices: []schema.Choice{{Index: 0, Text: residual}},
Object: "text_completion",
}
if data, err := json.Marshal(residualResp); err == nil {
_, _ = fmt.Fprintf(c.Response().Writer, "data: %s\n\n", string(data))
}
}
}
stopReason := FinishReasonStop
resp := &schema.OpenAIResponse{
ID: id,

View File

@@ -391,18 +391,12 @@ func buildClassifier(cfg *config.ModelConfig, deps ClassifierDeps) (router.Class
}
// assertClassifierDeclaresScore refuses to build the score classifier
// unless classifier_model's config declares FLAG_SCORE. The actual
// usecase-conflict check (score + chat/completion/embeddings on
// llama-cpp) lives in ModelConfig.Validate() and fires at config load
// and save time — by the time we get here, any model that reached the
// loader is already conflict-free. This check just refuses to bind a
// model that never declared itself for Score in the first place; that
// model could be a misconfigured chat model the operator pointed at
// by accident, and without FLAG_SCORE the validator never saw it.
// unless classifier_model's config declares FLAG_SCORE. This check only
// refuses to bind a model that never declared itself for Score in the
// first place; that model could be a misconfigured chat model the
// operator pointed at by accident.
//
// When lookup is nil (test wiring) the check is skipped and we fall
// back to the C++ backend's runtime tripwire as the last line of
// defence.
// When lookup is nil (test wiring) the check is skipped.
func assertClassifierDeclaresScore(classifierModel string, lookup ModelConfigLookup) error {
if lookup == nil {
return nil
@@ -416,8 +410,8 @@ func assertClassifierDeclaresScore(classifierModel string, lookup ModelConfigLoo
if !cfg.HasUsecases(config.FLAG_SCORE) {
return fmt.Errorf(
"router classifier score: classifier_model %q does not declare the "+
"score usecase. Add `known_usecases: [score]` to its config so "+
"the loader can reject conflicting usecase combinations",
"score usecase. Add `known_usecases: [score]` (alongside any other "+
"usecases the model serves) to its config",
classifierModel)
}
return nil

View File

@@ -1,24 +1,29 @@
import { test, expect } from '@playwright/test'
// Mocked fixture covering the three things the page renders:
// - PII pattern catalogue (action badges, action-change buttons)
// - Per-model resolved PII state (one with default off, one with proxy default on, one with explicit YAML)
// Mocked fixture covering the things the page renders:
// - Per-model resolved PII state + the NER detectors each references
// (one with default off, one with proxy default on, one explicit YAML)
// - Recent events feed (the page must NEVER show the redacted content)
const MOCK_STATUS = {
pii: {
enabled_globally: true,
default_enabled_for_backends: ['cloud-proxy'],
patterns: [
{ id: 'email', description: 'Email addresses', action: 'mask', max_match_length: 254 },
{ id: 'ssn', description: 'US Social Security Numbers', action: 'mask', max_match_length: 11 },
{ id: 'api_key_prefix', description: 'API key prefixes', action: 'block', max_match_length: 200 },
],
models: [
{ name: 'qwen-7b', backend: 'llama-cpp', enabled: false, explicit: false, default_for_backend: false, overrides: null },
{ name: 'claude-sonnet', backend: 'cloud-proxy', enabled: true, explicit: false, default_for_backend: true, overrides: null },
{ name: 'claude-strict', backend: 'cloud-proxy', enabled: true, explicit: true, default_for_backend: true, overrides: { ssn: 'block' } },
{ name: 'qwen-7b', backend: 'llama-cpp', enabled: false, explicit: false, default_for_backend: false, detectors: null },
{ name: 'claude-sonnet', backend: 'cloud-proxy', enabled: true, explicit: false, default_for_backend: true, detectors: null },
{ name: 'claude-strict', backend: 'cloud-proxy', enabled: true, explicit: true, default_for_backend: true, detectors: ['privacy-filter-multilingual'] },
],
recent_event_count: 2,
// Instance-wide default detector set (managed by the Detector models
// table's per-row Default toggle).
default_detectors: ['global-ner-default'],
// The token_classify "filter" models themselves: one NER, one in-process
// pattern matcher, plus an orphan default that names a model not loaded.
detector_models: [
{ name: 'privacy-filter-multilingual', backend: 'llama-cpp', type: 'ner', default: false },
{ name: 'secret-filter', backend: 'pattern', type: 'pattern', default: false },
{ name: 'global-ner-default', backend: '', type: 'unknown', default: true, missing: true },
],
},
router: {
configured: true,
@@ -114,23 +119,104 @@ test.describe('Middleware page — admin in no-auth mode', () => {
await page.route('**/api/router/decisions?**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK_DECISIONS) })
)
// The Default PII policy detector picker is capability-filtered to
// token_classify via /api/models/capabilities.
await page.route('**/api/models/capabilities', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ models: [{ id: 'privacy-filter-multilingual', capabilities: ['FLAG_TOKEN_CLASSIFY'], backend: 'llama-cpp' }] }),
})
)
await page.route('**/api/settings', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true }) })
)
// The per-model PII toggle PATCHes the model config (pii.enabled).
await page.route('**/api/models/config-json/**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true }) })
)
})
test('Filtering tab renders pattern catalogue and per-model state', async ({ page }) => {
test('Filtering tab renders per-model state and referenced detectors', async ({ page }) => {
await page.goto('/app/middleware')
// Pattern table — at least one pattern id visible.
await expect(page.getByText('email').first()).toBeVisible()
await expect(page.getByText('api_key_prefix').first()).toBeVisible()
// Per-model state — each model's name is visible.
await expect(page.getByText('qwen-7b').first()).toBeVisible()
await expect(page.getByText('claude-strict').first()).toBeVisible()
// The detector a model references is shown in its row.
await expect(page.getByText('privacy-filter-multilingual').first()).toBeVisible()
// Default-policy banner names the backends with PII on by default.
await expect(page.getByText(/cloud-proxy/).first()).toBeVisible()
})
test('Filtering tab lists detector models with type badges and a default toggle', async ({ page }) => {
await page.goto('/app/middleware')
// The Detector models card renders every token_classify filter model.
await expect(page.getByText('Detector models')).toBeVisible()
const nerRow = page.locator('tr').filter({ hasText: 'privacy-filter-multilingual' }).first()
await expect(nerRow).toContainText(/NER/i)
const patternRow = page.locator('tr').filter({ hasText: 'secret-filter' }).first()
await expect(patternRow).toContainText(/pattern/i)
// The NER detector is not (yet) a default — its toggle is unchecked.
// (The underlying checkbox is 0×0 by design, so we click the label wrapper.)
const nerToggle = nerRow.locator('label.toggle')
await expect(nerToggle.locator('input[type="checkbox"]')).not.toBeChecked()
// Toggling it on persists the new default set via POST /api/settings.
const saved = page.waitForRequest(req =>
req.url().includes('/api/settings') && req.method() === 'POST')
await nerToggle.click()
const req = await saved
const body = JSON.parse(req.postData() || '{}')
expect(body.pii_default_detectors).toContain('privacy-filter-multilingual')
})
test('Filtering tab surfaces an orphan default detector that is not loaded', async ({ page }) => {
await page.goto('/app/middleware')
// global-ner-default names a model that is not loaded, but it is in the
// default set — it must still appear (toggled on) so admins can remove it.
const orphanRow = page.locator('tr').filter({ hasText: 'global-ner-default' }).first()
await expect(orphanRow).toContainText(/not loaded/i)
await expect(orphanRow.locator('label.toggle input[type="checkbox"]')).toBeChecked()
})
test('Filtering tab flags an enabled model with no detector as a no-op', async ({ page }) => {
await page.goto('/app/middleware')
// claude-sonnet is enabled by the cloud-proxy backend default but lists
// no detectors and there is no instance default detector — it scans
// nothing, so the row must warn rather than read as protected.
const noopRow = page.locator('tr').filter({ hasText: 'claude-sonnet' }).first()
await expect(noopRow).toContainText(/no-op/i)
// claude-strict has an explicit detector — it must NOT be flagged.
const okRow = page.locator('tr').filter({ hasText: 'claude-strict' }).first()
await expect(okRow).not.toContainText(/no-op/i)
})
test('Filtering tab PII column toggles a model\'s pii.enabled via PATCH', async ({ page }) => {
await page.goto('/app/middleware')
// qwen-7b is OFF (enabled:false) — its PII toggle reads unchecked.
const row = page.locator('tr').filter({ hasText: 'qwen-7b' }).first()
const toggle = row.locator('label.toggle')
await expect(toggle.locator('input[type="checkbox"]')).not.toBeChecked()
// Toggling on PATCHes the model config with an explicit pii.enabled:true,
// scoped to that model (no other field is sent — the server deep-merges).
const patched = page.waitForRequest(req =>
req.url().includes('/api/models/config-json/') && req.method() === 'PATCH')
await toggle.click()
const req = await patched
expect(decodeURIComponent(req.url())).toContain('qwen-7b')
const body = JSON.parse(req.postData() || '{}')
expect(body.pii.enabled).toBe(true)
})
test('Routing tab renders configured routers and recent decisions', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Routing/i }).click()
@@ -265,25 +351,6 @@ test.describe('Middleware page — admin in no-auth mode', () => {
await expect(page.getByText(/^proxy traffic$/i).first()).toBeVisible()
})
test('PUT /api/pii/patterns/:id fires when an action button is clicked', async ({ page }) => {
let putHit = null
await page.route('**/api/pii/patterns/email', (route) => {
if (route.request().method() === 'PUT') {
putHit = JSON.parse(route.request().postData() || '{}')
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ id: 'email', action: putHit.action, persisted: false }) })
} else {
route.continue()
}
})
await page.goto('/app/middleware')
// Click the email row's "block" button (currently mask, so block is
// enabled). Use a precise locator that matches the inner button.
const emailRow = page.locator('tr').filter({ hasText: 'email' }).first()
await emailRow.getByRole('button', { name: 'block' }).click()
await expect.poll(() => putHit).toEqual({ action: 'block' })
})
})
test.describe('Middleware page — non-admin under auth-on', () => {

View File

@@ -12,6 +12,9 @@ const MOCK_METADATA = {
{ path: 'cuda', yaml_key: 'cuda', go_type: 'bool', ui_type: 'bool', section: 'general', label: 'CUDA', description: 'Enable CUDA GPU acceleration', component: 'toggle', order: 30 },
{ path: 'parameters.temperature', yaml_key: 'temperature', go_type: '*float64', ui_type: 'float', section: 'parameters', label: 'Temperature', description: 'Sampling temperature', component: 'slider', min: 0, max: 2, step: 0.1, order: 0 },
{ path: 'parameters.top_p', yaml_key: 'top_p', go_type: '*float64', ui_type: 'float', section: 'parameters', label: 'Top P', description: 'Nucleus sampling threshold', component: 'slider', min: 0, max: 1, step: 0.05, order: 10 },
{ path: 'pii_detection.builtins', yaml_key: 'builtins', go_type: '[]string', ui_type: '[]string', section: 'general', label: 'Built-in Secret Patterns', description: 'Built-in credential patterns', component: 'pii-builtins-select', options: [{ value: 'anthropic_api_key', label: 'anthropic_api_key — Anthropic API key' }, { value: 'github_token', label: 'github_token — GitHub token' }], order: 213 },
{ path: 'pii_detection.patterns', yaml_key: 'patterns', go_type: '[]config.PIIPattern', ui_type: 'object', section: 'general', label: 'Custom Secret Patterns', description: 'Operator-defined restricted-regex patterns', component: 'pii-pattern-list', order: 214 },
{ path: 'pii_detection.entity_actions', yaml_key: 'entity_actions', go_type: 'map[string]string', ui_type: 'map', section: 'general', label: 'Detector Entity Actions', description: 'Per-entity-group action policy', component: 'entity-action-list', order: 212 },
],
}
@@ -258,4 +261,72 @@ test.describe('Model Editor - Interactive Tab', () => {
await expect(page.locator('nav').first()).toBeVisible()
})
test('built-in secret patterns render as a checklist from field options', async ({ page }) => {
const searchInput = page.locator('input[placeholder="Search fields to add..."]')
await searchInput.fill('Built-in Secret Patterns')
const dropdown = searchInput.locator('..').locator('..')
await dropdown.locator('div', { hasText: 'Built-in Secret Patterns' }).first().click()
// One checkbox per catalogue option; toggling one enables Save.
const anthropic = page.locator('label', { hasText: 'Anthropic API key' }).locator('input[type="checkbox"]')
await expect(anthropic).toHaveCount(1)
await anthropic.check()
await expect(anthropic).toBeChecked()
})
test('custom secret patterns render the pattern-list editor', async ({ page }) => {
const searchInput = page.locator('input[placeholder="Search fields to add..."]')
await searchInput.fill('Custom Secret Patterns')
const dropdown = searchInput.locator('..').locator('..')
await dropdown.locator('div', { hasText: 'Custom Secret Patterns' }).first().click()
// Empty state + an Add button; adding a row shows the name + match inputs.
const addBtn = page.locator('button', { hasText: 'Add pattern' })
await expect(addBtn).toBeVisible()
await addBtn.click()
await expect(page.locator('input[placeholder^="Name (group)"]')).toBeVisible()
await expect(page.locator('input[placeholder^="match,"]')).toBeVisible()
})
// Regression: a map-typed field (entity_actions) present in the loaded YAML
// must render WITH its values. flattenConfig used to recurse into the map,
// scattering it across pii_detection.entity_actions.<GROUP> paths that match
// no registered field, so the editor showed neither the field nor the
// per-entity policy (e.g. SSN -> block) the operator had configured.
test('entity_actions map field present in YAML renders with its values', async ({ page }) => {
// Override the edit endpoint for this test: YAML that carries a populated
// entity_actions map alongside a scalar sibling (default_action).
await page.route('**/api/models/edit/ner-model', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
name: 'ner-model',
config: [
'name: ner-model',
'backend: llama-cpp',
'pii_detection:',
' default_action: mask',
' entity_actions:',
' SSN: block',
' EMAIL: mask',
'',
].join('\n'),
}),
})
})
await page.goto('/app/model-editor/ner-model')
// The entity-action-list editor is rendered (field label visible)…
await expect(page.getByText('Detector Entity Actions').first()).toBeVisible()
// …and bound to the existing map: one row per configured group, in order.
const groupInputs = page.locator('input[aria-label="Entity group"]')
await expect(groupInputs).toHaveCount(2)
await expect(groupInputs.nth(0)).toHaveValue('SSN')
await expect(groupInputs.nth(1)).toHaveValue('EMAIL')
// The action select shows the bound action label (block), proving the map
// values bound, not just an empty editor.
await expect(page.getByText(/block —/i).first()).toBeVisible()
})
})

View File

@@ -178,7 +178,7 @@ test.describe("Models Gallery - Backend Features", () => {
});
const BACKEND_USECASES_MOCK = {
"llama-cpp": ["chat", "embeddings", "vision"],
"llama-cpp": ["chat", "embeddings", "vision", "token_classify"],
whisper: ["transcript"],
stablediffusion: ["image"],
};
@@ -285,13 +285,15 @@ test.describe("Models Gallery - Multi-select Filters", () => {
await expect(sttBtn).toBeDisabled();
await expect(imageBtn).toBeDisabled();
// Chat, Embeddings, Vision should remain enabled
// Chat, Embeddings, Vision, NER should remain enabled
const chatBtn = page.locator(".filter-btn", { hasText: "Chat" });
const embBtn = page.locator(".filter-btn", { hasText: "Embeddings" });
const visBtn = page.locator(".filter-btn", { hasText: "Vision" });
const nerBtn = page.locator(".filter-btn", { hasText: "NER" });
await expect(chatBtn).toBeEnabled();
await expect(embBtn).toBeEnabled();
await expect(visBtn).toBeEnabled();
await expect(nerBtn).toBeEnabled();
});
test("backend clears incompatible filters", async ({ page }) => {

View File

@@ -30,6 +30,7 @@
"rerank": "Rerank",
"detection": "Detection",
"vad": "VAD",
"ner": "NER",
"fitsGpu": "Fits in GPU",
"allBackends": "All Backends",
"searchBackends": "Search backends..."

View File

@@ -6,7 +6,9 @@ import SearchableModelSelect from './SearchableModelSelect'
import AutocompleteInput from './AutocompleteInput'
import CodeEditor from './CodeEditor'
import StructuredCodeEditor from './StructuredCodeEditor'
import PIIPatternListEditor from './PIIPatternListEditor'
import EntityActionListEditor from './EntityActionListEditor'
import PatternListEditor from './PatternListEditor'
import ModelMultiSelect from './ModelMultiSelect'
import RouterCandidatesEditor from './RouterCandidatesEditor'
import RouterPoliciesEditor from './RouterPoliciesEditor'
@@ -17,6 +19,7 @@ const PROVIDER_TO_CAPABILITY = {
'models:transcript': 'FLAG_TRANSCRIPT',
'models:vad': 'FLAG_VAD',
'models:score': 'FLAG_SCORE',
'models:token_classify': 'FLAG_TOKEN_CLASSIFY',
}
function coerceValue(raw, uiType) {
@@ -395,10 +398,10 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
)
}
// PII pattern list — per-model action overrides for named patterns.
// The pattern catalog is loaded from /api/pii/patterns at render time
// so new built-in patterns surface automatically.
if (component === 'pii-pattern-list') {
// PII detectors — a capability-filtered multi-select of token_classify
// models (the consuming model's pii.detectors list).
if (component === 'model-multi-select') {
const cap = PROVIDER_TO_CAPABILITY[field.autocomplete_provider] || undefined
return (
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
@@ -407,7 +410,62 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
</div>
</div>
<PIIPatternListEditor value={value} onChange={handleChange} />
<ModelMultiSelect value={value} onChange={handleChange} capability={cap} placeholder={field.placeholder} />
</div>
)
}
// PII detection entity-action map — a detector model's
// pii_detection.entity_actions (entity group -> mask|block|allow).
if (component === 'entity-action-list') {
return (
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<div>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
</div>
</div>
<EntityActionListEditor value={value} onChange={handleChange} />
</div>
)
}
// PII built-in secret patterns — a checklist of named built-in patterns
// (pii_detection.builtins). value is an array of selected names.
if (component === 'pii-builtins-select') {
const selected = Array.isArray(value) ? value : []
const toggle = (name) => {
handleChange(selected.includes(name) ? selected.filter(n => n !== name) : [...selected, name])
}
return (
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{(field.options || []).map(opt => (
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.8125rem', cursor: 'pointer' }}>
<input type="checkbox" checked={selected.includes(opt.value)} onChange={() => toggle(opt.value)} />
{opt.label || opt.value}
</label>
))}
</div>
</div>
)
}
// PII custom secret patterns — operator-defined restricted-regex rules
// (pii_detection.patterns). value is an array of {name, match, action, min_len}.
if (component === 'pii-pattern-list') {
return (
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
</div>
<PatternListEditor value={value} onChange={handleChange} />
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useMemo } from 'react'
import SearchableSelect from './SearchableSelect'
// Editor for a detector model's pii_detection.entity_actions map:
// entity-group name -> action. The value is an object {GROUP: action};
// this component renders one row per entry and emits a fresh object on
// every change. Entity-group names are model-defined (the privacy-filter
// family emits uppercase names with no separators), so the group field is
// free text with a datalist of common high-value categories for
// convenience — any string the model emits is valid.
const ACTION_OPTIONS = [
{ value: 'mask', label: 'mask — replace with [REDACTED:ner:GROUP]' },
{ value: 'block', label: 'block — reject the request (HTTP 400)' },
{ value: 'allow', label: 'allow — detect & log, leave text unchanged' },
]
// Common categories surfaced as datalist hints. Not exhaustive and not
// authoritative — the model's own label set is the source of truth.
const COMMON_GROUPS = [
'PASSWORD', 'PIN', 'CVV', 'CREDITCARD', 'IBAN', 'BIC', 'BANKACCOUNT', 'SSN',
'BITCOINADDRESS', 'ETHEREUMADDRESS', 'LITECOINADDRESS',
'EMAIL', 'PHONE', 'URL', 'IPADDRESS', 'MACADDRESS',
'FIRSTNAME', 'LASTNAME', 'MIDDLENAME', 'USERNAME', 'DATEOFBIRTH',
'STREET', 'CITY', 'STATE', 'ZIPCODE', 'GPSCOORDINATES',
]
export default function EntityActionListEditor({ value, onChange }) {
// value is an object map; preserve insertion order via Object.entries.
const entries = useMemo(
() => (value && typeof value === 'object' && !Array.isArray(value) ? Object.entries(value) : []),
[value]
)
const datalistId = 'pii-entity-groups'
const update = (index, key, action) => {
const next = entries.map((e, i) => (i === index ? [key, action] : e))
onChange(Object.fromEntries(next.filter(([k]) => k !== '')))
}
const remove = (index) => {
onChange(Object.fromEntries(entries.filter((_, i) => i !== index)))
}
const add = () => {
// New rows default to mask; an empty key is tolerated transiently and
// filtered out on the next edit / when serialised.
onChange(Object.fromEntries([...entries, ['', 'mask']]))
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
<datalist id={datalistId}>
{COMMON_GROUPS.map(g => <option key={g} value={g} />)}
</datalist>
{entries.length === 0 && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
No per-entity actions every detected group uses the default action. Add a row to
block or allow-log a specific entity group (e.g. <code>PASSWORD</code> block).
</div>
)}
{entries.map(([group, action], i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<input
className="input"
list={datalistId}
value={group}
placeholder="Entity group (e.g. PASSWORD)"
onChange={e => update(i, e.target.value, action)}
style={{ flex: '1 1 220px', minWidth: 180, fontSize: '0.8125rem' }}
aria-label="Entity group"
/>
<SearchableSelect
value={action || 'mask'}
onChange={v => update(i, group, v)}
options={ACTION_OPTIONS}
placeholder="Action..."
style={{ flex: '1 1 240px', minWidth: 220 }}
/>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => remove(i)}
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
aria-label="Remove entity action">
<i className="fas fa-times" />
</button>
</div>
))}
<button type="button" className="btn btn-secondary btn-sm" onClick={add}
style={{ alignSelf: 'flex-start', fontSize: '0.75rem' }}>
<i className="fas fa-plus" /> Add entity action
</button>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import SearchableModelSelect from './SearchableModelSelect'
// Editor for a list of model names (value is []string). Selected models render
// as compact removable chips; a single capability-filtered, commit-only picker
// adds new ones. Used for pii.detectors / the instance-wide default detector,
// where every entry must be a token_classify model. Already-selected models are
// guarded against so each appears at most once.
//
// The picker is commit-only on purpose: typing a partial query must never be
// treated as a chosen model (otherwise each keystroke would add a bogus entry),
// and selecting one input box per detector wastes vertical space.
export default function ModelMultiSelect({ value, onChange, capability, placeholder }) {
const items = Array.isArray(value) ? value : []
const remove = (index) => onChange(items.filter((_, i) => i !== index))
const add = (v) => {
if (!v || items.includes(v)) return
onChange([...items, v])
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
{items.length === 0 ? (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
No detectors PII is enabled but nothing scans requests. Add a token-classification
(NER) model below; its <code>pii_detection</code> block supplies the policy.
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{items.map((name, i) => (
<span key={i} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '2px 4px 2px 10px', fontSize: '0.8125rem',
fontFamily: 'var(--font-mono)', background: 'var(--color-bg-tertiary)',
borderRadius: 'var(--radius-md)',
}}>
{name}
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => remove(i)}
style={{ padding: '0 6px', fontSize: '0.75rem', lineHeight: 1.6 }}
aria-label={`Remove ${name}`}>
<i className="fas fa-times" />
</button>
</span>
))}
</div>
)}
{/* Size by width only. The container is a flex column, so a flex-basis
here would set the wrapper's HEIGHT — which the dropdown anchors to
(top: 100%), opening it far below the input. */}
<SearchableModelSelect
value=""
onChange={add}
commitOnly
capability={capability}
placeholder={placeholder || '+ Add detector model...'}
style={{ width: '100%', maxWidth: 360 }}
/>
</div>
)
}

View File

@@ -1,120 +0,0 @@
import { useState, useEffect, useMemo } from 'react'
import { apiUrl } from '../utils/basePath'
import SearchableSelect from './SearchableSelect'
const ACTION_OPTIONS = [
{ value: 'mask', label: 'Mask — replace with a [REDACTED:id] placeholder' },
{ value: 'block', label: 'Block — reject the request (request side) / mask in stream' },
{ value: 'allow', label: 'Allow — detect & log, leave text unchanged' },
]
export default function PIIPatternListEditor({ value, onChange }) {
const items = Array.isArray(value) ? value : []
const [catalog, setCatalog] = useState([])
const [loadError, setLoadError] = useState(null)
useEffect(() => {
let cancelled = false
fetch(apiUrl('/api/pii/patterns'))
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(data => { if (!cancelled) setCatalog(data?.patterns || []) })
.catch(err => { if (!cancelled) setLoadError(err.message) })
return () => { cancelled = true }
}, [])
const idOptions = useMemo(() =>
catalog.map(p => ({
value: p.id,
label: p.description ? `${p.id}${p.description}` : p.id,
})),
[catalog]
)
// Patterns already chosen — exclude from the "add row" select so each
// pattern only appears once per model.
const usedIDs = new Set(items.map(it => it?.id).filter(Boolean))
const availableForAdd = idOptions.filter(o => !usedIDs.has(o.value))
const update = (index, key, val) => {
const next = items.map((it, i) =>
i === index ? { ...it, [key]: val } : it
)
onChange(next)
}
const remove = (index) => {
onChange(items.filter((_, i) => i !== index))
}
const add = (id) => {
const cat = catalog.find(c => c.id === id)
onChange([...items, { id, action: cat?.action || 'mask' }])
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
{loadError && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-error)' }}>
Could not load pattern catalog: {loadError}. You can still type IDs manually.
</div>
)}
{items.length === 0 && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
No overrides every pattern uses its global default action. Add a row below to
tighten or relax the action for a specific pattern on this model.
</div>
)}
{items.map((row, i) => {
const cat = catalog.find(c => c.id === row?.id)
const idLabel = cat?.description ? `${row.id}${cat.description}` : (row?.id || '')
// Show the chosen id even if the catalog hasn't loaded yet (or
// the YAML references an unknown pattern), so users can edit
// without losing context.
const idItems = [
...(row?.id && !idOptions.some(o => o.value === row.id)
? [{ value: row.id, label: idLabel }]
: []),
...idOptions.filter(o => o.value === row?.id || !usedIDs.has(o.value)),
]
return (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<SearchableSelect
value={row?.id || ''}
onChange={v => update(i, 'id', v)}
options={idItems}
placeholder="Pattern..."
style={{ flex: '1 1 220px', minWidth: 200 }}
/>
<SearchableSelect
value={row?.action || 'mask'}
onChange={v => update(i, 'action', v)}
options={ACTION_OPTIONS}
placeholder="Action..."
style={{ flex: '1 1 240px', minWidth: 220 }}
/>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => remove(i)}
style={{ padding: '2px 8px', fontSize: '0.75rem' }}>
<i className="fas fa-times" />
</button>
</div>
)
})}
{availableForAdd.length > 0 && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<SearchableSelect
value=""
onChange={v => v && add(v)}
options={availableForAdd}
placeholder="+ Add pattern override..."
style={{ flex: '1 1 220px', minWidth: 200 }}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,96 @@
import { useMemo } from 'react'
import SearchableSelect from './SearchableSelect'
// Editor for a pattern detector's pii_detection.patterns: a list of
// operator-defined secret patterns. Value is an array of
// { name, match, action?, min_len? }; this renders one row per pattern and
// emits a fresh array on every change. Patterns use a restricted regex subset
// validated server-side at save (an invalid pattern surfaces as the save
// error), so no regex engine is shipped to the client.
const ACTION_OPTIONS = [
{ value: '', label: 'default (use Default Action)' },
{ value: 'mask', label: 'mask — replace the span' },
{ value: 'block', label: 'block — reject the request' },
{ value: 'allow', label: 'allow — detect & log only' },
]
function emptyPattern() {
return { name: '', match: '', action: '', min_len: 0 }
}
export default function PatternListEditor({ value, onChange }) {
const rows = useMemo(() => (Array.isArray(value) ? value : []), [value])
const update = (index, patch) => {
onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
}
const remove = (index) => onChange(rows.filter((_, i) => i !== index))
const add = () => onChange([...rows, emptyPattern()])
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
Restricted regex: literals, <code>[]</code> classes, <code>\w \d \s</code>, <code>?*+{'{m,n}'}</code>, anchors.
Each pattern must contain a fixed literal run of 3 characters (e.g. <code>sk-prefix-</code>);
<code>.</code> and capturing groups are not allowed. Matches report under the pattern name.
</div>
{rows.length === 0 && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
No custom patterns. Enable built-ins above, or add a pattern for an internal credential
format (e.g. <code>tok-[A-Za-z0-9]{'{32,64}'}</code>).
</div>
)}
{rows.map((r, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<input
className="input"
value={r.name || ''}
placeholder="Name (group), e.g. INTERNAL_TOKEN"
onChange={e => update(i, { name: e.target.value })}
style={{ flex: '1 1 180px', minWidth: 150, fontSize: '0.8125rem' }}
aria-label="Pattern name"
/>
<input
className="input input-mono"
value={r.match || ''}
placeholder="match, e.g. tok-[A-Za-z0-9]{32,64}"
onChange={e => update(i, { match: e.target.value })}
style={{ flex: '2 1 240px', minWidth: 200, fontSize: '0.8125rem', fontFamily: 'var(--font-mono)' }}
aria-label="Pattern match"
/>
<SearchableSelect
value={r.action || ''}
onChange={v => update(i, { action: v })}
options={ACTION_OPTIONS}
placeholder="Action..."
style={{ flex: '1 1 200px', minWidth: 180 }}
/>
<input
className="input"
type="number"
min={0}
value={r.min_len || 0}
title="Minimum match length (0 = no floor)"
onChange={e => update(i, { min_len: parseInt(e.target.value, 10) || 0 })}
style={{ width: 80, fontSize: '0.8125rem' }}
aria-label="Minimum length"
/>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => remove(i)}
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
aria-label="Remove pattern">
<i className="fas fa-times" />
</button>
</div>
))}
<button type="button" className="btn btn-secondary btn-sm" onClick={add}
style={{ alignSelf: 'flex-start', fontSize: '0.75rem' }}>
<i className="fas fa-plus" /> Add pattern
</button>
</div>
)
}

View File

@@ -1,7 +1,13 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useModels } from '../hooks/useModels'
export default function SearchableModelSelect({ value, onChange, capability, placeholder = 'Type or select a model...', style }) {
// commitOnly: when true, onChange fires only on an explicit commit (selecting an
// item, or Enter) — never on each keystroke. Use it where each onChange is a
// final selection (e.g. the ModelMultiSelect "add" picker), so a partial typed
// query isn't treated as a chosen value. After a commit the field is cleared,
// matching the add-and-clear flow. Default false keeps the as-you-type
// behaviour single-value editors rely on.
export default function SearchableModelSelect({ value, onChange, capability, placeholder = 'Type or select a model...', style, commitOnly = false }) {
const { models, loading } = useModels(capability)
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
@@ -33,11 +39,13 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
: -1
const commit = useCallback((val) => {
setQuery(val)
// In commitOnly mode the field is an "add" box — clear it after a pick so
// the next selection starts fresh; otherwise reflect the chosen value.
setQuery(commitOnly ? '' : val)
onChange(val)
setOpen(false)
setFocusIndex(-1)
}, [onChange])
}, [onChange, commitOnly])
const handleKeyDown = (e) => {
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
@@ -133,8 +141,10 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
setQuery(e.target.value)
setOpen(true)
setFocusIndex(-1)
// Commit on every keystroke so the parent always has current value
onChange(e.target.value)
// Single-value editors want the parent updated as you type; an
// "add" picker (commitOnly) must wait for an explicit commit so a
// partial query is never mistaken for a chosen model.
if (!commitOnly) onChange(e.target.value)
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}

View File

@@ -1,6 +1,11 @@
import { useState, useEffect } from 'react'
import { modelsApi } from '../utils/api'
// Stable empty references so consumers that memoize on `sections`/`fields`
// (e.g. ModelEditor's leafPaths) don't see a new array every render while
// the metadata request is still in flight — which would thrash their effects.
const EMPTY = []
export function useConfigMetadata() {
const [metadata, setMetadata] = useState(null)
const [loading, setLoading] = useState(true)
@@ -14,8 +19,8 @@ export function useConfigMetadata() {
}, [])
return {
sections: metadata?.sections || [],
fields: metadata?.fields || [],
sections: metadata?.sections || EMPTY,
fields: metadata?.fields || EMPTY,
loading,
error,
}

View File

@@ -2,12 +2,13 @@ import { useState, useEffect, useCallback, useRef, useMemo, Fragment } from 'rea
import { useOutletContext, Link, useNavigate, useLocation, useSearchParams } from 'react-router-dom'
import { apiUrl } from '../utils/basePath'
import { fromState } from '../utils/editorNav'
import { settingsApi } from '../utils/api'
import { settingsApi, modelsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import Toggle from '../components/Toggle'
// Middleware admin page. Three tabs:
// - Filtering: PII pattern catalogue + per-model resolved state +
// pattern-action editor (PUT /api/pii/patterns/:id, transient).
// - Filtering: per-model resolved PII state + per-model detector list
// (detection policy lives on each detector model's pii_detection block).
// - Routing: placeholder until subsystem 2 lands. Renders the note
// from /api/router/status so admins see "not yet implemented" rather
// than an empty page.
@@ -27,8 +28,6 @@ const TABS = [
{ id: 'events', label: 'Events', icon: 'fa-list-ul' },
]
const ACTIONS = ['mask', 'block', 'allow']
function actionBadge(action) {
const colors = {
mask: 'var(--color-primary)',
@@ -82,8 +81,6 @@ export default function Middleware() {
const [searchParams, setSearchParams] = useSearchParams()
const initialTab = searchParams.get('tab') || localStorage.getItem('middleware-tab') || 'filtering'
const [activeTab, setActiveTab] = useState(TABS.some(t => t.id === initialTab) ? initialTab : 'filtering')
const [pendingPattern, setPendingPattern] = useState(null) // id while a PUT is in flight
const selectTab = (id) => {
setActiveTab(id)
localStorage.setItem('middleware-tab', id)
@@ -130,51 +127,6 @@ export default function Middleware() {
return () => clearInterval(refreshRef.current)
}, [fetchAll])
const mutatePattern = async (patternID, body, successMsg) => {
setPendingPattern(patternID)
try {
const res = await fetch(apiUrl(`/api/pii/patterns/${encodeURIComponent(patternID)}`), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
}
addToast(successMsg, 'success')
await fetchAll()
} catch (err) {
addToast(`Failed to update pattern: ${err.message}`, 'error')
} finally {
setPendingPattern(null)
}
}
const setPatternAction = (patternID, action) =>
mutatePattern(patternID, { action }, `Pattern ${patternID}: action ${action} (transient — click "Save to disk" to persist)`)
const setPatternDisabled = (patternID, disabled) =>
mutatePattern(patternID, { disabled }, `Pattern ${patternID}: ${disabled ? 'disabled' : 'enabled'} (transient — click "Save to disk" to persist)`)
const [persisting, setPersisting] = useState(false)
const persistPatterns = async () => {
setPersisting(true)
try {
const res = await fetch(apiUrl('/api/pii/patterns/persist'), { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => ({}))
addToast(`Saved ${data.override_count ?? 0} pattern override(s) to runtime_settings.json`, 'success')
} catch (err) {
addToast(`Failed to persist: ${err.message}`, 'error')
} finally {
setPersisting(false)
}
}
return (
<div className="page page--wide">
<div className="page-header" style={{ marginBottom: 'var(--spacing-sm)' }}>
@@ -207,14 +159,7 @@ export default function Middleware() {
<LoadingSpinner size="lg" />
</div>
) : activeTab === 'filtering' ? (
<FilteringTab
status={status}
pendingPattern={pendingPattern}
onSetAction={setPatternAction}
onSetDisabled={setPatternDisabled}
onPersist={persistPatterns}
persisting={persisting}
/>
<FilteringTab status={status} addToast={addToast} onChanged={fetchAll} />
) : activeTab === 'routing' ? (
<RoutingTab status={status} decisions={decisions} />
) : activeTab === 'proxy' ? (
@@ -226,24 +171,33 @@ export default function Middleware() {
)
}
function FilteringTab({ status, pendingPattern, onSetAction, onSetDisabled, onPersist, persisting }) {
function FilteringTab({ status, addToast, onChanged }) {
const location = useLocation()
// Rows mid-save, so just that model's toggle disables while the PATCH
// round-trips (and the 5s background poll re-syncs the resolved state).
const [piiBusy, setPiiBusy] = useState(() => new Set())
// Toggling the PII column writes an explicit pii.enabled to the model YAML
// via PATCH /api/models/config-json/:name (a deep-merge that preserves
// pii.detectors and every other field). This makes the resolved state
// explicit: a cloud-proxy model shown ON by backend default becomes
// pii.enabled:true; toggling it OFF writes pii.enabled:false.
const togglePII = async (name, on) => {
setPiiBusy(prev => new Set(prev).add(name))
try {
await modelsApi.patchConfig(name, { pii: { enabled: on } })
addToast?.(on ? `PII filtering enabled for ${name}` : `PII filtering disabled for ${name}`, 'success')
onChanged?.()
} catch (err) {
addToast?.(`Failed to update ${name}: ${err.message}`, 'error')
} finally {
setPiiBusy(prev => { const n = new Set(prev); n.delete(name); return n })
}
}
if (!status?.pii) return null
const pii = status.pii
if (!pii.enabled_globally) {
return (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-shield-slash" /></div>
<h2 className="empty-state-title">PII filtering disabled</h2>
<p className="empty-state-text">
The PII filter is disabled by <code>{pii.reason || '--disable-pii'}</code>.
Restart without that flag to enable it.
</p>
</div>
)
}
return (
<>
{/* Default rule banner */}
@@ -251,90 +205,23 @@ function FilteringTab({ status, pendingPattern, onSetAction, onSetDisabled, onPe
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-info-circle" style={{ color: 'var(--color-text-muted)', marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Default policy</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>NER-based PII redaction</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
PII redaction is per-model and OFF by default. Backends matching <code>{(pii.default_enabled_for_backends || []).join(', ')}</code> default to ON (cloud passthroughs). Override per model with <code>pii: {'{'} enabled: true {'}'}</code> in the model YAML.
Redaction is per-model and runs request-side. It is OFF by default; backends matching <code>{(pii.default_enabled_for_backends || []).join(', ')}</code> default to ON (cloud passthroughs). A model opts in with <code>pii: {'{'} enabled: true, detectors: [&hellip;] {'}'}</code>; each detector is a <code>token_classify</code> model whose <code>pii_detection</code> block defines the policy (which entities, what action, min score). Edit a detector model to change its policy.
</div>
</div>
</div>
</div>
{/* Patterns table */}
<div className="card" style={{ padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>Active patterns</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
Toggle / action edits are transient click Save to disk to persist.
</span>
<button
className="btn btn-secondary btn-sm"
onClick={onPersist}
disabled={persisting}
style={{ fontSize: '0.75rem' }}
>
<i className={`fas ${persisting ? 'fa-spinner fa-spin' : 'fa-save'}`} /> Save to disk
</button>
</div>
</div>
<div className="table-container">
<table className="table">
<thead>
<tr>
<th style={{ width: 80 }}>Enabled</th>
<th style={{ width: 140 }}>Pattern</th>
<th>Description</th>
<th style={{ width: 110 }}>Action</th>
<th style={{ width: 250 }}>Change</th>
</tr>
</thead>
<tbody>
{pii.patterns.map(p => {
const enabled = !p.disabled
const muted = p.disabled
return (
<tr key={p.id} style={muted ? { opacity: 0.55 } : undefined}>
<td>
<input
type="checkbox"
checked={enabled}
disabled={pendingPattern === p.id}
onChange={e => onSetDisabled(p.id, !e.target.checked)}
style={{ cursor: 'pointer' }}
aria-label={`Enable ${p.id} pattern`}
/>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem', fontWeight: 600 }}>{p.id}</td>
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{p.description}</td>
<td>{actionBadge(p.action)}</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
{ACTIONS.map(a => (
<button
key={a}
className={`btn btn-sm ${p.action === a ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => onSetAction(p.id, a)}
disabled={pendingPattern === p.id || p.action === a || p.disabled}
style={{ fontSize: '0.6875rem', padding: '2px 8px' }}
>
{a}
</button>
))}
</div>
</td>
</tr>
)})}
</tbody>
</table>
</div>
</div>
{/* Detector models + instance-wide default policy (per-row toggle) */}
<DetectorModels pii={pii} addToast={addToast} onChanged={onChanged} />
{/* Per-model resolved state */}
<div className="card" style={{ padding: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>Per-model state</span>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
Edit the model YAML to change these.
Toggle PII inline; edit a row for detectors and policy.
</span>
</div>
<div className="table-container">
@@ -343,9 +230,9 @@ function FilteringTab({ status, pendingPattern, onSetAction, onSetDisabled, onPe
<tr>
<th>Model</th>
<th style={{ width: 120 }}>Backend</th>
<th style={{ width: 80 }}>PII</th>
<th style={{ width: 120 }}>PII</th>
<th style={{ width: 110 }}>Source</th>
<th>Pattern overrides</th>
<th>Detectors</th>
<th style={{ width: 80 }}>Edit</th>
</tr>
</thead>
@@ -354,13 +241,29 @@ function FilteringTab({ status, pendingPattern, onSetAction, onSetDisabled, onPe
<tr key={m.name}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>{m.name}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{m.backend || '—'}</td>
<td>{enabledBadge(m.enabled)}</td>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Toggle
checked={!!m.enabled}
disabled={piiBusy.has(m.name)}
onChange={(v) => togglePII(m.name, v)}
/>
{m.enabled && (!m.detectors || m.detectors.length === 0) && (
<span
title="Enabled but no detector resolved — nothing is scanned. Toggle a detector's Default on above, or add pii.detectors to the model."
style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'var(--color-warning)', whiteSpace: 'nowrap', cursor: 'help' }}
>
<i className="fas fa-triangle-exclamation" style={{ marginRight: 3 }} />no-op
</span>
)}
</span>
</td>
<td style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{m.explicit ? 'YAML' : (m.default_for_backend ? 'backend default' : 'default off')}
</td>
<td style={{ fontSize: '0.75rem', fontFamily: 'var(--font-mono)' }}>
{m.overrides && Object.keys(m.overrides).length > 0
? Object.entries(m.overrides).map(([k, v]) => `${k}=${v}`).join(', ')
{m.detectors && m.detectors.length > 0
? <>{m.detectors.join(', ')}{m.detectors_from_default && <span style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-sans)' }}> (default)</span>}</>
: <span style={{ color: 'var(--color-text-muted)' }}></span>}
</td>
<td>
@@ -391,6 +294,147 @@ function FilteringTab({ status, pendingPattern, onSetAction, onSetDisabled, onPe
)
}
// detectorTypeBadge labels a detector model by how it matches: a neural NER
// token-classifier vs an in-process restricted-regex pattern matcher. `unknown`
// is a default that names a model no longer loaded.
function detectorTypeBadge(type) {
const map = {
ner: { label: 'NER', color: 'var(--color-primary)' },
pattern: { label: 'pattern', color: 'var(--color-data-2, var(--color-warning))' },
unknown: { label: 'not loaded', color: 'var(--color-text-muted)' },
}
const t = map[type] || map.unknown
return (
<span style={{
display: 'inline-block',
padding: '2px 8px',
fontSize: '0.6875rem',
fontWeight: 600,
borderRadius: 'var(--radius-sm)',
background: t.color,
color: 'white',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
}}>
{t.label}
</span>
)
}
// DetectorModels lists the token_classify "filter" models (NER + in-process
// pattern matchers) and, via a per-row toggle, manages the instance-wide
// default detector set (RuntimeSettings.pii_default_detectors, saved via POST
// /api/settings). A detector toggled on is applied to any PII-enabled model
// that names none of its own — chiefly cloud-proxy / MITM models, which are
// PII-enabled by default but carry no detector list. Per-model `pii.detectors`
// always overrides. This replaces the old model-multiselect chooser: the table
// shows every available detector, so admins toggle defaults instead of retyping
// names, and link straight to each detector's config to edit its policy.
function DetectorModels({ pii, addToast, onChanged }) {
const navigate = useNavigate()
const location = useLocation()
const rows = useMemo(() => pii.detector_models || [], [pii.detector_models])
// Names currently in the default set; the toggle adds/removes against this.
const defaults = useMemo(() => pii.default_detectors || [], [pii.default_detectors])
// Track which rows are mid-save to disable just that toggle (optimistic).
const [busy, setBusy] = useState(() => new Set())
const toggleDefault = async (name, on) => {
const next = on
? [...new Set([...defaults, name])]
: defaults.filter(d => d !== name)
setBusy(prev => new Set(prev).add(name))
try {
const body = await settingsApi.save({ pii_default_detectors: next })
if (body && body.success === false) throw new Error(body.error || 'unknown error')
addToast?.(on ? `${name} added to default detectors` : `${name} removed from default detectors`, 'success')
onChanged?.()
} catch (err) {
addToast?.(`Failed to save: ${err.message}`, 'error')
} finally {
setBusy(prev => { const n = new Set(prev); n.delete(name); return n })
}
}
return (
<div className="card" style={{ padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)', gap: 'var(--spacing-sm)', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>Detector models</span>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate('/app/model-editor?template=secret-filter', { state: fromState(location, 'Middleware') })}
title="Add a NER or pattern detector model"
>
<i className="fas fa-plus" /> Add detector model
</button>
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-sm)' }}>
These token_classify models do the scanning. Toggle <strong>Default</strong> on to apply a
detector to any PII-enabled model that names none of its own (chiefly cloud-proxy / MITM models).
Per-model <code>pii.detectors</code> always overrides. Edit a detector to change which entities it
flags and what action it takes.
</div>
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Detector model</th>
<th style={{ width: 110 }}>Type</th>
<th style={{ width: 120 }}>Backend</th>
<th style={{ width: 110 }}>Default</th>
<th style={{ width: 80 }}>Edit</th>
</tr>
</thead>
<tbody>
{rows.map(d => (
<tr key={d.name}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem', fontWeight: 600 }}>
{d.missing
? <span title="This default detector names a model that is not loaded.">{d.name}</span>
: <Link to={`/app/model-editor/${encodeURIComponent(d.name)}`} state={fromState(location, 'Middleware')} title={`Edit ${d.name}.yaml`}>{d.name}</Link>}
</td>
<td>{detectorTypeBadge(d.type)}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{d.backend || '—'}</td>
<td>
<Toggle
checked={!!d.default}
disabled={busy.has(d.name)}
onChange={(v) => toggleDefault(d.name, v)}
/>
</td>
<td>
{d.missing ? (
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}></span>
) : (
<Link
to={`/app/model-editor/${encodeURIComponent(d.name)}`}
state={fromState(location, 'Middleware')}
className="btn btn-secondary btn-sm"
style={{ fontSize: '0.6875rem', padding: '2px 8px' }}
title={`Edit ${d.name}.yaml`}
>
<i className="fas fa-pen-to-square" /> Edit
</Link>
)}
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 'var(--spacing-md)' }}>
No detector models loaded. Add one with the button above (a token_classify NER model
or a built-in secret pattern model).
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
// decisionActiveSet rebuilds the Set of active labels from a
// DecisionRecord's comma-joined `label` column. Used by both the
// collapsed-row score suffix and the expanded-row bar rendering.
@@ -1011,7 +1055,7 @@ function EventsTab({ events }) {
<div className="empty-state-icon"><i className="fas fa-list-ul" /></div>
<h2 className="empty-state-title">No events</h2>
<p className="empty-state-text">
Events appear here when the PII filter matches a pattern, when the MITM proxy decides whether
Events appear here when a PII detector flags an entity, when the MITM proxy decides whether
to intercept a hostname, or when an intercepted request finishes. Request bodies are never
stored — use the API and backend traces for that.
</p>

View File

@@ -31,13 +31,23 @@ const SECTION_COLORS = {
mitm: 'var(--color-warning)', pii: 'var(--color-error)', other: 'var(--color-text-muted)',
}
function flattenConfig(obj, prefix = '') {
// flattenConfig turns a parsed YAML config into a flat { 'a.b.c': value }
// map keyed by the same dotted paths the field registry uses. leafPaths is
// the set of registered schema leaf paths: recursion STOPS at any of them so
// a map-typed field (e.g. pii_detection.entity_actions, a {GROUP: action}
// object) is stored whole at its own path. Without this guard a map's value
// was scattered into `pii_detection.entity_actions.SSN` etc. — paths that
// match no registered field — so the editor rendered neither the field nor
// its values, hiding per-entity policy like SSN→block from the operator.
function flattenConfig(obj, leafPaths, prefix = '') {
const result = {}
if (!obj || typeof obj !== 'object') return result
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
Object.assign(result, flattenConfig(val, path))
if (leafPaths && leafPaths.has(path)) {
result[path] = val
} else if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
Object.assign(result, flattenConfig(val, leafPaths, path))
} else {
result[path] = val
}
@@ -82,6 +92,16 @@ export default function ModelEditor() {
const { addToast } = useOutletContext()
const { sections, fields, loading: metaLoading, error: metaError } = useConfigMetadata()
// Registered schema leaf paths. flattenConfig stops recursing at these so
// map-typed fields (e.g. pii_detection.entity_actions) bind as a whole
// object to their registered editor instead of vanishing into sub-paths.
const leafPaths = useMemo(() => new Set(fields.map(f => f.path)), [fields])
// The parsed (not-yet-flattened) config loaded from the server. Flattening
// is deferred to a separate effect keyed on leafPaths so the schema metadata
// can arrive after the config without a fetch race re-clobbering values.
const [loadedConfig, setLoadedConfig] = useState(null)
const isCreateMode = !name
const [selectedTemplate, setSelectedTemplate] = useState(null)
@@ -123,7 +143,9 @@ export default function ModelEditor() {
}
}, [isCreateMode, searchParams, handleSelectTemplate])
// Load raw YAML config (edit mode only)
// Load raw YAML config (edit mode only). This only fetches + parses; the
// flatten-into-form-values step is the separate effect below so it can
// re-run when the schema metadata (leafPaths) resolves without re-fetching.
useEffect(() => {
if (!name) return
modelsApi.getEditConfig(name)
@@ -131,26 +153,29 @@ export default function ModelEditor() {
const raw = data?.config || ''
setYamlText(raw)
setSavedYamlText(raw)
// Parse YAML to get only the fields actually present in the file
try {
const parsed = YAML.parse(raw)
const flat = flattenConfig(parsed || {})
const active = new Set(Object.keys(flat))
setValues(flat)
setInitialValues(structuredClone(flat))
setActiveFieldPaths(active)
setLoadedConfig(YAML.parse(raw) || {})
} catch {
// If YAML parsing fails, start with empty state
setValues({})
setInitialValues({})
setActiveFieldPaths(new Set())
setLoadedConfig({})
}
})
.catch(err => addToast(`Failed to load config: ${err.message}`, 'error'))
.finally(() => setConfigLoading(false))
}, [name, addToast])
// Flatten the loaded config into form values. Keyed on leafPaths so a late
// schema-metadata resolution re-flattens (keeping map fields whole) WITHOUT
// re-fetching — avoiding a two-fetch race that could clobber values. Only
// fires on (re)load: loadedConfig changes per model, leafPaths is stable
// once metadata is in, so this never stomps in-progress edits.
useEffect(() => {
if (loadedConfig === null) return
const flat = flattenConfig(loadedConfig, leafPaths)
setValues(flat)
setInitialValues(structuredClone(flat))
setActiveFieldPaths(new Set(Object.keys(flat)))
}, [loadedConfig, leafPaths])
// Build field lookup
const fieldsByPath = useMemo(() => {
const map = {}
@@ -325,7 +350,7 @@ export default function ModelEditor() {
try {
const parsed = YAML.parse(yamlText)
parsedName = parsed?.name ?? null
const flat = flattenConfig(parsed || {})
const flat = flattenConfig(parsed || {}, leafPaths)
setValues(flat)
setInitialValues(structuredClone(flat))
setActiveFieldPaths(new Set(Object.keys(flat)))

View File

@@ -36,6 +36,7 @@ const FILTERS = [
{ key: 'rerank', labelKey: 'filters.rerank', icon: 'fa-sort' },
{ key: 'detection', labelKey: 'filters.detection', icon: 'fa-bullseye' },
{ key: 'vad', labelKey: 'filters.vad', icon: 'fa-wave-square' },
{ key: 'token_classify', labelKey: 'filters.ner', icon: 'fa-tags' },
]
export default function Models() {

View File

@@ -75,6 +75,8 @@ const TYPE_COLORS = {
detection: { bg: 'var(--color-info-light)', color: 'var(--color-data-8)' },
model_load: { bg: 'var(--color-error-light)', color: 'var(--color-data-2)' },
vector_store: { bg: 'var(--color-accent-light)', color: 'var(--color-data-7)' },
token_classify: { bg: 'var(--color-info-light)', color: 'var(--color-data-3)' },
pattern_pii: { bg: 'var(--color-error-light)', color: 'var(--color-data-2)' },
}
function typeBadgeStyle(type) {

View File

@@ -22,3 +22,4 @@ export const CAP_SPEAKER_RECOGNITION = 'FLAG_SPEAKER_RECOGNITION'
export const CAP_AUDIO_TRANSFORM = 'FLAG_AUDIO_TRANSFORM'
export const CAP_REALTIME_AUDIO = 'FLAG_REALTIME_AUDIO'
export const CAP_SCORE = 'FLAG_SCORE'
export const CAP_TOKEN_CLASSIFY = 'FLAG_TOKEN_CLASSIFY'

View File

@@ -146,22 +146,38 @@ const MODEL_TEMPLATES = [
id: 'mitm',
label: 'MITM Intercept',
icon: 'fa-shield-halved',
description: 'Bind a hostname to this config for the cloudproxy MITM listener. PII filtering and pattern overrides flow from this config when the host is intercepted.',
description: 'Bind a hostname to this config for the cloudproxy MITM listener. PII filtering (the NER detectors listed here) is applied to intercepted request bodies for the host.',
// The mitm- name prefix is a convention, not a contract — the
// dispatcher looks up by host, not name. Prefixing keeps the
// config out of the way of callable model names so a chat client
// accidentally requesting "anthropic" doesn't hit a backendless
// intercept config.
//
// pii.patterns is pre-seeded with an empty list so the override
// editor is visible by default — admins typically want to tighten
// a couple of pattern actions when intercepting a cloud provider.
// An empty list serializes out and the redactor ignores it.
// pii.detectors is pre-seeded empty so the detector picker is visible
// by default — admins point it at a token_classify model whose
// pii_detection block defines the policy.
fields: {
'name': 'mitm-anthropic',
'mitm.hosts': ['api.anthropic.com'],
'pii.enabled': true,
'pii.patterns': [],
'pii.detectors': [],
},
},
{
id: 'secret-filter',
label: 'Secret Pattern Detector',
icon: 'fa-key',
description: 'An in-process token_classify detector that flags high-entropy secrets (API keys, tokens) with bounded restricted-regex patterns — no backend, no GGUF, zero VRAM. Enable the built-in provider patterns below and/or add your own under PII Detection. Reference it from a model\'s pii.detectors, or toggle it on as a default detector on the Middleware page.',
fields: {
'name': 'secret-filter',
'backend': 'pattern',
'known_usecases': ['token_classify'],
'pii_detection.default_action': 'block',
'pii_detection.builtins': [
'anthropic_api_key', 'openai_api_key', 'github_token', 'github_pat',
'aws_access_key', 'google_api_key', 'slack_token', 'stripe_key',
'jwt', 'private_key_block',
],
},
},
]

View File

@@ -22,8 +22,8 @@ import (
func RegisterAnthropicRoutes(app *echo.Echo,
re *middleware.RequestExtractor,
application *application.Application) {
application *application.Application,
) {
// Anthropic Messages API endpoint
var natsClient mcpTools.MCPNATSClient
if d := application.Distributed(); d != nil {
@@ -36,8 +36,6 @@ func RegisterAnthropicRoutes(app *echo.Echo,
application.TemplatesEvaluator(),
application.ApplicationConfig(),
natsClient,
application.PIIRedactor(),
application.PIIEvents(),
)
messagesMiddleware := []echo.MiddlewareFunc{
@@ -69,7 +67,7 @@ func RegisterAnthropicRoutes(app *echo.Echo,
},
),
middleware.AdmissionControl(application.AdmissionLimiter(), application.PIIEvents()),
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.Anthropic(), application.FallbackUser()),
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.Anthropic(), application.FallbackUser(), pii.WithNERResolver(application.PIINERResolver()), pii.WithPolicyResolver(application.PIIPolicyResolver())),
}
// Main Anthropic endpoint

View File

@@ -299,53 +299,85 @@ func buildAdmissionStatus(app *application.Application) map[string]any {
}
// buildPIIStatus builds the pii section of /api/middleware/status. It
// reads the live redactor, walks every model config, and reports the
// resolved enabled state plus any per-pattern overrides — that's what
// the admin page renders side-by-side so the operator can see at a
// glance which models are protected.
//
// Returns a sentinel "disabled" payload when the redactor is nil
// (--disable-pii), letting the page show "filter switched off" rather
// than a confusing empty state.
// walks every model config and reports the resolved enabled state plus
// the NER detector models each one references — that's what the admin
// page renders so the operator can see at a glance which models are
// protected and by which detectors. The detection policy itself
// (entity→action, min score) lives on each detector model's
// pii_detection block.
func buildPIIStatus(app *application.Application) map[string]any {
redactor := app.PIIRedactor()
if redactor == nil {
return map[string]any{
"enabled_globally": false,
"reason": "--disable-pii",
"patterns": []any{},
"models": []any{},
}
}
patterns := redactor.Patterns()
patternList := make([]map[string]any, 0, len(patterns))
for _, p := range patterns {
patternList = append(patternList, map[string]any{
"id": p.ID,
"description": p.Description,
"action": string(p.Action),
"disabled": p.Disabled,
"max_match_length": p.MaxMatchLength,
})
}
appCfg := app.ApplicationConfig()
models := []map[string]any{}
for _, cfg := range app.ModelConfigLoader().GetAllModelsConfigs() {
// Only list models PII filtering can actually apply to (reachable
// through a text-accepting endpoint with a PII adapter wired).
// Skips VAD/STT/embedding/image-only models and the token_classify
// detector models themselves, which are the filters, not consumers.
if !cfg.PIIFilterApplies() {
continue
}
explicit := cfg.PII.Enabled != nil
ownDetectors := cfg.PIIDetectors()
// Resolve through the shared policy so the table reflects the EFFECTIVE
// state, including the instance-wide default detector — what the
// request path actually does.
enabled, detectors := app.ResolvePIIPolicy(&cfg)
entry := map[string]any{
"name": cfg.Name,
"backend": cfg.Backend,
"enabled": cfg.PIIIsEnabled(),
"overrides": cfg.PIIPatternOverrides(),
"enabled": enabled,
"detectors": detectors,
"explicit": explicit,
// Why is this on? backend default (cloud-proxy) vs an explicit YAML
// toggle. Helps admins understand the resolved state without
// reading source.
"default_for_backend": !explicit && cfg.Backend == "cloud-proxy",
// The detectors came from the global default, not this model's YAML.
"detectors_from_default": enabled && len(ownDetectors) == 0 && len(detectors) > 0,
}
// explicit-set tells the UI whether the resolved state came
// from the YAML or the backend-prefix default. Helps admins
// understand "why is this on?" without reading source.
entry["explicit"] = cfg.PII.Enabled != nil
entry["default_for_backend"] = cfg.Backend == "cloud-proxy"
models = append(models, entry)
}
// Detector models: the token_classify "filter" models themselves (NER and
// in-process pattern matchers), which PIIFilterApplies deliberately omits
// from the consumer list above. The Filtering tab renders these as a table
// with a per-row toggle marking membership in the instance-wide default
// detector set, so admins manage defaults without retyping model names.
defaultSet := map[string]bool{}
for _, d := range appCfg.PIIDefaultDetectors {
defaultSet[d] = true
}
detectorModels := []map[string]any{}
for _, cfg := range app.ModelConfigLoader().GetAllModelsConfigs() {
if !cfg.HasUsecases(config.FLAG_TOKEN_CLASSIFY) {
continue
}
typ := "ner"
if cfg.IsPatternDetector() {
typ = "pattern"
}
detectorModels = append(detectorModels, map[string]any{
"name": cfg.Name,
"backend": cfg.Backend,
"type": typ,
// Whether this detector is in the instance-wide default set.
"default": defaultSet[cfg.Name],
})
delete(defaultSet, cfg.Name)
}
// Surface any default detector that names a model that is no longer loaded
// (or lost the token_classify usecase) so the admin can still toggle it off.
for name := range defaultSet {
detectorModels = append(detectorModels, map[string]any{
"name": name,
"backend": "",
"type": "unknown",
"default": true,
"missing": true,
})
}
recentCount := 0
if app.PIIEvents() != nil {
if n, err := app.PIIEvents().Count(context.Background()); err == nil {
@@ -356,8 +388,10 @@ func buildPIIStatus(app *application.Application) map[string]any {
return map[string]any{
"enabled_globally": true,
"default_enabled_for_backends": []string{"cloud-proxy"},
"patterns": patternList,
"models": models,
"detector_models": detectorModels,
"recent_event_count": recentCount,
// Instance-wide default policy (the Default PII policy editor).
"default_detectors": appCfg.PIIDefaultDetectors,
}
}

View File

@@ -10,13 +10,15 @@ import (
"github.com/mudler/LocalAI/core/http/endpoints/ollama"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/services/routing/piiadapter"
"github.com/mudler/LocalAI/pkg/distributedhdr"
)
func RegisterOllamaRoutes(app *echo.Echo,
re *middleware.RequestExtractor,
application *application.Application) {
application *application.Application,
) {
traceMiddleware := middleware.TraceMiddleware(application)
usageMiddleware := middleware.UsageMiddleware(application.StatsRecorder(), application.FallbackUser())
nodeHeaderMiddleware := middleware.ExposeNodeHeader(application.ApplicationConfig())
@@ -35,6 +37,7 @@ func RegisterOllamaRoutes(app *echo.Echo,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OllamaChatRequest) }),
setOllamaChatRequestContext(application.ApplicationConfig()),
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.OllamaChat(), application.FallbackUser(), pii.WithNERResolver(application.PIINERResolver()), pii.WithPolicyResolver(application.PIIPolicyResolver())),
}
app.POST("/api/chat", chatHandler, chatMiddleware...)
@@ -52,6 +55,7 @@ func RegisterOllamaRoutes(app *echo.Echo,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OllamaGenerateRequest) }),
setOllamaGenerateRequestContext(application.ApplicationConfig()),
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.OllamaGenerate(), application.FallbackUser(), pii.WithNERResolver(application.PIINERResolver()), pii.WithPolicyResolver(application.PIIPolicyResolver())),
}
app.POST("/api/generate", generateHandler, generateMiddleware...)
@@ -67,6 +71,7 @@ func RegisterOllamaRoutes(app *echo.Echo,
traceMiddleware,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EMBEDDINGS)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OllamaEmbedRequest) }),
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.OllamaEmbed(), application.FallbackUser(), pii.WithNERResolver(application.PIINERResolver()), pii.WithPolicyResolver(application.PIIPolicyResolver())),
}
app.POST("/api/embed", embedHandler, embedMiddleware...)
app.POST("/api/embeddings", embedHandler, embedMiddleware...)

View File

@@ -16,7 +16,8 @@ import (
func RegisterOpenAIRoutes(app *echo.Echo,
re *middleware.RequestExtractor,
application *application.Application) {
application *application.Application,
) {
// openAI compatible API endpoint
traceMiddleware := middleware.TraceMiddleware(application)
usageMiddleware := middleware.UsageMiddleware(application.StatsRecorder(), application.FallbackUser())
@@ -42,7 +43,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
}
// chat
chatHandler := openai.ChatEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig(), natsClient, application.LocalAIAssistant(), application.PIIRedactor(), application.PIIEvents())
chatHandler := openai.ChatEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig(), natsClient, application.LocalAIAssistant())
chatMiddleware := []echo.MiddlewareFunc{
nodeHeaderMiddleware,
usageMiddleware,
@@ -91,7 +92,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
// configs honour the routed target (e.g., a router fans out to
// claude-strict; that model's pii block applies, not the
// router model's).
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.OpenAI(), application.FallbackUser()),
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.OpenAI(), application.FallbackUser(), pii.WithNERResolver(application.PIINERResolver()), pii.WithPolicyResolver(application.PIIPolicyResolver())),
}
app.POST("/v1/chat/completions", chatHandler, chatMiddleware...)
app.POST("/chat/completions", chatHandler, chatMiddleware...)
@@ -112,12 +113,13 @@ func RegisterOpenAIRoutes(app *echo.Echo,
return next(c)
}
},
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.OpenAICompletion(), application.FallbackUser(), pii.WithNERResolver(application.PIINERResolver()), pii.WithPolicyResolver(application.PIIPolicyResolver())),
}
app.POST("/v1/edits", editHandler, editMiddleware...)
app.POST("/edits", editHandler, editMiddleware...)
// completion
completionHandler := openai.CompletionEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig(), application.PIIRedactor(), application.PIIEvents())
completionHandler := openai.CompletionEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
completionMiddleware := []echo.MiddlewareFunc{
nodeHeaderMiddleware,
usageMiddleware,
@@ -133,6 +135,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
return next(c)
}
},
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.OpenAICompletion(), application.FallbackUser(), pii.WithNERResolver(application.PIINERResolver()), pii.WithPolicyResolver(application.PIIPolicyResolver())),
}
app.POST("/v1/completions", completionHandler, completionMiddleware...)
app.POST("/completions", completionHandler, completionMiddleware...)
@@ -155,6 +158,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
return next(c)
}
},
pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.OpenAICompletion(), application.FallbackUser(), pii.WithNERResolver(application.PIINERResolver()), pii.WithPolicyResolver(application.PIIPolicyResolver())),
}
app.POST("/v1/embeddings", embeddingHandler, embeddingMiddleware...)
app.POST("/embeddings", embeddingHandler, embeddingMiddleware...)

View File

@@ -6,58 +6,30 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/services/routing/pii"
)
// RegisterPIIRoutes wires the read-only routing-PII endpoints. They
// surface (a) the active pattern set so admins can verify what is
// being filtered, (b) the recent PIIEvent log so they can audit what
// has been redacted, and (c) a dry-run "test" endpoint so an admin
// can paste candidate text and see what the redactor would do without
// sending a real request.
// RegisterPIIRoutes wires the read-only PII audit endpoint. The
// detection itself runs request-side from the chat middleware
// (routes/openai.go) and the MITM input path, driven by per-model NER
// detectors; this endpoint is observation-side only.
//
// The redactor itself runs from the chat middleware in routes/openai.go;
// these endpoints are observation- and configuration-side only.
// The legacy regex tier (pattern catalogue + per-pattern action editor
// + dry-run/decide oracles) was removed — policy now lives on each
// detector model's pii_detection block, so there is nothing global to
// list or mutate here.
func RegisterPIIRoutes(e *echo.Echo, app *application.Application) {
if app.PIIRedactor() == nil {
stub := func(c echo.Context) error {
if app.PIIEvents() == nil {
e.GET("/api/pii/events", func(c echo.Context) error {
return c.JSON(http.StatusServiceUnavailable, map[string]string{
"error": "PII filter is disabled (--disable-pii)",
"error": "PII subsystem unavailable",
})
}
e.GET("/api/pii/patterns", stub)
e.GET("/api/pii/events", stub)
e.POST("/api/pii/test", stub)
e.POST("/api/pii/decide", stub)
e.POST("/api/pii/patterns/persist", stub)
})
return
}
// GetPIIPatternsEndpoint godoc
// @Summary List the active PII patterns
// @Description Returns the configured pattern set with their actions. Available without auth.
// @Tags pii
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/pii/patterns [get]
e.GET("/api/pii/patterns", func(c echo.Context) error {
patterns := app.PIIRedactor().Patterns()
out := make([]map[string]any, 0, len(patterns))
for _, p := range patterns {
out = append(out, map[string]any{
"id": p.ID,
"description": p.Description,
"action": string(p.Action),
"disabled": p.Disabled,
"max_match_length": p.MaxMatchLength,
})
}
return c.JSON(http.StatusOK, map[string]any{"patterns": out})
})
// GetPIIEventsEndpoint godoc
// @Summary List recent middleware events
// @Description The event log is shared between the PII filter and the MITM proxy: PII redactions, proxy_connect (intercept decisions), and proxy_traffic (per-request byte counts) all flow through the same store. Filter by kind to narrow the view. Admin-only when auth is on; available to the local user in single-user mode.
@@ -65,8 +37,9 @@ func RegisterPIIRoutes(e *echo.Echo, app *application.Application) {
// @Produce json
// @Param correlation_id query string false "Correlation ID join key"
// @Param user_id query string false "User id"
// @Param pattern_id query string false "Pattern id (e.g. email, ssn)"
// @Param pattern_id query string false "Detector group id (e.g. ner:EMAIL, pattern:ANTHROPIC_KEY)"
// @Param kind query string false "Event kind: pii | proxy_connect | proxy_traffic"
// @Param origin query string false "Redaction origin: middleware | proxy | pii_analyze | pii_redact"
// @Param limit query int false "Max events" default(100)
// @Success 200 {object} map[string]interface{}
// @Router /api/pii/events [get]
@@ -91,6 +64,7 @@ func RegisterPIIRoutes(e *echo.Echo, app *application.Application) {
UserID: c.QueryParam("user_id"),
PatternID: c.QueryParam("pattern_id"),
Kind: pii.EventKind(c.QueryParam("kind")),
Origin: c.QueryParam("origin"),
Limit: limit,
})
if err != nil {
@@ -99,162 +73,11 @@ func RegisterPIIRoutes(e *echo.Echo, app *application.Application) {
return c.JSON(http.StatusOK, map[string]any{"events": events})
})
// PostPIITestEndpoint godoc
// @Summary Dry-run the PII redactor against text
// @Description Useful for admins tuning patterns. Returns the redacted text, matched spans, and whether the input would have been blocked.
// @Tags pii
// @Accept json
// @Produce json
// @Param body body map[string]string true "JSON {\"text\":\"...\"}"
// @Success 200 {object} map[string]interface{}
// @Router /api/pii/test [post]
e.POST("/api/pii/test", func(c echo.Context) error {
var body struct {
Text string `json:"text"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
}
res := app.PIIRedactor().Redact(body.Text)
return c.JSON(http.StatusOK, map[string]any{
"redacted": res.Redacted,
"spans": res.Spans,
"blocked": res.Blocked,
"masked": res.Masked,
})
})
// POST /api/pii/decide — programmatic PII decision oracle for
// external routers. Returns findings + suggested action without
// mutating the caller's request or recording an audit event.
// Production hot path — admin-only, matching /api/pii/events.
decideHandler := localai.PIIDecideEndpoint(app.PIIRedactor())
e.POST("/api/pii/decide", func(c echo.Context) error {
viewer := resolveUsageUser(c, app)
if viewer == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
if viewer.Role != auth.RoleAdmin {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
return decideHandler(c)
})
// PutPIIPatternActionEndpoint godoc
// @Summary Change a pattern's action in-process
// @Description Mutates the named pattern's action (mask|block|allow). Transient — restored to YAML defaults on restart. Admin-only.
// @Tags pii
// @Accept json
// @Produce json
// @Param id path string true "Pattern id"
// @Param body body map[string]string true "JSON {\"action\":\"mask|block|allow\"}"
// @Success 200 {object} map[string]interface{}
// @Router /api/pii/patterns/{id} [put]
e.PUT("/api/pii/patterns/:id", func(c echo.Context) error {
viewer := resolveUsageUser(c, app)
if viewer == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
if viewer.Role != auth.RoleAdmin {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
id := c.Param("id")
if id == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "pattern id is required"})
}
// Either field is optional. The body must set at least one;
// otherwise the call is a no-op and the client probably means
// to PUT something.
var body struct {
Action *string `json:"action,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
}
if body.Action == nil && body.Disabled == nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "must specify action and/or disabled"})
}
if body.Action != nil {
if err := app.PIIRedactor().SetAction(id, pii.Action(*body.Action)); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
}
if body.Disabled != nil {
if err := app.PIIRedactor().SetDisabled(id, *body.Disabled); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
}
return c.JSON(http.StatusOK, map[string]any{
"id": id,
"action": body.Action,
"disabled": body.Disabled,
"persisted": false,
})
})
// PostPIIPatternsPersistEndpoint godoc
// @Summary Persist current pattern overrides to disk
// @Description Snapshots the live redactor's per-pattern (action, disabled) state into runtime_settings.json so the next process start re-applies it. Admin-only. Pairs with PUT /api/pii/patterns/:id which only mutates in-process.
// @Tags pii
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/pii/patterns/persist [post]
e.POST("/api/pii/patterns/persist", func(c echo.Context) error {
viewer := resolveUsageUser(c, app)
if viewer == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
if viewer.Role != auth.RoleAdmin {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
appCfg := app.ApplicationConfig()
existing, err := appCfg.ReadPersistedSettings()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "read settings: " + err.Error()})
}
// Only persist patterns whose live state differs from the YAML
// default — that way an operator can compare runtime_settings.json
// at a glance and see only the deltas they applied.
defaults, dErr := pii.LoadConfig(appCfg.PIIConfigPath)
if dErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "reload defaults: " + dErr.Error()})
}
defaultByID := make(map[string]pii.Pattern, len(defaults))
for _, d := range defaults {
defaultByID[d.ID] = d
}
overrides := map[string]config.PIIPatternRuntimeOverride{}
for _, p := range app.PIIRedactor().Patterns() {
d, ok := defaultByID[p.ID]
ov := config.PIIPatternRuntimeOverride{}
changed := false
if !ok || p.Action != d.Action {
action := string(p.Action)
ov.Action = &action
changed = true
}
if !ok || p.Disabled != d.Disabled {
disabled := p.Disabled
ov.Disabled = &disabled
changed = true
}
if changed {
overrides[p.ID] = ov
}
}
existing.PIIPatternOverrides = &overrides
if err := appCfg.WritePersistedSettings(existing); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "write settings: " + err.Error()})
}
// Mirror onto the live ApplicationConfig so a subsequent reload
// without a process restart sees the same map.
appCfg.PIIPatternOverrides = overrides
return c.JSON(http.StatusOK, map[string]any{
"persisted": true,
"override_count": len(overrides),
})
})
// Synchronous redaction service: scan a string and either report the
// detected entities (analyze) or apply the policy (redact). Unlike the
// admin-only events log above, these are an inference-tier service gated
// by the pii_filter feature (any authenticated user), so a client can use
// LocalAI's PII engine without routing a full chat request through it.
e.POST("/api/pii/analyze", localai.PIIAnalyzeEndpoint(app))
e.POST("/api/pii/redact", localai.PIIRedactEndpoint(app))
}

View File

@@ -56,6 +56,7 @@ var usecaseFilters = map[string]config.ModelConfigUsecase{
config.UsecaseAudioTransform: config.FLAG_AUDIO_TRANSFORM,
config.UsecaseDiarization: config.FLAG_DIARIZATION,
config.UsecaseRealtimeAudio: config.FLAG_REALTIME_AUDIO,
config.UsecaseTokenClassify: config.FLAG_TOKEN_CLASSIFY,
}
// extractHFRepo tries to find a HuggingFace repo ID from model overrides or URLs.