Files
soft_usite/rest/controllers/umind_ai_propia_controller.go
T
Lizandro GuarnizoandClaude Opus 5 926bbde9fb feat(umind): que el cliente conecte su propia cuenta de IA
El modelo soportaba una config de IA por espacio desde hace varias fases, pero
no había forma de cargarla: ni el cliente desde el portal, ni el staff por él.
Quedaba como una columna que solo se podía llenar tocando la base a mano.

Ahora hay una pantalla — "Tu IA", desde la lista de agentes — donde se conecta
una cuenta de OpenAI, Anthropic, Gemini, Groq, DeepSeek, Qwen u Ollama. Con el
botón "Probar", que manda una consulta real: una clave vencida se descubre ahí
y no cuando un cliente escribe y no le contestan.

El aislamiento es la parte delicada, y va en tres capas:

Las configs globales son del staff. Un cliente puede usarlas —le aparecen en el
selector— pero no editarlas ni borrarlas; el acceso corta antes de mirar
permisos, así que ni siquiera se le confirma que existen. Sin eso, cualquiera
podría cambiarle el proveedor de IA a todos los demás o dejarlos sin servicio.

El tenant sale del alcance ya validado, nunca del body. Si viniera del cuerpo
de la petición, bastaría con cambiar el número para colgarle una config a otro
cliente, y con ella una clave que no es suya.

El tenant no se puede mover en una edición, por lo mismo al revés: sería
regalarle la propia.

La clave se guarda cifrada y no vuelve nunca al navegador — ni al dueño. Solo
sus últimos cuatro caracteres, que alcanzan para reconocer cuál cargó.

Borrar está bloqueado si hay agentes usándola: si no, quedarían apuntando a
algo inexistente y cayendo al proveedor global sin que nadie se entere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:08:43 -05:00

236 lines
8.6 KiB
Go

