Files
LocalAI/core/http/endpoints/studio/admin.go
T
Ettore Di Giacinto c70566d4f7 feat(connectors): keep two connections of one bot from answering twice
Two enabled connections can hold different token pairs of the same
upstream account, such as one Slack app registered twice. Both then
receive every message and both answer it.

The op "me" may now return accountId, a stable name of the upstream
account. Slack returns the workspace and bot user, Telegram the bot ID
and Discord the application ID. The host stores it on the binding when
a token is saved and, for a binding that has none, when its add-on runs.
An add-on that does not return it has no identity and is never compared.

When two enabled bindings of one owner and kind share an identity:

- the binding carries a duplicate marker with the code
  duplicate_connection, with a sentence in all eight languages of the
  connection texts;
- saving one of them is a 409 of that type until the request carries
  confirm_duplicate, and nothing is saved before;
- only the oldest enabled binding answers a message that has a platform
  message ID. The other logs one line and stays silent. The choice reads
  the stored bindings only, so every frontend makes the same one.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-05 11:36:23 +00:00

198 lines
7.5 KiB
Go

package studio
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/schema"
)
// The routes below are for administrators. They list what every user has set up
// (saved agents, triggers with their runs, connector bindings), each with its
// owner, and let the administrator switch a trigger or a connector off or delete
// any of the three. They never return a secret: a binding has no token, and an
// agent has no instructions. The route group carries the administrator gate.
// AdminListAgents lists the saved agents of every user.
// @Summary List the saved agents of every user (administrators).
// @Description Read-only view with the owner of each agent. The instructions are not returned.
// @Tags chats
// @Success 200 {object} schema.ChatsAdminAgentList "Every user's agents, by owner and name"
// @Failure 403 {object} schema.ErrorResponse "Not an administrator"
// @Router /api/chats/admin/agents [get]
func (h *Handlers) AdminListAgents(c echo.Context) error {
list, err := h.svc.AdminAgents(c.Request().Context())
if err != nil {
return failFor(c, err)
}
out := schema.ChatsAdminAgentList{Agents: make([]schema.ChatsAdminAgent, 0, len(list))}
for _, a := range list {
v := agentOut(a)
out.Agents = append(out.Agents, schema.ChatsAdminAgent{ID: v.ID, Owner: a.Principal, Name: v.Name, Description: v.Description,
Model: v.Model, MCPServers: v.MCPServers, Addons: v.Addons, Collections: v.Collections, Skills: v.Skills,
Created: v.Created, Updated: v.Updated})
}
return c.JSON(http.StatusOK, out)
}
// AdminDeleteAgent deletes the saved agent of any user.
// @Summary Delete a saved agent of any user (administrators).
// @Tags chats
// @Param id path string true "Agent ID"
// @Success 204 "Deleted"
// @Failure 404 {object} schema.ErrorResponse "No such agent"
// @Router /api/chats/admin/agents/{id} [delete]
func (h *Handlers) AdminDeleteAgent(c echo.Context) error {
if err := h.svc.AdminDeleteAgent(c.Request().Context(), c.Param("id")); err != nil {
return failFor(c, err)
}
return c.NoContent(http.StatusNoContent)
}
// AdminListTriggers lists the triggers of every user.
// @Summary List the triggers of every user (administrators).
// @Description Read-only view with the owner of each trigger and its last run.
// @Tags chats
// @Success 200 {object} schema.ChatsAdminTriggerList "Every user's triggers, by owner and name"
// @Failure 403 {object} schema.ErrorResponse "Not an administrator"
// @Router /api/chats/admin/triggers [get]
func (h *Handlers) AdminListTriggers(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
ctx := c.Request().Context()
list, err := host.AdminTriggers(ctx)
if err != nil {
return failTrigger(c, err)
}
out := schema.ChatsAdminTriggerList{Triggers: make([]schema.ChatsAdminTrigger, 0, len(list))}
for _, t := range list {
out.Triggers = append(out.Triggers, schema.ChatsAdminTrigger{Owner: t.Owner, ChatsTrigger: triggerOut(ctx, host, t, true)})
}
return c.JSON(http.StatusOK, out)
}
// AdminTriggerRuns lists the runs of a trigger of any user.
// @Summary List the runs of a trigger of any user (administrators).
// @Tags chats
// @Param id path string true "Trigger ID"
// @Param limit query int false "How many runs, newest first (default 20, at most 200)"
// @Success 200 {object} schema.ChatsTriggerRunList "The runs, newest first"
// @Failure 404 {object} schema.ErrorResponse "No such trigger"
// @Router /api/chats/admin/triggers/{id}/runs [get]
func (h *Handlers) AdminTriggerRuns(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
t, runs, err := host.AdminTriggerRuns(c.Request().Context(), c.Param("id"), intParam(c, "limit", 20, 200))
if err != nil {
return failTrigger(c, err)
}
out := schema.ChatsTriggerRunList{Runs: make([]schema.ChatsTriggerRun, 0, len(runs))}
for _, r := range runs {
out.Runs = append(out.Runs, runOut(host, t.Owner, r))
}
return c.JSON(http.StatusOK, out)
}
// AdminDisableTrigger switches a trigger of any user off.
// @Summary Switch off a trigger of any user (administrators).
// @Tags chats
// @Param id path string true "Trigger ID"
// @Success 200 {object} schema.ChatsAdminTrigger "The trigger, now off"
// @Failure 404 {object} schema.ErrorResponse "No such trigger"
// @Router /api/chats/admin/triggers/{id}/disable [post]
func (h *Handlers) AdminDisableTrigger(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
ctx := c.Request().Context()
t, err := host.AdminDisableTrigger(ctx, c.Param("id"))
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusOK, schema.ChatsAdminTrigger{Owner: t.Owner, ChatsTrigger: triggerOut(ctx, host, t, true)})
}
// AdminDeleteTrigger deletes a trigger of any user.
// @Summary Delete a trigger of any user (administrators).
// @Tags chats
// @Param id path string true "Trigger ID"
// @Success 204 "Deleted"
// @Failure 404 {object} schema.ErrorResponse "No such trigger"
// @Router /api/chats/admin/triggers/{id} [delete]
func (h *Handlers) AdminDeleteTrigger(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
if err := host.AdminDeleteTrigger(c.Request().Context(), c.Param("id")); err != nil {
return failTrigger(c, err)
}
return c.NoContent(http.StatusNoContent)
}
// AdminListConnectors lists the connector bindings of every user.
// @Summary List the connector bindings of every user (administrators).
// @Description Read-only view with the owner of each binding. The bot token is never returned.
// @Tags chats
// @Success 200 {object} schema.ChatsAdminConnectorList "Every user's bindings, by name"
// @Failure 403 {object} schema.ErrorResponse "Not an administrator"
// @Router /api/chats/admin/connectors [get]
func (h *Handlers) AdminListConnectors(c echo.Context) error {
host, ok := h.connectorHost(c)
if !ok {
return nil
}
list, err := host.AdminConnectors(c.Request().Context())
if err != nil {
return failConnector(c, err)
}
out := schema.ChatsAdminConnectorList{Connectors: make([]schema.ChatsAdminConnector, 0, len(list))}
for _, b := range list {
out.Connectors = append(out.Connectors, schema.ChatsAdminConnector{Owner: b.Owner, ChatsConnector: connectorOut(b.ConnectorInfo, connectorLang(c))})
}
return c.JSON(http.StatusOK, out)
}
// AdminDisableConnector switches a connector binding of any user off.
// @Summary Switch off a connector binding of any user (administrators).
// @Tags chats
// @Param id path string true "Binding ID"
// @Success 200 {object} schema.ChatsConnector "The binding, now off"
// @Failure 404 {object} schema.ErrorResponse "No such binding"
// @Router /api/chats/admin/connectors/{id}/disable [post]
func (h *Handlers) AdminDisableConnector(c echo.Context) error {
host, ok := h.connectorHost(c)
if !ok {
return nil
}
b, err := host.AdminDisableConnector(c.Request().Context(), c.Param("id"))
if err != nil {
return failConnector(c, err)
}
return c.JSON(http.StatusOK, connectorOut(b, connectorLang(c)))
}
// AdminDeleteConnector deletes a connector binding of any user.
// @Summary Delete a connector binding of any user (administrators).
// @Tags chats
// @Param id path string true "Binding ID"
// @Success 204 "Deleted"
// @Failure 404 {object} schema.ErrorResponse "No such binding"
// @Router /api/chats/admin/connectors/{id} [delete]
func (h *Handlers) AdminDeleteConnector(c echo.Context) error {
host, ok := h.connectorHost(c)
if !ok {
return nil
}
if err := host.AdminDeleteConnector(c.Request().Context(), c.Param("id")); err != nil {
return failConnector(c, err)
}
return c.NoContent(http.StatusNoContent)
}