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}) }