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>
236 lines
8.6 KiB
Go
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})
|
|
}
|