mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-05 04:24:39 -04:00
* feat(schema): validate portable speaker profiles Add the versioned profile schema for explicit speaker enrollment. Validate compatibility against separately supplied loaded-encoder metadata. Reject unusable speakers, invalid vectors, and inconsistent clean spans. This slice does not change HTTP routes, backend integration, or the UI. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(parakeet): export profiles with transcripts Export opt-in speaker profiles and trusted encoder metadata. Replay registrations by ID so duplicate display names keep independent vectors. Use one profile-capable diarization for slots, names, and clean spans. Assign timestamped ASR words to those slots without a second diarization. Preserve legacy opt-out and no-ASR behavior, and propagate failures. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(audio): enroll portable speaker profiles Gate profile exports with voice-recognition permission and validate registration against metadata from the loaded encoder. Preserve audio enrollment and independent registrations with duplicate display names. Exclude diarization and registration exchanges before API trace capture so persisted traces cannot retain profile vectors or JSON audio. Defer candidate dimensions to trusted loaded metadata. Sort candidates by registration ID so incompatible profiles cannot suppress legacy voices through registry iteration order. Keep portable identity checks closed when trusted metadata is unavailable. Test persisted traces, explicit slot zero, and selection through offline and live transport. Document privacy and the ephemeral registry lifecycle. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): remember speakers from diarization Add a Studio page for diarization and opt-in speaker profiles. Preview clean intervals from the original recording before explicit registration. Join profiles by raw speaker labels, preserve duplicate names, and relabel turns only after a successful save. Discard stale results when the model or recording changes. Share registration metadata with voice management without storing vectors or recordings from this flow. Document permissions and the global, ephemeral registry. Cover enrollment, permissions, previews, and asynchronous races with mocked Playwright tests. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: clarify HTTP speaker enrollment support Replace the stale enrollment limitation with the current HTTP workflow. Distinguish native transport from explicit registration and link its docs. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(parakeet): pin merged speaker profile support Use the merged commit from mudler/parakeet.cpp#80. Its tree matches the previously accepted native pin. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: add diarization enrollment setup example Connect the existing gallery modes to the speaker enrollment workflow. Show installation, private profile export, explicit raw-slot registration, and later recognition without another export. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(blog): explain diarization speaker profiles Put the diarization walkthrough on the LocalAI website in the feature PR. Cover the three gallery modes, explicit enrollment, and privacy limits. Link setup instructions and keep availability conditional on feature support. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(blog): focus diarization on everyday use Explain what users can do with recordings before the setup steps. Replace the technical walkthrough with a short Studio guide and link readers to the existing reference for model names and developer use. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(blog): lead with speaker capabilities Present speaker recognition through everyday uses and a short UI flow. Keep technical reference details in the existing documentation. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(diarization): satisfy Go lint checks Avoid copying protobuf message state when extending backend status, check the multipart reader close result, and document the focused testing.T lint exemptions. Assisted-by: nib:gpt-5.6-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
462 lines
13 KiB
Go
462 lines
13 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/emirpasic/gods/v2/queues/circularbuffer"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/application"
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
"github.com/mudler/LocalAI/core/trace/tracepersist"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
type APIExchangeRequest struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Headers *http.Header `json:"headers"`
|
|
Body *[]byte `json:"body"`
|
|
BodyTruncated bool `json:"body_truncated,omitempty"`
|
|
BodyBytes int `json:"body_bytes,omitempty"` // original size before truncation
|
|
}
|
|
|
|
type APIExchangeResponse struct {
|
|
Status int `json:"status"`
|
|
Headers *http.Header `json:"headers"`
|
|
Body *[]byte `json:"body"`
|
|
BodyTruncated bool `json:"body_truncated,omitempty"`
|
|
BodyBytes int `json:"body_bytes,omitempty"` // original size before truncation
|
|
}
|
|
|
|
type APIExchange struct {
|
|
// ID identifies this exchange for the lifetime of the process. The list
|
|
// endpoint returns trimmed entries; clients fetch the full payload back
|
|
// by ID from /api/traces/:id.
|
|
ID string `json:"id"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Duration time.Duration `json:"duration"`
|
|
Request APIExchangeRequest `json:"request"`
|
|
Response APIExchangeResponse `json:"response"`
|
|
Error string `json:"error,omitempty"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
UserName string `json:"user_name,omitempty"`
|
|
// ClientIP is the caller's address as resolved by echo (honours
|
|
// X-Forwarded-For / X-Real-IP behind a trusted proxy), and UserAgent
|
|
// is the raw User-Agent header. Both are surfaced in the admin Traces
|
|
// UI so an operator can tell who/what issued each request.
|
|
ClientIP string `json:"client_ip,omitempty"`
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
}
|
|
|
|
var traceBuffer *circularbuffer.Queue[APIExchange]
|
|
var inFlightTraces = make(map[string]APIExchange)
|
|
var mu sync.Mutex
|
|
var logChan = make(chan traceCommand, 100)
|
|
var traceIDSeq atomic.Uint64
|
|
var traceConsumerOnce sync.Once
|
|
var traceStore *tracepersist.Store[APIExchange]
|
|
var traceStoreKey string
|
|
|
|
type traceCommand struct {
|
|
exchange *APIExchange
|
|
store *tracepersist.Store[APIExchange]
|
|
clear chan error
|
|
}
|
|
|
|
func nextTraceID() string {
|
|
return strconv.FormatUint(traceIDSeq.Add(1), 10)
|
|
}
|
|
|
|
func initializeTracing(dataPath string, maxItems int) {
|
|
if maxItems <= 0 {
|
|
maxItems = 100
|
|
}
|
|
key := filepath.Join(dataPath, strconv.Itoa(maxItems))
|
|
mu.Lock()
|
|
if traceBuffer != nil && traceStoreKey == key {
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
|
|
var store *tracepersist.Store[APIExchange]
|
|
var restored []APIExchange
|
|
if dataPath != "" {
|
|
var err error
|
|
store, err = tracepersist.New[APIExchange](filepath.Join(dataPath, "traces", "api"), maxItems)
|
|
if err != nil {
|
|
xlog.Warn("Failed to initialize API trace persistence", "error", err)
|
|
} else if restored, err = store.Load(); err != nil {
|
|
xlog.Warn("Failed to restore API traces", "error", err)
|
|
store = nil
|
|
}
|
|
}
|
|
traceBuffer = circularbuffer.New[APIExchange](maxItems)
|
|
for _, exchange := range restored {
|
|
traceBuffer.Enqueue(exchange)
|
|
advanceTraceID(&traceIDSeq, exchange.ID)
|
|
}
|
|
traceStore = store
|
|
traceStoreKey = key
|
|
mu.Unlock()
|
|
|
|
traceConsumerOnce.Do(func() {
|
|
go func() {
|
|
for command := range logChan {
|
|
if command.clear != nil {
|
|
mu.Lock()
|
|
if traceBuffer != nil {
|
|
traceBuffer.Clear()
|
|
}
|
|
mu.Unlock()
|
|
var err error
|
|
if command.store != nil {
|
|
err = command.store.Clear()
|
|
}
|
|
command.clear <- err
|
|
continue
|
|
}
|
|
exchange := *command.exchange
|
|
mu.Lock()
|
|
delete(inFlightTraces, exchange.ID)
|
|
if traceBuffer != nil {
|
|
traceBuffer.Enqueue(exchange)
|
|
}
|
|
mu.Unlock()
|
|
if command.store != nil {
|
|
if err := command.store.Append(exchange.ID, exchange); err != nil {
|
|
xlog.Warn("Failed to persist API trace", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
})
|
|
}
|
|
|
|
func advanceTraceID(seq *atomic.Uint64, id string) {
|
|
n, err := strconv.ParseUint(id, 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for current := seq.Load(); n > current && !seq.CompareAndSwap(current, n); current = seq.Load() {
|
|
}
|
|
}
|
|
|
|
type bodyWriter struct {
|
|
http.ResponseWriter
|
|
body *bytes.Buffer
|
|
maxBytes int // 0 = unlimited capture
|
|
truncated bool
|
|
totalBytes int // bytes the upstream handler wrote, even past the cap
|
|
}
|
|
|
|
func (w *bodyWriter) Write(b []byte) (int, error) {
|
|
// Capture into the trace buffer up to maxBytes, then drop the overflow
|
|
// so a chatty endpoint can't grow the buffer without bound. The full
|
|
// payload still flows through to the real client below.
|
|
w.totalBytes += len(b)
|
|
if w.maxBytes <= 0 {
|
|
w.body.Write(b)
|
|
} else if remain := w.maxBytes - w.body.Len(); remain > 0 {
|
|
if remain >= len(b) {
|
|
w.body.Write(b)
|
|
} else {
|
|
w.body.Write(b[:remain])
|
|
w.truncated = true
|
|
}
|
|
} else {
|
|
w.truncated = true
|
|
}
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
func (w *bodyWriter) Flush() {
|
|
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
// truncateForTrace returns a defensive copy of body capped at maxBytes,
|
|
// and a flag indicating whether the cap forced truncation. maxBytes <= 0
|
|
// disables the cap.
|
|
func truncateForTrace(body []byte, maxBytes int) ([]byte, bool) {
|
|
if maxBytes <= 0 || len(body) <= maxBytes {
|
|
out := make([]byte, len(body))
|
|
copy(out, body)
|
|
return out, false
|
|
}
|
|
out := make([]byte, maxBytes)
|
|
copy(out, body[:maxBytes])
|
|
return out, true
|
|
}
|
|
|
|
// Hijack lets WebSocket upgraders (gorilla/websocket) reach the
|
|
// underlying connection. Without this, gorilla's Hijacker type-assertion
|
|
// fails on the wrapped writer and the handshake returns 500.
|
|
func (w *bodyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
if hj, ok := w.ResponseWriter.(http.Hijacker); ok {
|
|
return hj.Hijack()
|
|
}
|
|
return nil, nil, http.ErrNotSupported
|
|
}
|
|
|
|
// sensitiveTraceHeaders is the set of header names whose values must not
|
|
// land in the in-memory trace buffer. Keys are canonical — http.Header
|
|
// stores them that way, so range yields canonical keys directly.
|
|
var sensitiveTraceHeaders = map[string]struct{}{
|
|
"Authorization": {},
|
|
"Proxy-Authorization": {},
|
|
"Cookie": {},
|
|
"Set-Cookie": {},
|
|
"X-Api-Key": {},
|
|
"Xi-Api-Key": {},
|
|
"X-Auth-Token": {},
|
|
}
|
|
|
|
func redactSensitiveHeaders(h http.Header) http.Header {
|
|
out := h.Clone()
|
|
for k := range out {
|
|
if _, ok := sensitiveTraceHeaders[k]; ok {
|
|
out[k] = []string{"[redacted]"}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TraceMiddleware intercepts and logs JSON API requests and responses
|
|
func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
|
initializeTracing(app.ApplicationConfig().DataPath, app.ApplicationConfig().TracingMaxItems)
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if !app.ApplicationConfig().EnableTracing {
|
|
return next(c)
|
|
}
|
|
|
|
// Biometric routes can carry vectors in either direction and JSON
|
|
// diarization carries base64 audio even without profile export.
|
|
// Exclude the whole exchange before reading or wrapping bodies,
|
|
// including registration if tracing is installed globally later.
|
|
switch c.Path() {
|
|
case "/v1/audio/diarization", "/audio/diarization", "/v1/voice/register":
|
|
return next(c)
|
|
}
|
|
|
|
ct, _, _ := mime.ParseMediaType(c.Request().Header.Get("Content-Type"))
|
|
if ct != "application/json" {
|
|
return next(c)
|
|
}
|
|
|
|
body, err := io.ReadAll(c.Request().Body)
|
|
if err != nil {
|
|
xlog.Error("Failed to read request body")
|
|
return err
|
|
}
|
|
|
|
// Restore the body for downstream handlers
|
|
c.Request().Body = io.NopCloser(bytes.NewBuffer(body))
|
|
|
|
startTime := time.Now()
|
|
|
|
// Cap captured payload size. Without this, /embeddings and
|
|
// streaming /chat/completions blow the in-memory buffer into the
|
|
// tens of MB, which then locks the admin Traces UI fetching the
|
|
// JSON dump faster than the 5s auto-refresh.
|
|
maxBodyBytes := app.ApplicationConfig().TracingMaxBodyBytes
|
|
requestHeaders := redactSensitiveHeaders(c.Request().Header)
|
|
requestBody, requestTruncated := truncateForTrace(body, maxBodyBytes)
|
|
exchange := APIExchange{
|
|
ID: nextTraceID(),
|
|
Timestamp: startTime,
|
|
ClientIP: c.RealIP(),
|
|
UserAgent: c.Request().UserAgent(),
|
|
Request: APIExchangeRequest{
|
|
Method: c.Request().Method,
|
|
Path: c.Path(),
|
|
Headers: &requestHeaders,
|
|
Body: &requestBody,
|
|
BodyTruncated: requestTruncated,
|
|
BodyBytes: len(body),
|
|
},
|
|
}
|
|
if user := auth.GetUser(c); user != nil {
|
|
exchange.UserID = user.ID
|
|
exchange.UserName = user.Name
|
|
}
|
|
mu.Lock()
|
|
inFlightTraces[exchange.ID] = exchange
|
|
mu.Unlock()
|
|
queued := false
|
|
defer func() {
|
|
if queued {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
delete(inFlightTraces, exchange.ID)
|
|
mu.Unlock()
|
|
}()
|
|
|
|
// Wrap response writer to capture body
|
|
resBody := new(bytes.Buffer)
|
|
mw := &bodyWriter{
|
|
ResponseWriter: c.Response().Writer,
|
|
body: resBody,
|
|
maxBytes: maxBodyBytes,
|
|
}
|
|
c.Response().Writer = mw
|
|
|
|
handlerErr := next(c)
|
|
|
|
// Restore original writer unconditionally
|
|
c.Response().Writer = mw.ResponseWriter
|
|
|
|
// Echo renders returned errors after middleware unwinds. Its default
|
|
// response status is already 200, so use the error while uncommitted.
|
|
status := c.Response().Status
|
|
if handlerErr != nil && !c.Response().Committed {
|
|
status = http.StatusInternalServerError
|
|
var httpErr *echo.HTTPError
|
|
if errors.As(handlerErr, &httpErr) {
|
|
status = httpErr.Code
|
|
}
|
|
}
|
|
|
|
// Create exchange log (always, even on error). Sensitive headers
|
|
// (Authorization, API keys, cookies) are redacted before storage —
|
|
// the trace endpoint is admin-only but the buffer is also reachable
|
|
// via any heap-dump-style introspection, and tokens shouldn't
|
|
// outlive the request that carried them.
|
|
responseHeaders := redactSensitiveHeaders(c.Response().Header())
|
|
responseBody := make([]byte, resBody.Len())
|
|
copy(responseBody, resBody.Bytes())
|
|
exchange.Duration = time.Since(startTime)
|
|
exchange.Response = APIExchangeResponse{
|
|
Status: status,
|
|
Headers: &responseHeaders,
|
|
Body: &responseBody,
|
|
BodyTruncated: mw.truncated,
|
|
BodyBytes: mw.totalBytes,
|
|
}
|
|
if handlerErr != nil {
|
|
exchange.Error = handlerErr.Error()
|
|
}
|
|
|
|
mu.Lock()
|
|
store := traceStore
|
|
mu.Unlock()
|
|
select {
|
|
case logChan <- traceCommand{exchange: &exchange, store: store}:
|
|
queued = true
|
|
default:
|
|
xlog.Warn("Trace channel full, dropping trace")
|
|
}
|
|
|
|
return handlerErr
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetTraces returns a copy of the logged API exchanges for display
|
|
func GetTraces() []APIExchange {
|
|
mu.Lock()
|
|
if traceBuffer == nil {
|
|
mu.Unlock()
|
|
return []APIExchange{}
|
|
}
|
|
traces := traceBuffer.Values()
|
|
for _, exchange := range inFlightTraces {
|
|
exchange.Duration = time.Since(exchange.Timestamp)
|
|
traces = append(traces, exchange)
|
|
}
|
|
mu.Unlock()
|
|
|
|
slices.SortFunc(traces, func(a, b APIExchange) int {
|
|
return b.Timestamp.Compare(a.Timestamp)
|
|
})
|
|
|
|
return traces
|
|
}
|
|
|
|
// GetTracesPage returns the newest-first window [offset, offset+limit) of the
|
|
// trace buffer together with the total number of buffered exchanges. A limit
|
|
// <= 0 means "no bound" and returns everything from offset onwards.
|
|
func GetTracesPage(offset, limit int) ([]APIExchange, int) {
|
|
all := GetTraces()
|
|
return window(all, offset, limit), len(all)
|
|
}
|
|
|
|
// GetTrace returns the buffered exchange with the given ID.
|
|
func GetTrace(id string) (APIExchange, bool) {
|
|
for _, t := range GetTraces() {
|
|
if t.ID == id {
|
|
return t, true
|
|
}
|
|
}
|
|
return APIExchange{}, false
|
|
}
|
|
|
|
// SummarizeExchange strips the heavy parts of an exchange: request/response
|
|
// bodies and header maps. What remains is enough to render the trace list
|
|
// (method, path, status, timing, sizes, caller), and the byte counters are
|
|
// preserved so the UI can still say how big the dropped payload was. Callers
|
|
// fetch the full record by ID when a row is expanded.
|
|
//
|
|
// This is what keeps the polling cost bounded: bodies are what made
|
|
// /api/traces a multi-megabyte response on every refresh.
|
|
func SummarizeExchange(e APIExchange) APIExchange {
|
|
e.Request.Body = nil
|
|
e.Request.Headers = nil
|
|
e.Response.Body = nil
|
|
e.Response.Headers = nil
|
|
return e
|
|
}
|
|
|
|
// window slices s to the requested page, clamping out-of-range bounds to an
|
|
// empty result rather than panicking.
|
|
func window[T any](s []T, offset, limit int) []T {
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
if offset >= len(s) {
|
|
return []T{}
|
|
}
|
|
s = s[offset:]
|
|
if limit > 0 && limit < len(s) {
|
|
s = s[:limit]
|
|
}
|
|
out := make([]T, len(s))
|
|
copy(out, s)
|
|
return out
|
|
}
|
|
|
|
// ClearTraces clears the in-memory logs
|
|
func ClearTraces() {
|
|
mu.Lock()
|
|
store := traceStore
|
|
initialized := traceBuffer != nil
|
|
mu.Unlock()
|
|
if initialized {
|
|
done := make(chan error, 1)
|
|
logChan <- traceCommand{store: store, clear: done}
|
|
if err := <-done; err != nil {
|
|
xlog.Warn("Failed to clear persisted API traces", "error", err)
|
|
}
|
|
return
|
|
}
|
|
mu.Lock()
|
|
if traceBuffer != nil {
|
|
traceBuffer.Clear()
|
|
}
|
|
mu.Unlock()
|
|
}
|