mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-18 21:58:58 -04:00
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:
committed by
GitHub
parent
c133ca39dc
commit
3fa7b2955c
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
248
core/http/endpoints/localai/pii.go
Normal file
248
core/http/endpoints/localai/pii.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
258
core/http/endpoints/localai/pii_test.go
Normal file
258
core/http/endpoints/localai/pii_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"rerank": "Rerank",
|
||||
"detection": "Detection",
|
||||
"vad": "VAD",
|
||||
"ner": "NER",
|
||||
"fitsGpu": "Fits in GPU",
|
||||
"allBackends": "All Backends",
|
||||
"searchBackends": "Search backends..."
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
98
core/http/react-ui/src/components/EntityActionListEditor.jsx
Normal file
98
core/http/react-ui/src/components/EntityActionListEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
core/http/react-ui/src/components/ModelMultiSelect.jsx
Normal file
62
core/http/react-ui/src/components/ModelMultiSelect.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
96
core/http/react-ui/src/components/PatternListEditor.jsx
Normal file
96
core/http/react-ui/src/components/PatternListEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: […] {'}'}</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>
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
1
core/http/react-ui/src/utils/capabilities.js
vendored
1
core/http/react-ui/src/utils/capabilities.js
vendored
@@ -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'
|
||||
|
||||
28
core/http/react-ui/src/utils/modelTemplates.js
vendored
28
core/http/react-ui/src/utils/modelTemplates.js
vendored
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user