Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion core/http/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func API(application *application.Application) (*fiber.App, error) {
router.Use(recover.New())
}

// OpenTelemetry metrics for Prometheus export
if !application.ApplicationConfig().DisableMetrics {
metricsService, err := services.NewLocalAIMetricsService()
if err != nil {
Expand All @@ -141,6 +142,7 @@ func API(application *application.Application) (*fiber.App, error) {
})
}
}

// Health Checks should always be exempt from auth, so register these first
routes.HealthRoutes(router)

Expand Down Expand Up @@ -202,12 +204,28 @@ func API(application *application.Application) (*fiber.App, error) {
routes.RegisterElevenLabsRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
routes.RegisterLocalAIRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
routes.RegisterOpenAIRoutes(router, requestExtractor, application)

if !application.ApplicationConfig().DisableWebUI {

// Create metrics store for tracking usage (before API routes registration)
metricsStore := services.NewInMemoryMetricsStore()

// Add metrics middleware BEFORE API routes so it can intercept them
router.Use(middleware.MetricsMiddleware(metricsStore))

// Register cleanup on shutdown
router.Hooks().OnShutdown(func() error {
metricsStore.Stop()
log.Info().Msg("Metrics store stopped")
return nil
})

// Create opcache for tracking UI operations
opcache := services.NewOpCache(application.GalleryService())
routes.RegisterUIAPIRoutes(router, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), opcache)
routes.RegisterUIAPIRoutes(router, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, metricsStore)
routes.RegisterUIRoutes(router, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
}

routes.RegisterJINARoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())

// Define a custom 404 handler
Expand Down
61 changes: 61 additions & 0 deletions core/http/endpoints/localai/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package localai

import (
"github.com/gofiber/fiber/v2"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/utils"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/model"
)

// SettingsEndpoint handles the settings page which shows detailed model/backend management
func SettingsEndpoint(appConfig *config.ApplicationConfig,
cl *config.ModelConfigLoader, ml *model.ModelLoader, opcache *services.OpCache) func(*fiber.Ctx) error {
return func(c *fiber.Ctx) error {
modelConfigs := cl.GetAllModelsConfigs()
galleryConfigs := map[string]*gallery.ModelConfig{}

installedBackends, err := gallery.ListSystemBackends(appConfig.SystemState)
if err != nil {
return err
}

for _, m := range modelConfigs {
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
if err != nil {
continue
}
galleryConfigs[m.Name] = cfg
}

loadedModels := ml.ListLoadedModels()
loadedModelsMap := map[string]bool{}
for _, m := range loadedModels {
loadedModelsMap[m.ID] = true
}

modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)

// Get model statuses to display in the UI the operation in progress
processingModels, taskTypes := opcache.GetStatus()

summary := fiber.Map{
"Title": "LocalAI - Settings & Management",
"Version": internal.PrintableVersion(),
"BaseURL": utils.BaseURL(c),
"Models": modelsWithoutConfig,
"ModelsConfig": modelConfigs,
"GalleryConfig": galleryConfigs,
"ApplicationConfig": appConfig,
"ProcessingModels": processingModels,
"TaskTypes": taskTypes,
"LoadedModels": loadedModelsMap,
"InstalledBackends": installedBackends,
}

// Render settings page
return c.Render("views/settings", summary)
}
}
174 changes: 174 additions & 0 deletions core/http/middleware/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package middleware

import (
"encoding/json"
"strings"
"time"

"github.com/gofiber/fiber/v2"
"github.com/mudler/LocalAI/core/services"
"github.com/rs/zerolog/log"
)

// MetricsMiddleware creates a middleware that tracks API usage metrics
// Note: Uses CONTEXT_LOCALS_KEY_MODEL_NAME constant defined in request.go
func MetricsMiddleware(metricsStore services.MetricsStore) fiber.Handler {
return func(c *fiber.Ctx) error {
path := c.Path()

// Skip tracking for UI routes, static files, and non-API endpoints
if shouldSkipMetrics(path) {
return c.Next()
}

// Record start time
start := time.Now()

// Get endpoint category
endpoint := categorizeEndpoint(path)

// Continue with the request
err := c.Next()

// Record metrics after request completes
duration := time.Since(start)
success := err == nil && c.Response().StatusCode() < 400

// Extract model name from context (set by RequestExtractor middleware)
// Use the same constant as RequestExtractor
model := "unknown"
if modelVal, ok := c.Locals(CONTEXT_LOCALS_KEY_MODEL_NAME).(string); ok && modelVal != "" {
model = modelVal
log.Debug().Str("model", model).Str("endpoint", endpoint).Msg("Recording metrics for request")
} else {
// Fallback: try to extract from path params or query
model = extractModelFromRequest(c)
log.Debug().Str("model", model).Str("endpoint", endpoint).Msg("Recording metrics for request (fallback)")
}

// Extract backend from response headers if available
backend := string(c.Response().Header.Peek("X-LocalAI-Backend"))

// Record the request
metricsStore.RecordRequest(endpoint, model, backend, success, duration)

return err
}
}

