Files
LocalAI/core/services/routing/pii/middleware.go
Richard Palethorpe 085fc53bbc fix(router): production-ready request router + auto-size batch for embedding/rerank (#10104)
* 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>
2026-06-12 16:21:15 +02:00

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[:])
}