mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-11 00:38:53 -04:00
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>
133 lines
4.2 KiB
Go
133 lines
4.2 KiB
Go
// Package piiadapter holds the per-API-shape adapters that translate
|
|
// between the routing/pii middleware and concrete request types from
|
|
// core/schema. Lives outside core/services/routing/pii so the schema
|
|
// package never imports pii (and pii never imports schema), keeping
|
|
// the dependency direction clean.
|
|
package piiadapter
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
"github.com/mudler/LocalAI/core/services/routing/pii"
|
|
)
|
|
|
|
// OpenAI returns a pii.Adapter for *schema.OpenAIRequest. It scans
|
|
// every chat message's text content (string-form or text blocks of
|
|
// the structured `[]any` content), and writes redacted text back.
|
|
//
|
|
// Multimodal content (image_url, audio_url, video_url) is left alone
|
|
// — PII in image bytes is the encoder NER tier's problem, not the
|
|
// regex tier's. We do walk text fields embedded inside content
|
|
// blocks because those are the most common shape Claude Code and
|
|
// similar clients produce.
|
|
//
|
|
// System / developer / tool messages are scanned as well: an API key
|
|
// pasted into a system prompt is just as leak-prone as one in a user
|
|
// message.
|
|
func OpenAI() pii.Adapter {
|
|
return pii.Adapter{
|
|
Scan: func(parsed any) []pii.ScannedText {
|
|
req, ok := parsed.(*schema.OpenAIRequest)
|
|
if !ok || req == nil {
|
|
return nil
|
|
}
|
|
var out []pii.ScannedText
|
|
for i := range req.Messages {
|
|
msg := &req.Messages[i]
|
|
switch ct := msg.Content.(type) {
|
|
case string:
|
|
if ct != "" {
|
|
// Index encodes (message index, -1) to mean
|
|
// "the whole Content string". Negative
|
|
// inner indices are a valid sentinel because
|
|
// real array indices are ≥ 0.
|
|
out = append(out, pii.ScannedText{
|
|
Index: encodeIdx(i, -1),
|
|
Text: ct,
|
|
})
|
|
}
|
|
case []any:
|
|
for j, block := range ct {
|
|
if blockMap, ok := block.(map[string]any); ok {
|
|
if blockMap["type"] == "text" {
|
|
if text, ok := blockMap["text"].(string); ok && text != "" {
|
|
out = append(out, pii.ScannedText{
|
|
Index: encodeIdx(i, j),
|
|
Text: text,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
},
|
|
Apply: func(parsed any, updates []pii.ScannedText) {
|
|
req, ok := parsed.(*schema.OpenAIRequest)
|
|
if !ok || req == nil {
|
|
return
|
|
}
|
|
for _, u := range updates {
|
|
msgIdx, blockIdx := decodeIdx(u.Index)
|
|
if msgIdx < 0 || msgIdx >= len(req.Messages) {
|
|
continue
|
|
}
|
|
msg := &req.Messages[msgIdx]
|
|
if blockIdx < 0 {
|
|
// Whole-string content. Write BOTH the serializable
|
|
// Content and the StringContent staging buffer: the
|
|
// rendered-template path (evaluator.TemplateMessages,
|
|
// taken whenever use_tokenizer_template is off — e.g.
|
|
// cloud-proxy translate and Go-templated chat models)
|
|
// reads StringContent, not Content. Masking only Content
|
|
// would leave the original in StringContent and leak it
|
|
// to the backend/upstream.
|
|
msg.Content = u.Text
|
|
msg.StringContent = u.Text
|
|
continue
|
|
}
|
|
blocks, ok := msg.Content.([]any)
|
|
if !ok || blockIdx >= len(blocks) {
|
|
continue
|
|
}
|
|
blockMap, ok := blocks[blockIdx].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Keep the StringContent projection in sync. For multimodal
|
|
// messages StringContent is the text blocks flattened with
|
|
// media markers injected (see middleware/request.go), so we
|
|
// can't just overwrite it — replace this block's original text
|
|
// run in place, preserving the markers around it.
|
|
if orig, ok := blockMap["text"].(string); ok && orig != "" && msg.StringContent != "" {
|
|
msg.StringContent = strings.Replace(msg.StringContent, orig, u.Text, 1)
|
|
}
|
|
blockMap["text"] = u.Text
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// encodeIdx packs (msg, block) into one int. block=-1 means
|
|
// "the whole Content string"; bit 24 is the sentinel flag and
|
|
// bits 0..23 hold the block index, leaving the rest for msg.
|
|
const idxWholeStringFlag = 1 << 24
|
|
const idxBlockMask = (1 << 24) - 1
|
|
|
|
func encodeIdx(msg, block int) int {
|
|
if block < 0 {
|
|
return (msg << 25) | idxWholeStringFlag
|
|
}
|
|
return (msg << 25) | (block & idxBlockMask)
|
|
}
|
|
|
|
func decodeIdx(packed int) (msg, block int) {
|
|
msg = packed >> 25
|
|
if packed&idxWholeStringFlag != 0 {
|
|
return msg, -1
|
|
}
|
|
return msg, packed & idxBlockMask
|
|
}
|