// shouldSkipMetrics determines if a request should be excluded from metrics
func shouldSkipMetrics(path string) bool {
// Skip UI routes
skipPrefixes := []string{
"/views/",
"/static/",
"/browse/",
"/chat/",
"/text2image/",
"/tts/",
"/talk/",
"/models/edit/",
"/import-model",
"/settings",
"/api/models", // UI API endpoints
"/api/backends", // UI API endpoints
"/api/operations", // UI API endpoints
"/api/p2p", // UI API endpoints
"/api/metrics", // Metrics API itself
}

for _, prefix := range skipPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}

// Also skip root path and other UI pages
if path == "/" || path == "/index" {
return true
}

return false
}

// categorizeEndpoint maps request paths to friendly endpoint categories
func categorizeEndpoint(path string) string {
// OpenAI-compatible endpoints
if strings.HasPrefix(path, "/v1/chat/completions") || strings.HasPrefix(path, "/chat/completions") {
return "chat"
}
if strings.HasPrefix(path, "/v1/completions") || strings.HasPrefix(path, "/completions") {
return "completions"
}
if strings.HasPrefix(path, "/v1/embeddings") || strings.HasPrefix(path, "/embeddings") {
return "embeddings"
}
if strings.HasPrefix(path, "/v1/images/generations") || strings.HasPrefix(path, "/images/generations") {
return "image-generation"
}
if strings.HasPrefix(path, "/v1/audio/transcriptions") || strings.HasPrefix(path, "/audio/transcriptions") {
return "transcriptions"
}
if strings.HasPrefix(path, "/v1/audio/speech") || strings.HasPrefix(path, "/audio/speech") {
return "text-to-speech"
}
if strings.HasPrefix(path, "/v1/models") || strings.HasPrefix(path, "/models") {
return "models"
}

// LocalAI-specific endpoints
if strings.HasPrefix(path, "/v1/internal") {
return "internal"
}
if strings.Contains(path, "/tts") {
return "text-to-speech"
}
if strings.Contains(path, "/stt") || strings.Contains(path, "/whisper") {
return "speech-to-text"
}
if strings.Contains(path, "/sound-generation") {
return "sound-generation"
}

// Default to the first path segment
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) > 0 {
return parts[0]
}

return "unknown"
}

// extractModelFromRequest attempts to extract the model name from the request
func extractModelFromRequest(c *fiber.Ctx) string {
// Try query parameter first
model := c.Query("model")
if model != "" {
return model
}

// Try to extract from JSON body for POST requests
if c.Method() == fiber.MethodPost {
// Read body
bodyBytes := c.Body()
if len(bodyBytes) > 0 {
// Parse JSON
var reqBody map[string]interface{}
if err := json.Unmarshal(bodyBytes, &reqBody); err == nil {
if modelVal, ok := reqBody["model"]; ok {
if modelStr, ok := modelVal.(string); ok {
return modelStr
}
}
}
}
}

// Try path parameter for endpoints like /models/:model
model = c.Params("model")
if model != "" {
return model
}

return "unknown"
}
4 changes: 4 additions & 0 deletions core/http/middleware/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR
log.Debug().Str("context localModelName", localModelName).Msg("overriding empty model name in request body with value found earlier in middleware chain")
input.ModelName(&localModelName)
}
} else {
// Update context locals with the model name from the request body
// This ensures downstream middleware (like metrics) can access it
ctx.Locals(CONTEXT_LOCALS_KEY_MODEL_NAME, input.ModelName(nil))
}

cfg, err := re.modelConfigLoader.LoadModelConfigFileByNameDefaultOptions(input.ModelName(nil), re.applicationConfig)
Expand Down
3 changes: 3 additions & 0 deletions core/http/routes/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ func RegisterUIRoutes(app *fiber.App,

app.Get("/", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))

// Settings page - detailed model/backend management
app.Get("/settings", localai.SettingsEndpoint(appConfig, cl, ml, processingOps))

// P2P
app.Get("/p2p", func(c *fiber.Ctx) error {
summary := fiber.Map{
Expand Down
Loading
Loading