mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-02 06:04:09 -04:00
* feat: add distributed mode (experimental) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix data races, mutexes, transactions Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactorings Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fixups Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix events and tool stream in agent chat Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * use ginkgo Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(cron): compute correctly time boundaries avoiding re-triggering Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * enhancements, refactorings Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * do not flood of healthy checks Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * do not list obvious backends as text backends Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * tests fixups Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Drop redundant healthcheck Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * enhancements, refactorings Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
474 lines
15 KiB
Go
474 lines
15 KiB
Go
package localai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAGI/core/state"
|
|
coreTypes "github.com/mudler/LocalAGI/core/types"
|
|
agiServices "github.com/mudler/LocalAGI/services"
|
|
"github.com/mudler/LocalAI/core/application"
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
"github.com/mudler/LocalAI/core/services/agentpool"
|
|
"github.com/mudler/LocalAI/core/services/agents"
|
|
"github.com/mudler/LocalAI/pkg/utils"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// getUserID extracts the scoped user ID from the request context.
|
|
// Returns empty string when auth is not active (backward compat).
|
|
func getUserID(c echo.Context) string {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return ""
|
|
}
|
|
return user.ID
|
|
}
|
|
|
|
// isAdminUser returns true if the authenticated user has admin role.
|
|
func isAdminUser(c echo.Context) bool {
|
|
user := auth.GetUser(c)
|
|
return user != nil && user.Role == auth.RoleAdmin
|
|
}
|
|
|
|
// wantsAllUsers returns true if the request has ?all_users=true and the user is admin.
|
|
func wantsAllUsers(c echo.Context) bool {
|
|
return c.QueryParam("all_users") == "true" && isAdminUser(c)
|
|
}
|
|
|
|
// effectiveUserID returns the user ID to scope operations to.
|
|
// SECURITY: Only admins and agent-worker service accounts may supply
|
|
// ?user_id=<id> to operate on another user's resources. Agent-worker users are
|
|
// created exclusively server-side during node registration and need to access
|
|
// collections on behalf of the user whose agent they are executing.
|
|
// Regular callers always get their own ID regardless of query params.
|
|
func effectiveUserID(c echo.Context) string {
|
|
if targetUID := c.QueryParam("user_id"); targetUID != "" && canImpersonateUser(c) {
|
|
if callerID := getUserID(c); callerID != targetUID {
|
|
xlog.Info("User impersonation", "caller", callerID, "target", targetUID, "path", c.Path())
|
|
}
|
|
return targetUID
|
|
}
|
|
return getUserID(c)
|
|
}
|
|
|
|
// canImpersonateUser returns true if the caller is allowed to use ?user_id= to
|
|
// scope operations to another user. Allowed for admins and agent-worker service
|
|
// accounts (ProviderAgentWorker is set server-side during node registration and
|
|
// cannot be self-assigned).
|
|
func canImpersonateUser(c echo.Context) bool {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return false
|
|
}
|
|
return user.Role == auth.RoleAdmin || user.Provider == auth.ProviderAgentWorker
|
|
}
|
|
|
|
func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := getUserID(c)
|
|
statuses := svc.ListAgentsForUser(userID)
|
|
agents := slices.Sorted(maps.Keys(statuses))
|
|
resp := map[string]any{
|
|
"agents": agents,
|
|
"agentCount": len(agents),
|
|
"actions": len(agiServices.AvailableActions),
|
|
"connectors": len(agiServices.AvailableConnectors),
|
|
"statuses": statuses,
|
|
}
|
|
if hubURL := svc.AgentHubURL(); hubURL != "" {
|
|
resp["agent_hub_url"] = hubURL
|
|
}
|
|
|
|
// Admin cross-user aggregation
|
|
if wantsAllUsers(c) {
|
|
grouped := svc.ListAllAgentsGrouped()
|
|
userGroups := map[string]any{}
|
|
for uid, agentList := range grouped {
|
|
if uid == userID || uid == "" {
|
|
continue
|
|
}
|
|
userGroups[uid] = map[string]any{"agents": agentList}
|
|
}
|
|
if len(userGroups) > 0 {
|
|
resp["user_groups"] = userGroups
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
func CreateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := getUserID(c)
|
|
var cfg state.AgentConfig
|
|
if err := c.Bind(&cfg); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
if err := svc.CreateAgentForUser(userID, &cfg); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func GetAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
|
|
statuses := svc.ListAgentsForUser(userID)
|
|
active, exists := statuses[name]
|
|
if !exists {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"active": active})
|
|
}
|
|
}
|
|
|
|
func UpdateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
var cfg state.AgentConfig
|
|
if err := c.Bind(&cfg); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
if err := svc.UpdateAgentForUser(userID, name, &cfg); err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func DeleteAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
if err := svc.DeleteAgentForUser(userID, name); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func GetAgentConfigEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
cfg := svc.GetAgentConfigForUser(userID, name)
|
|
if cfg == nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
|
}
|
|
return c.JSON(http.StatusOK, cfg)
|
|
}
|
|
}
|
|
|
|
func PauseAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
if err := svc.PauseAgentForUser(userID, c.Param("name")); err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func ResumeAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
if err := svc.ResumeAgentForUser(userID, c.Param("name")); err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func GetAgentStatusEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
|
|
history := svc.GetAgentStatusForUser(userID, name)
|
|
if history == nil {
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"Name": name,
|
|
"History": []string{},
|
|
})
|
|
}
|
|
entries := []string{}
|
|
for i := len(history.Results()) - 1; i >= 0; i-- {
|
|
h := history.Results()[i]
|
|
actionName := ""
|
|
if h.ActionCurrentState.Action != nil {
|
|
actionName = h.ActionCurrentState.Action.Definition().Name.String()
|
|
}
|
|
entries = append(entries, fmt.Sprintf("Reasoning: %s\nAction taken: %s\nParameters: %+v\nResult: %s",
|
|
h.Reasoning,
|
|
actionName,
|
|
h.ActionCurrentState.Params,
|
|
h.Result))
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"Name": name,
|
|
"History": entries,
|
|
})
|
|
}
|
|
}
|
|
|
|
func GetAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
|
|
history, err := svc.GetAgentObservablesForUser(userID, name)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
if history == nil {
|
|
history = []json.RawMessage{}
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"Name": name,
|
|
"History": history,
|
|
})
|
|
}
|
|
}
|
|
|
|
func ClearAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
if err := svc.ClearAgentObservablesForUser(userID, name); err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"Name": name, "cleared": true})
|
|
}
|
|
}
|
|
|
|
func ChatWithAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
var payload struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if err := c.Bind(&payload); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request format"})
|
|
}
|
|
message := strings.TrimSpace(payload.Message)
|
|
if message == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Message cannot be empty"})
|
|
}
|
|
messageID, err := svc.ChatForUser(userID, name, message)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusAccepted, map[string]any{
|
|
"status": "message_received",
|
|
"message_id": messageID,
|
|
})
|
|
}
|
|
}
|
|
|
|
func AgentSSEEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
|
|
// Try local SSE manager first
|
|
manager := svc.GetSSEManagerForUser(userID, name)
|
|
if manager != nil {
|
|
return agentpool.HandleSSE(c, manager)
|
|
}
|
|
|
|
// Fall back to distributed EventBridge SSE
|
|
var bridge *agents.EventBridge
|
|
if d := app.Distributed(); d != nil {
|
|
bridge = d.AgentBridge
|
|
}
|
|
if bridge != nil {
|
|
return bridge.HandleSSE(c, name, userID)
|
|
}
|
|
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
|
}
|
|
}
|
|
|
|
func GetAgentConfigMetaEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
return c.JSON(http.StatusOK, svc.GetConfigMetaResult())
|
|
}
|
|
}
|
|
|
|
func ExportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := c.Param("name")
|
|
data, err := svc.ExportAgentForUser(userID, name)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.json", name))
|
|
return c.JSONBlob(http.StatusOK, data)
|
|
}
|
|
}
|
|
|
|
func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := getUserID(c)
|
|
|
|
// Try multipart form file first
|
|
file, err := c.FormFile("file")
|
|
if err == nil {
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to open file"})
|
|
}
|
|
defer src.Close()
|
|
data, err := io.ReadAll(src)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read file"})
|
|
}
|
|
if err := svc.ImportAgentForUser(userID, data); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// Try JSON body
|
|
var cfg state.AgentConfig
|
|
if err := json.NewDecoder(c.Request().Body).Decode(&cfg); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request: provide a file or JSON body"})
|
|
}
|
|
data, err := json.Marshal(&cfg)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
if err := svc.ImportAgentForUser(userID, data); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
// --- Actions ---
|
|
|
|
func ListActionsEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"actions": svc.ListAvailableActions(),
|
|
})
|
|
}
|
|
}
|
|
|
|
func GetActionDefinitionEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
actionName := c.Param("name")
|
|
|
|
var payload struct {
|
|
Config map[string]string `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {
|
|
payload.Config = map[string]string{}
|
|
}
|
|
|
|
def, err := svc.GetActionDefinition(actionName, payload.Config)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, def)
|
|
}
|
|
}
|
|
|
|
func ExecuteActionEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
actionName := c.Param("name")
|
|
|
|
var payload struct {
|
|
Config map[string]string `json:"config"`
|
|
Params coreTypes.ActionParams `json:"params"`
|
|
}
|
|
if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
}
|
|
|
|
result, err := svc.ExecuteAction(c.Request().Context(), actionName, payload.Config, payload.Params)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, result)
|
|
}
|
|
}
|
|
|
|
func AgentFileEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
|
|
requestedPath := c.QueryParam("path")
|
|
if requestedPath == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no file path specified"})
|
|
}
|
|
|
|
// Resolve the real path (follows symlinks, eliminates ..)
|
|
resolved, err := filepath.EvalSymlinks(filepath.Clean(requestedPath))
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
|
|
// Determine the allowed outputs directory — scoped to the user when auth is active
|
|
allowedDir := svc.OutputsDir()
|
|
user := auth.GetUser(c)
|
|
if user != nil {
|
|
allowedDir = filepath.Join(allowedDir, user.ID)
|
|
}
|
|
|
|
allowedDirResolved, _ := filepath.EvalSymlinks(filepath.Clean(allowedDir))
|
|
|
|
if utils.InTrustedRoot(resolved, allowedDirResolved) != nil {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
|
|
}
|
|
|
|
info, err := os.Stat(resolved)
|
|
if err != nil || info.IsDir() {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
|
|
return c.File(resolved)
|
|
}
|
|
}
|