Files
wehub-resource-sync 1b8708893a
Security Scan / tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:12:26 +08:00

158 lines
5.4 KiB
Go

package routes
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/routing/billing"
)
// RegisterUsageRoutes wires the routing-module billing endpoints. These
// are the auth-agnostic siblings of /api/auth/usage and
// /api/auth/admin/usage — they go through application.StatsRecorder()
// so that a no-auth single-user box also gets a working dashboard
// (the existing /api/auth/usage hardcodes a 401 when no user is on the
// context).
//
// Permission model:
// - GET /api/usage → current user's own usage; falls back to
// the synthetic "local" user when auth is off.
// - GET /api/usage/all → cluster-wide; requires admin when auth
// is on. In no-auth mode the local user is the only principal and
// is treated as admin (the LocalUser is constructed with Role:
// admin), so this endpoint returns the same data as /api/usage.
//
// Both endpoints accept ?period={day|week|month|all} (default month)
// and ?user_id=… on the admin path.
func RegisterUsageRoutes(e *echo.Echo, app *application.Application) {
rec := app.StatsRecorder()
if rec == nil {
// Stats explicitly disabled (--disable-stats). Register stub
// handlers that return 503 with a clear reason rather than
// 404; clients (UI, MCP tools) can distinguish "not enabled
// here" from "endpoint missing entirely".
stub := func(c echo.Context) error {
return c.JSON(http.StatusServiceUnavailable, map[string]string{
"error": "usage tracking is disabled (--disable-stats)",
})
}
e.GET("/api/usage", stub)
e.GET("/api/usage/all", stub)
return
}
// GetUsageEndpoint godoc
// @Summary Get usage and token totals for the current user
// @Description Returns time-bucketed token usage for the authenticated user. In single-user no-auth mode, returns usage for the synthetic "local" user. Pass ?period={day|week|month|all}.
// @Tags usage
// @Produce json
// @Param period query string false "Time window: day, week, month, all" default(month)
// @Success 200 {object} map[string]interface{}
// @Router /api/usage [get]
e.GET("/api/usage", func(c echo.Context) error {
user := resolveUsageUser(c, app)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "not authenticated",
})
}
period := c.QueryParam("period")
if period == "" {
period = "month"
}
buckets, err := rec.Aggregate(c.Request().Context(), billing.AggregateQuery{
UserID: user.ID,
Period: period,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to get usage",
})
}
return c.JSON(http.StatusOK, usageResponse(buckets, user))
})
// GetAllUsageEndpoint godoc
// @Summary Get cluster-wide usage (admin)
// @Description Returns aggregate usage across all users. Requires admin role when auth is enabled. In single-user no-auth mode, returns the same data as /api/usage (the local user is the only principal).
// @Tags usage
// @Produce json
// @Param period query string false "Time window: day, week, month, all" default(month)
// @Param user_id query string false "Filter to a specific user"
// @Success 200 {object} map[string]interface{}
// @Failure 403 {object} map[string]string
// @Router /api/usage/all [get]
e.GET("/api/usage/all", func(c echo.Context) error {
user := resolveUsageUser(c, app)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "not authenticated",
})
}
// Admin gate. The synthetic local user is built with Role: admin
// in single-user mode, so this passes naturally when auth is off.
if user.Role != auth.RoleAdmin {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "admin access required",
})
}
period := c.QueryParam("period")
if period == "" {
period = "month"
}
filterUser := c.QueryParam("user_id")
buckets, err := rec.Aggregate(c.Request().Context(), billing.AggregateQuery{
UserID: filterUser, // empty = all users
Period: period,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to get usage",
})
}
return c.JSON(http.StatusOK, usageResponse(buckets, user))
})
}
// resolveUsageUser returns the authenticated user when present,
// otherwise the synthetic local user when auth is off. Centralizes the
// "if not auth, fall back to local" pattern that both routes need.
func resolveUsageUser(c echo.Context, app *application.Application) *auth.User {
if u := auth.GetUser(c); u != nil {
return u
}
return app.FallbackUser()
}
// usageResponse builds the JSON shape the UI consumes. The "viewer"
// field surfaces who the data belongs to so a single-user dashboard
// can show "local" without inventing its own labels.
func usageResponse(buckets []auth.UsageBucket, viewer *auth.User) map[string]any {
totals := auth.UsageTotals{}
for _, b := range buckets {
totals.PromptTokens += b.PromptTokens
totals.CompletionTokens += b.CompletionTokens
totals.TotalTokens += b.TotalTokens
totals.RequestCount += b.RequestCount
}
resp := map[string]any{
"usage": buckets,
"totals": totals,
}
if viewer != nil {
resp["viewer"] = map[string]string{
"id": viewer.ID,
"name": viewer.Name,
"role": viewer.Role,
"provider": viewer.Provider,
}
}
return resp
}