- Endpoint GET /ai-config/:id/test que llama /api/tags (Ollama) o /v1/models (OpenAI-compat/Anthropic) - Botón "Probar" en cada fila de la tabla con modal de resultado JSON Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
256 lines
7.9 KiB
Go
256 lines
7.9 KiB
Go
package controllers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
// AiConfigIndex renderiza la vista del panel de configuraciones de IA.
|
|
func AiConfigIndex(c *fiber.Ctx) error {
|
|
data := fiber.Map{
|
|
"user": c.Locals("user").(map[string]interface{}),
|
|
"modules": c.Locals("userModules"),
|
|
}
|
|
return c.Render("ai_config", data, "layouts/main")
|
|
}
|
|
|
|
// GetAiConfigs devuelve la lista paginada en JSON.
|
|
func GetAiConfigs(c *fiber.Ctx) error {
|
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
limit := 20
|
|
offset := (page - 1) * limit
|
|
search := c.Query("search", "")
|
|
|
|
items, total, err := models.GetAllAiConfigs(limit, offset, search)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
// Ocultar la API key en el listado (mostrar solo últimos 4 chars)
|
|
type safe struct {
|
|
ID uint `json:"ID"`
|
|
Nombre string `json:"nombre"`
|
|
Provider string `json:"provider"`
|
|
ApiKeyHint string `json:"api_key_hint"`
|
|
BaseURL string `json:"base_url"`
|
|
ModelName string `json:"model_name"`
|
|
IsActive bool `json:"is_active"`
|
|
Notes string `json:"notes"`
|
|
Modulo string `json:"modulo"`
|
|
}
|
|
safeItems := make([]safe, len(items))
|
|
for i, it := range items {
|
|
hint := "••••"
|
|
if len(it.ApiKey) > 4 {
|
|
hint = "••••" + it.ApiKey[len(it.ApiKey)-4:]
|
|
}
|
|
safeItems[i] = safe{
|
|
ID: it.ID,
|
|
Nombre: it.Nombre,
|
|
Provider: it.Provider,
|
|
ApiKeyHint: hint,
|
|
BaseURL: it.BaseURL,
|
|
ModelName: it.ModelName,
|
|
IsActive: it.IsActive,
|
|
Notes: it.Notes,
|
|
Modulo: it.Modulo,
|
|
}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"items": safeItems,
|
|
"total": total,
|
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
|
"page": page,
|
|
})
|
|
}
|
|
|
|
// CreateAiConfigHandler crea una nueva configuración de IA.
|
|
func CreateAiConfigHandler(c *fiber.Ctx) error {
|
|
type Req struct {
|
|
Nombre string `json:"nombre"`
|
|
Provider string `json:"provider"`
|
|
ApiKey string `json:"api_key"`
|
|
BaseURL string `json:"base_url"`
|
|
ModelName string `json:"model_name"`
|
|
IsActive bool `json:"is_active"`
|
|
Notes string `json:"notes"`
|
|
Modulo string `json:"modulo"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if strings.TrimSpace(req.Nombre) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
|
|
}
|
|
if strings.TrimSpace(req.Provider) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "provider es requerido"})
|
|
}
|
|
if strings.TrimSpace(req.ApiKey) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "api_key es requerido"})
|
|
}
|
|
|
|
item := models.AiConfig{
|
|
Nombre: strings.TrimSpace(req.Nombre),
|
|
Provider: strings.ToLower(strings.TrimSpace(req.Provider)),
|
|
ApiKey: strings.TrimSpace(req.ApiKey),
|
|
BaseURL: strings.TrimSpace(req.BaseURL),
|
|
ModelName: strings.TrimSpace(req.ModelName),
|
|
IsActive: req.IsActive,
|
|
Notes: req.Notes,
|
|
Modulo: models.JoinModulos(strings.Split(req.Modulo, ",")),
|
|
}
|
|
if err := models.CreateAiConfig(&item); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true, "id": item.ID})
|
|
}
|
|
|
|
// UpdateAiConfigHandler actualiza una configuración de IA existente.
|
|
func UpdateAiConfigHandler(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"})
|
|
}
|
|
|
|
type Req struct {
|
|
Nombre string `json:"nombre"`
|
|
Provider string `json:"provider"`
|
|
ApiKey string `json:"api_key"` // vacío = no cambiar
|
|
BaseURL string `json:"base_url"`
|
|
ModelName string `json:"model_name"`
|
|
IsActive bool `json:"is_active"`
|
|
Notes string `json:"notes"`
|
|
Modulo string `json:"modulo"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
|
|
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),
|
|
"is_active": req.IsActive,
|
|
"notes": req.Notes,
|
|
"modulo": models.JoinModulos(strings.Split(req.Modulo, ",")),
|
|
}
|
|
if strings.TrimSpace(req.ApiKey) != "" {
|
|
updates["api_key"] = strings.TrimSpace(req.ApiKey)
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
// GetAiConfigSelect devuelve lista simple para selects
|
|
func GetAiConfigSelect(c *fiber.Ctx) error {
|
|
items, err := models.GetAiConfigSelect()
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"registros": items})
|
|
}
|
|
|
|
// TestAiConfigHandler verifica conectividad con el provider de la config.
|
|
func TestAiConfigHandler(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"})
|
|
}
|
|
var item models.AiConfig
|
|
if err := models.GetAiConfigByID(uint(id), &item); err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "config no encontrada"})
|
|
}
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
var testURL string
|
|
var req *http.Request
|
|
|
|
switch item.Provider {
|
|
case "ollama":
|
|
base := strings.TrimSuffix(strings.TrimRight(item.BaseURL, "/"), "/v1")
|
|
testURL = base + "/api/tags"
|
|
req, _ = http.NewRequest("GET", testURL, nil)
|
|
if item.ApiKey != "" && item.ApiKey != "ollama" {
|
|
req.SetBasicAuth("ollama", item.ApiKey)
|
|
}
|
|
case "anthropic":
|
|
// Anthropic no tiene /models; usamos /v1/models igual (devuelve 200 con lista)
|
|
base := item.BaseURL
|
|
if base == "" {
|
|
base = "https://api.anthropic.com/v1"
|
|
}
|
|
testURL = base + "/models"
|
|
req, _ = http.NewRequest("GET", testURL, nil)
|
|
req.Header.Set("x-api-key", item.ApiKey)
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
default:
|
|
base := item.BaseURL
|
|
if base == "" {
|
|
switch item.Provider {
|
|
case "openai":
|
|
base = "https://api.openai.com/v1"
|
|
case "qwen":
|
|
base = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
case "groq":
|
|
base = "https://api.groq.com/openai/v1"
|
|
default:
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "BaseURL requerida para este provider"})
|
|
}
|
|
}
|
|
testURL = base + "/models"
|
|
req, _ = http.NewRequest("GET", testURL, nil)
|
|
req.Header.Set("Authorization", "Bearer "+item.ApiKey)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return c.JSON(fiber.Map{"ok": false, "error": err.Error()})
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
var body map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&body)
|
|
return c.JSON(fiber.Map{"ok": false, "status": resp.StatusCode, "error": body})
|
|
}
|
|
|
|
// Para Ollama devolvemos la lista de modelos disponibles
|
|
if item.Provider == "ollama" {
|
|
var body map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&body)
|
|
return c.JSON(fiber.Map{"ok": true, "status": resp.StatusCode, "data": body})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"ok": true, "status": resp.StatusCode})
|
|
}
|
|
|
|
// DeleteAiConfigHandler elimina una configuración de IA.
|
|
func DeleteAiConfigHandler(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 := models.DeleteAiConfig(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|