diff --git a/core/http/app.go b/core/http/app.go index dcd9a2219958..4ad554f70875 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -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 { @@ -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) @@ -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 diff --git a/core/http/endpoints/localai/settings.go b/core/http/endpoints/localai/settings.go new file mode 100644 index 000000000000..50291f3db20c --- /dev/null +++ b/core/http/endpoints/localai/settings.go @@ -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) + } +} diff --git a/core/http/middleware/metrics.go b/core/http/middleware/metrics.go new file mode 100644 index 000000000000..6a2f474c29fb --- /dev/null +++ b/core/http/middleware/metrics.go @@ -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" +} diff --git a/core/http/middleware/request.go b/core/http/middleware/request.go index 35f39f7f37f9..1147c11dfdd3 100644 --- a/core/http/middleware/request.go +++ b/core/http/middleware/request.go @@ -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) diff --git a/core/http/routes/ui.go b/core/http/routes/ui.go index c781bd88b021..1816a86fcb9c 100644 --- a/core/http/routes/ui.go +++ b/core/http/routes/ui.go @@ -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{ diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go index 1fb016825c2b..0ea8f6ca42d4 100644 --- a/core/http/routes/ui_api.go +++ b/core/http/routes/ui_api.go @@ -18,7 +18,7 @@ import ( ) // RegisterUIAPIRoutes registers JSON API routes for the web UI -func RegisterUIAPIRoutes(app *fiber.App, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) { +func RegisterUIAPIRoutes(app *fiber.App, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache, metricsStore services.MetricsStore) { // Operations API - Get all current operations (models + backends) app.Get("/api/operations", func(c *fiber.Ctx) error { @@ -716,4 +716,104 @@ func RegisterUIAPIRoutes(app *fiber.App, cl *config.ModelConfigLoader, appConfig }, }) }) + + // Metrics API endpoints + if metricsStore != nil { + // Get metrics summary + app.Get("/api/metrics/summary", func(c *fiber.Ctx) error { + endpointStats := metricsStore.GetEndpointStats() + modelStats := metricsStore.GetModelStats() + backendStats := metricsStore.GetBackendStats() + + // Get top 5 models + type modelStat struct { + Name string `json:"name"` + Count int64 `json:"count"` + } + topModels := make([]modelStat, 0) + for model, count := range modelStats { + topModels = append(topModels, modelStat{Name: model, Count: count}) + } + sort.Slice(topModels, func(i, j int) bool { + return topModels[i].Count > topModels[j].Count + }) + if len(topModels) > 5 { + topModels = topModels[:5] + } + + // Get top 5 endpoints + type endpointStat struct { + Name string `json:"name"` + Count int64 `json:"count"` + } + topEndpoints := make([]endpointStat, 0) + for endpoint, count := range endpointStats { + topEndpoints = append(topEndpoints, endpointStat{Name: endpoint, Count: count}) + } + sort.Slice(topEndpoints, func(i, j int) bool { + return topEndpoints[i].Count > topEndpoints[j].Count + }) + if len(topEndpoints) > 5 { + topEndpoints = topEndpoints[:5] + } + + return c.JSON(fiber.Map{ + "totalRequests": metricsStore.GetTotalRequests(), + "successRate": metricsStore.GetSuccessRate(), + "topModels": topModels, + "topEndpoints": topEndpoints, + "topBackends": backendStats, + }) + }) + + // Get endpoint statistics + app.Get("/api/metrics/endpoints", func(c *fiber.Ctx) error { + stats := metricsStore.GetEndpointStats() + return c.JSON(fiber.Map{ + "endpoints": stats, + }) + }) + + // Get model statistics + app.Get("/api/metrics/models", func(c *fiber.Ctx) error { + stats := metricsStore.GetModelStats() + return c.JSON(fiber.Map{ + "models": stats, + }) + }) + + // Get backend statistics + app.Get("/api/metrics/backends", func(c *fiber.Ctx) error { + stats := metricsStore.GetBackendStats() + return c.JSON(fiber.Map{ + "backends": stats, + }) + }) + + // Get time series data + app.Get("/api/metrics/timeseries", func(c *fiber.Ctx) error { + // Default to last 24 hours + hours := 24 + if hoursParam := c.Query("hours"); hoursParam != "" { + if h, err := strconv.Atoi(hoursParam); err == nil && h > 0 { + hours = h + } + } + + timeSeries := metricsStore.GetRequestsOverTime(hours) + return c.JSON(fiber.Map{ + "timeseries": timeSeries, + "hours": hours, + }) + }) + + // Reset metrics (optional - for testing/admin purposes) + app.Post("/api/metrics/reset", func(c *fiber.Ctx) error { + metricsStore.Reset() + return c.JSON(fiber.Map{ + "success": true, + "message": "Metrics reset successfully", + }) + }) + } } diff --git a/core/http/views/index.html b/core/http/views/index.html index b92c6f2e90e2..17db109e356a 100644 --- a/core/http/views/index.html +++ b/core/http/views/index.html @@ -3,599 +3,506 @@ {{template "views/partials/head" .}}
-The powerful FOSS alternative to OpenAI, Claude, and more
+Get started by installing your first AI model
-Get started by installing models from the gallery or check our documentation for guidance
- -Monitor your LocalAI instance
These models were found but don't have configuration files yet
-Active Backends
+ + + +- {{$modelsN}} model{{if gt $modelsN 1}}s{{end}} ready to use -
+Total API Requests
+0% success
Currently Loaded
No API requests yet
No model requests yet
- {{len .InstalledBackends}} backend{{if gt (len .InstalledBackends) 1}}s{{end}} ready to use -
+ +No timeline data yet
+Backends power your AI models. Install them from the backend gallery to get started
- - + + + +Browse Models
Documentation
+Reference
Manage your models, backends, and system configuration
+ +Get started by installing models from the gallery or check our documentation for guidance
+ + + + {{ if ne (len .Models) 0 }} +These models were found but don't have configuration files yet
+{{.}}
+No configuration
++ {{$modelsN}} model{{if gt $modelsN 1}}s{{end}} ready to use +
++ {{len .InstalledBackends}} backend{{if gt (len .InstalledBackends) 1}}s{{end}} ready to use +
+Backends power your AI models. Install them from the backend gallery to get started
+ + +