package controllers
import (
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
// ─── IA propia del cliente ───────────────────────────────────────────────────
//
// Un cliente puede traer su propia cuenta de OpenAI, Anthropic o Gemini en vez
// de consumir la nuestra. La config queda atada a su espacio (TenantID) y solo
// él la ve.
//
// Regla que atraviesa todo este archivo: las configs globales (TenantID nil)
// son del staff y ningún cliente las toca. Puede usarlas —aparecen en su
// selector— pero no editarlas ni borrarlas. Sin esa separación, un cliente
// podría cambiarle la clave de IA a todos los demás.
// aiConfigPropiaReq es lo que manda la pantalla. La clave viaja solo cuando se
// carga o se cambia: en blanco significa "dejá la que ya está".
type aiConfigPropiaReq struct {
TenantID uint `json:"tenant_id"`
Nombre string `json:"nombre"`
Provider string `json:"provider"`
ApiKey string `json:"api_key"`
BaseURL string `json:"base_url"`
ModelName string `json:"model_name"`
}
// proveedoresPermitidos acota lo que se puede cargar. Sin esta lista, un
// provider mal escrito termina en un error del proveedor que no se parece en
// nada a "escribiste mal el nombre".
var proveedoresPermitidos = map[string]bool{
"openai": true, "anthropic": true, "gemini": true,
"groq": true, "qwen": true, "deepseek": true, "ollama": true,
}
func (r aiConfigPropiaReq) validar() error {
if strings.TrimSpace(r.Nombre) == "" {
return fiber.NewError(fiber.StatusBadRequest, "poné un nombre para reconocerla, ej: \"Mi cuenta de OpenAI\"")
}
p := strings.ToLower(strings.TrimSpace(r.Provider))
if !proveedoresPermitidos[p] {
return fiber.NewError(fiber.StatusBadRequest, "proveedor no soportado: elegí OpenAI, Anthropic, Gemini, Groq, Qwen, DeepSeek u Ollama")
}
if strings.TrimSpace(r.ModelName) == "" {
return fiber.NewError(fiber.StatusBadRequest, "falta el modelo, ej: gpt-4o-mini o claude-sonnet-4")
}
// Ollama es el único que corre sin clave, contra un servidor propio.
if p != "ollama" && strings.TrimSpace(r.ApiKey) == "" {
return fiber.NewError(fiber.StatusBadRequest, "falta la clave de API de tu cuenta")
}
if p == "ollama" && strings.TrimSpace(r.BaseURL) == "" {
return fiber.NewError(fiber.StatusBadRequest, "Ollama necesita la URL de tu servidor, ej: https://ollama.tudominio.com/v1")
}
return nil
}
// accesoAiConfigPropia valida que el que llama pueda tocar esa config.
// Devuelve la config ya cargada.
func accesoAiConfigPropia(c *fiber.Ctx, id uint) (*models.AiConfig, error) {
var cfg models.AiConfig
if err := models.GetAiConfigByID(id, &cfg); err != nil {
return nil, errSinAcceso(c)
}
// Una config sin tenant es del staff. Ni siquiera se le confirma al cliente
// que existe.
if cfg.TenantID == nil {
return nil, errSinAcceso(c)
}
if !middlewares.UmindPuedeVerTenant(c, *cfg.TenantID) {
return nil, errSinAcceso(c)
}
return &cfg, nil
}
// ListarAiConfigsPropiasHandler devuelve solo las configs del cliente, con el
// detalle para administrarlas. Las globales quedan afuera: se usan pero no se
// gestionan desde acá.
// GET /umind/ai-propia?tenant_id=N
func ListarAiConfigsPropiasHandler(c *fiber.Ctx) error {
tenantID, _ := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
items, err := models.GetAiConfigsPorTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]fiber.Map, len(items))
for i, it := range items {
// La clave nunca vuelve, ni al dueño: solo su terminación, que alcanza
// para reconocer cuál cargó.
clave := it.ClaveEnClaro()
pista := "••••"
if len(clave) > 4 {
pista = "••••" + clave[len(clave)-4:]
}
out[i] = fiber.Map{
"ID": it.ID, "nombre": it.Nombre, "provider": it.Provider,
"model_name": it.ModelName, "base_url": it.BaseURL,
"api_key_pista": pista, "is_active": it.IsActive,
}
}
return c.JSON(fiber.Map{"items": out})
}
// CrearAiConfigPropiaHandler carga la cuenta de IA del cliente.
// POST /umind/ai-propia
func CrearAiConfigPropiaHandler(c *fiber.Ctx) error {
var req aiConfigPropiaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if err := accesoTenant(c, req.TenantID); err != nil {
return err
}
if err := req.validar(); err != nil {
return err
}
tenantID := req.TenantID
cfg := &models.AiConfig{
Nombre: strings.TrimSpace(req.Nombre),
Provider: strings.ToLower(strings.TrimSpace(req.Provider)),
ApiKey: models.CifrarClaveAi(strings.TrimSpace(req.ApiKey)),
BaseURL: strings.TrimSpace(req.BaseURL),
ModelName: strings.TrimSpace(req.ModelName),
IsActive: true,
// El tenant sale del alcance validado, nunca de lo que mande el body
// suelto: si no, bastaría con cambiar el número para colgarle una
// config a otro cliente.
TenantID: &tenantID,
// Sin módulo: es de este espacio y nada más. Ponerle uno la metería en
// el pozo de configs globales que usa el resto del sistema.
Modulo: "",
}
if err := models.CreateAiConfig(cfg); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": cfg.ID})
}
// ActualizarAiConfigPropiaHandler edita una config del cliente.
// PUT /umind/ai-propia/:id
func ActualizarAiConfigPropiaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAiConfigPropia(c, uint(id)); err != nil {
return err
}
var req aiConfigPropiaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
// En edición la clave puede venir vacía: es "no la cambies".
if strings.TrimSpace(req.ApiKey) == "" {
req.ApiKey = "sin-cambio"
}
if err := req.validar(); err != nil {
return err
}
updates := map[string]interface{}{
"nombre": strings.TrimSpace(req.Nombre),
"provider": strings.ToLower(strings.TrimSpace(req.Provider)),
"base_url": strings.TrimSpace(req.BaseURL),
"model_name": strings.TrimSpace(req.ModelName),
}
if req.ApiKey != "sin-cambio" {
updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey))
}
// tenant_id no se toca nunca: mover una config de espacio es regalarle a
// otro cliente una clave que no es suya.
if err := models.UpdateAiConfig(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// BorrarAiConfigPropiaHandler elimina la cuenta de IA del cliente.
// DELETE /umind/ai-propia/:id
func BorrarAiConfigPropiaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAiConfigPropia(c, uint(id)); err != nil {
return err
}
// Los agentes que la estaban usando quedan apuntando a una config que ya
// no existe y caerían al fallback global sin avisar. Mejor frenar y que la
// persona decida.
usando, err := models.ContarAgentesConAiConfig(uint(id))
if err == nil && usando > 0 {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": "hay agentes usando esta cuenta de IA: cambiales el modelo antes de borrarla",
})
}
if err := models.DeleteAiConfig(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ProbarAiConfigPropiaHandler manda una consulta real a la cuenta del cliente.
// Es la diferencia entre enterarse ahora o cuando un cliente real no recibe
// respuesta.
// POST /umind/ai-propia/:id/probar
func ProbarAiConfigPropiaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
cfg, err := accesoAiConfigPropia(c, uint(id))
if err != nil {
return err
}
respuesta, err := services.ProbarAiConfig(cfg)
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "respuesta": respuesta})
}