mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-14 19:58:44 -04:00
* fix(router): score classifier production-readiness Conversation trimming runs through the classifier model's chat template and trims by exact token count, sized to the model's n_batch which is now scaled to context so long probes can't crash the backend. Missing chat_message templates are a hard error at router build time. Router- facing factories (Embedder/Scorer/Reranker/TokenCounter) re-resolve ModelConfig per call so a model installed post-startup doesn't bind a stub Backend="" config and silently fall into the loader's auto- iterate path. New 'vector_store' backend trace recorded inside localVectorStore on every Search/Insert — including the backend-load-failure path that previously vanished into an xlog.Warn — with outcome tagging (hit/miss/empty_store/backend_load_error/find_error/insert_error/ok). Companion cleanup drops misleading similarity:0 and input_tokens_count:0 from non-hit and text-mode traces. Gallery local-store-development aliases to 'local-store' so the master image satisfies pkg/model.LocalStoreBackend lookups from the embedding cache. Misc: llama-cpp TokenizeString reads the correct 'prompt' JSON key (the original bug); ModelTokenize nil-guard; non-fatal mitm proxy startup; PII 'route_local' renamed to 'allow' with docs/UI in sync; model-editor footer no longer eats the edit area on small screens; several config-editor template/dropdown/section fixes. Tests: e2e router specs (casual/code-hint + long-conversation trim), vector_store trace specs, lazy-factory specs, gallery dev-alias resolution, Playwright trace badge + scroll regression. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(backend): auto-size batch to context for embedding and rerank models Embedding and rerank models pool over the whole input in a single physical batch (n_ubatch). With batch left at the 512 default, the backend rejects longer inputs with "input is too large to process", silently capping a large-context embedder (e.g. 8k/32k) at 512 tokens. Size n_batch to the context for these single-pass usecases, mirroring the existing FLAG_SCORE behaviour; an explicit batch: still wins. Extracts EffectiveContextSize/EffectiveBatchSize from grpcModelOpts so the effective decode window has one home for other callers to reuse. Adds an e2e-aio regression test that embeds a >512-token input. The AIO embedding model is switched to nomic-embed-text-v1.5 (2048 context) because the previous granite model was capped at 512 tokens and could not exercise the larger batch. Assisted-by: claude-code:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(gallery): raise arch-router scoring output cap via parallel:64 Scoring decodes the whole prompt+candidate in a single llama_decode and reads one logit row per candidate token. The vendored llama.cpp server caps causal output rows at n_parallel, so the default of 1 aborts with GGML_ASSERT(n_outputs_max <= cparams.n_outputs_max) on multi-token route labels. Set options: [parallel:64] on both arch-router quant entries to lift the cap; kv_unified (the grpc-server default) keeps the full context per sequence, so this does not split the KV cache. Assisted-by: claude-code:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
251 lines
8.5 KiB
Go
251 lines
8.5 KiB
Go
package pii
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
"github.com/mudler/LocalAI/core/services/routing/contract"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// Echo context keys this middleware reads from / writes to. The string
|
|
// values must match the constants in core/http/middleware/context_keys.go;
|
|
// kept in sync by hand because echoing constants across packages would
|
|
// drag the http/middleware package into pii's import graph and create
|
|
// a cycle (http/middleware will import this one).
|
|
const (
|
|
ctxKeyCorrelationID = "routing.correlation_id"
|
|
ctxKeyPIIEventID = "routing.pii_event_id"
|
|
// Must match the constants in core/http/middleware/request.go.
|
|
// Echoing them across packages would create an import cycle
|
|
// (http/middleware imports this package). Drift is caught by
|
|
// integration tests against the chat route.
|
|
ctxKeyParsedRequest = "LOCALAI_REQUEST"
|
|
ctxKeyModelConfig = "MODEL_CONFIG"
|
|
)
|
|
|
|
// ModelPIIConfig is the duck-typed view this middleware needs of the
|
|
// per-model PII configuration carried on the echo context. *config.ModelConfig
|
|
// satisfies it via PIIIsEnabled / PIIPatternOverrides; the indirection
|
|
// keeps the pii package from importing core/config.
|
|
//
|
|
// Consumers of the override map: the action returned from PIIPatternOverrides
|
|
// is the raw YAML string (e.g. "block"). Validation against the canonical
|
|
// ActionMask/Block/Allow constants happens here, so a typo in a model
|
|
// YAML logs and is ignored rather than panicking.
|
|
type ModelPIIConfig interface {
|
|
PIIIsEnabled() bool
|
|
PIIPatternOverrides() map[string]string
|
|
}
|
|
|
|
// ScannedText is one piece of user text from the request. Index is
|
|
// opaque to the middleware — the Adapter implementation uses it to
|
|
// put the redacted version back in the right place.
|
|
type ScannedText struct {
|
|
Index int
|
|
Text string
|
|
}
|
|
|
|
// Adapter pulls scannable text out of a parsed request and writes
|
|
// redacted text back. Provided as a per-API-shape function rather
|
|
// than an interface on the request type so the schema package does
|
|
// not have to depend on pii. Each route registration passes the
|
|
// adapter that knows its request format.
|
|
//
|
|
// The middleware calls Scan once per request and Apply once with
|
|
// every span the redactor returned. updates are guaranteed to share
|
|
// indices the adapter previously returned from Scan; the adapter
|
|
// must not assume input order matches scan order.
|
|
type Adapter struct {
|
|
Scan func(parsed any) []ScannedText
|
|
Apply func(parsed any, updates []ScannedText)
|
|
}
|
|
|
|
// RequestMiddleware applies the regex PII tier to incoming chat
|
|
// requests. If the parsed request is not a MessageScanner (e.g.,
|
|
// non-chat endpoints registered against the same group later), the
|
|
// middleware passes through.
|
|
//
|
|
// - On match with action=block: the request is rejected with 400 and
|
|
// a PIIEvent is recorded. The matched value is never echoed back
|
|
// to the client.
|
|
// - On match with action=mask: the redacted text replaces the
|
|
// original on the parsed request. PIIEvents are recorded.
|
|
// - On match with action=allow: the original text is left intact; a
|
|
// PIIEvent is still recorded so the detection is auditable.
|
|
//
|
|
// recorder is the Recorder on which to record events; nil disables
|
|
// recording (the redaction still happens). fallbackUser supplies the
|
|
// no-auth identity. The middleware writes ctxKeyPIIEventID on the echo
|
|
// context so the usage middleware can later cross-reference the event
|
|
// with the UsageRecord.
|
|
func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fallbackUser *auth.User) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if redactor == nil || len(redactor.Patterns()) == 0 || adapter.Scan == nil {
|
|
return next(c)
|
|
}
|
|
|
|
// Per-model gating: redaction is opt-in per model. If the
|
|
// resolved config disables PII for this model (the default
|
|
// for non-proxy backends), pass through immediately. We do
|
|
// this before parsing the request so a disabled model
|
|
// doesn't pay the regex scan cost.
|
|
if cfg, ok := c.Get(ctxKeyModelConfig).(ModelPIIConfig); ok {
|
|
if !cfg.PIIIsEnabled() {
|
|
return next(c)
|
|
}
|
|
} else {
|
|
// No ModelPIIConfig on context → fail-closed: skip
|
|
// redaction. This protects routes that wire the
|
|
// middleware before SetModelAndConfig runs (or non-chat
|
|
// routes that don't carry a model). The middleware was
|
|
// previously fail-open, applying the global redactor
|
|
// unconditionally; the new contract is per-model
|
|
// opt-in, and a missing model is treated as disabled.
|
|
return next(c)
|
|
}
|
|
|
|
parsed := c.Get(ctxKeyParsedRequest)
|
|
if parsed == nil {
|
|
return next(c)
|
|
}
|
|
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
user = fallbackUser
|
|
}
|
|
userID := ""
|
|
if user != nil {
|
|
userID = user.ID
|
|
}
|
|
correlationID, _ := c.Get(ctxKeyCorrelationID).(string)
|
|
|
|
// Resolve per-model action overrides once per request. The
|
|
// raw map is YAML strings; convert to the typed Action set
|
|
// and silently drop unknown values rather than failing the
|
|
// request — model YAML typos shouldn't take chat down.
|
|
var overrides map[string]Action
|
|
if cfg, ok := c.Get(ctxKeyModelConfig).(ModelPIIConfig); ok {
|
|
if raw := cfg.PIIPatternOverrides(); len(raw) > 0 {
|
|
overrides = make(map[string]Action, len(raw))
|
|
for id, action := range raw {
|
|
switch Action(action) {
|
|
case ActionMask, ActionBlock, ActionAllow:
|
|
overrides[id] = Action(action)
|
|
default:
|
|
xlog.Warn("pii: ignoring unknown action in per-model override",
|
|
"pattern", id, "action", action)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
texts := adapter.Scan(parsed)
|
|
updates := make([]ScannedText, 0, len(texts))
|
|
var blocked bool
|
|
var firstEventID string
|
|
|
|
for _, st := range texts {
|
|
if st.Text == "" {
|
|
continue
|
|
}
|
|
res := redactor.RedactWithOverrides(st.Text, overrides)
|
|
if len(res.Spans) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Persist one event per span so admins can see exactly
|
|
// which patterns fired in which positions. The action
|
|
// recorded is the resolved one (after override), so the
|
|
// events log reflects what actually happened to the
|
|
// request, not the global default.
|
|
for _, span := range res.Spans {
|
|
action := actionForSpan(redactor.Patterns(), span.Pattern, overrides)
|
|
ev := PIIEvent{
|
|
ID: newEventID(),
|
|
CorrelationID: correlationID,
|
|
UserID: userID,
|
|
Direction: DirectionIn,
|
|
PatternID: span.Pattern,
|
|
ByteOffset: span.Start,
|
|
Length: span.End - span.Start,
|
|
HashPrefix: span.HashPrefix,
|
|
Action: action,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
if firstEventID == "" {
|
|
firstEventID = ev.ID
|
|
}
|
|
if store != nil {
|
|
if err := store.Record(context.Background(), ev); err != nil {
|
|
xlog.Error("pii: failed to record event", "error", err, "pattern", span.Pattern)
|
|
}
|
|
}
|
|
// Contract: every span must produce an event.
|
|
contract.Invariant(
|
|
"pii.event_per_span",
|
|
span.Pattern != "" && ev.PatternID != "",
|
|
"correlation", correlationID, "pattern", span.Pattern,
|
|
)
|
|
}
|
|
|
|
if res.Blocked {
|
|
blocked = true
|
|
}
|
|
updates = append(updates, ScannedText{Index: st.Index, Text: res.Redacted})
|
|
}
|
|
|
|
if blocked {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": map[string]string{
|
|
"message": "request blocked by content policy (sensitive data detected)",
|
|
"type": "pii_blocked",
|
|
},
|
|
"correlation_id": correlationID,
|
|
"pii_event_id": firstEventID,
|
|
})
|
|
}
|
|
|
|
if len(updates) > 0 && adapter.Apply != nil {
|
|
adapter.Apply(parsed, updates)
|
|
}
|
|
if firstEventID != "" {
|
|
c.Set(ctxKeyPIIEventID, firstEventID)
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func actionForPattern(patterns []Pattern, id string) Action {
|
|
for _, p := range patterns {
|
|
if p.ID == id {
|
|
return p.Action
|
|
}
|
|
}
|
|
return ActionMask
|
|
}
|
|
|
|
// actionForSpan returns the resolved action for a span, preferring a
|
|
// per-request override over the pattern's stored action. Used so the
|
|
// PIIEvent log reflects the action that actually fired (e.g., a model
|
|
// upgraded email from mask to block — the event row says "block").
|
|
func actionForSpan(patterns []Pattern, id string, overrides map[string]Action) Action {
|
|
if action, ok := overrides[id]; ok {
|
|
return action
|
|
}
|
|
return actionForPattern(patterns, id)
|
|
}
|
|
|
|
func newEventID() string {
|
|
var b [12]byte
|
|
_, _ = rand.Read(b[:])
|
|
return "pii_" + hex.EncodeToString(b[:])
|
|
}
|