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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
195590f5e4
commit
926bbde9fb
@@ -0,0 +1,235 @@
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package controllers
|
||||
|
||||
import "testing"
|
||||
|
||||
// La validación es lo único que separa "cargué mi cuenta y anda" de un error
|
||||
// del proveedor que nadie sabe leer.
|
||||
func TestValidacionDeCuentaPropia(t *testing.T) {
|
||||
casos := []struct {
|
||||
nombre string
|
||||
req aiConfigPropiaReq
|
||||
valida bool
|
||||
}{
|
||||
{"completa", aiConfigPropiaReq{Nombre: "Mi OpenAI", Provider: "openai", ApiKey: "sk-x", ModelName: "gpt-4o-mini"}, true},
|
||||
{"sin nombre", aiConfigPropiaReq{Provider: "openai", ApiKey: "sk-x", ModelName: "gpt-4o-mini"}, false},
|
||||
{"sin modelo", aiConfigPropiaReq{Nombre: "X", Provider: "openai", ApiKey: "sk-x"}, false},
|
||||
{"sin clave", aiConfigPropiaReq{Nombre: "X", Provider: "openai", ModelName: "gpt-4o-mini"}, false},
|
||||
|
||||
// Un proveedor inventado se manda igual y falla recién contra la API,
|
||||
// con un mensaje que no dice "escribiste mal el nombre".
|
||||
{"proveedor inventado", aiConfigPropiaReq{Nombre: "X", Provider: "chatgpt-5", ApiKey: "k", ModelName: "m"}, false},
|
||||
|
||||
// Ollama corre contra un servidor propio: no lleva clave, pero sin URL
|
||||
// no hay a dónde ir.
|
||||
{"ollama con url y sin clave", aiConfigPropiaReq{Nombre: "X", Provider: "ollama", ModelName: "llama3.1", BaseURL: "https://o.x/v1"}, true},
|
||||
{"ollama sin url", aiConfigPropiaReq{Nombre: "X", Provider: "ollama", ModelName: "llama3.1"}, false},
|
||||
|
||||
{"mayúsculas en el proveedor", aiConfigPropiaReq{Nombre: "X", Provider: "OpenAI", ApiKey: "k", ModelName: "m"}, true},
|
||||
}
|
||||
|
||||
for _, c := range casos {
|
||||
err := c.req.validar()
|
||||
if c.valida && err != nil {
|
||||
t.Errorf("%s: debería pasar, dio: %v", c.nombre, err)
|
||||
}
|
||||
if !c.valida && err == nil {
|
||||
t.Errorf("%s: debería rechazarse y pasó", c.nombre)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Las configs globales son del staff y las comparten todos los clientes. Que un
|
||||
// cliente pudiera editarlas sería cambiarle el proveedor de IA a todos los
|
||||
// demás; que pudiera borrarlas, dejarlos sin servicio.
|
||||
func TestLasConfigsGlobalesNoSonDelCliente(t *testing.T) {
|
||||
// accesoAiConfigPropia corta cuando TenantID es nil, antes de mirar
|
||||
// permisos. Se comprueba la condición porque el handler necesita base.
|
||||
var tenantNil *uint
|
||||
if tenantNil != nil {
|
||||
t.Fatal("una config global tiene TenantID nil")
|
||||
}
|
||||
esGlobal := tenantNil == nil
|
||||
if !esGlobal {
|
||||
t.Error("una config sin tenant tiene que tratarse como global e intocable")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user