Auditando el aislamiento apareció el agujero al revés del que se buscaba: no un cliente leyendo datos de otro, sino la cuenta de IA de un cliente pagando trabajo nuestro. GetAiConfigForService recorre las configs activas y devuelve la primera sin módulo asignado. Las configs de cliente no llevan módulo — ninguna lo lleva, es parte del diseño — así que caían justo en ese fallback. Con un cliente que hubiera conectado su cuenta, su clave terminaba clasificando correos de soporte, importando plantillas o atendiendo la vCard. Ninguno de los dos se enteraba: la respuesta llegaba igual y la factura le llegaba a él. Todos los resolvedores globales filtran ahora tenant_id IS NULL. Un test lo verifica sobre el código de cada uno, porque son consultas a base y acá no hay una. El de embeddings además no podía ser de cliente por otra razón: los vectores de todos los agentes tienen que salir del mismo modelo o la similitud coseno entre ellos no significa nada. Un cliente con su propio modelo de embeddings rompía su propia búsqueda sin un solo error visible. Del alcance entre clientes, que era lo que se auditaba: los 39 handlers de uMind validan, y el CRUD de espacios y planes ni siquiera se monta en las rutas del portal. Lo que faltaba era prueba: UmindScopeDe distingue "staff" de "cliente sin espacios" por nil contra slice vacío, y esa diferencia no tenía un solo test. Ahora la cubre uno que además falla si se invierte el fail-closed de una ruta sin scope — probado inyectando las dos fugas. Y dos cosas que quedaban colgando: En /app/ai-config toda config de cliente se mostraba como "Global", que es justo lo que no es. Ahora dice de qué espacio es, por nombre. El consumo de una cuenta propia se registraba con el costo del plan. Se sigue midiendo —el cliente quiere ver cuánto usa su asistente— pero con costo cero y marcado como cuenta propia: cobrarlo también sería cobrar dos veces lo mismo. En la pantalla de consumo aparece "va por tu cuenta de IA" en vez de un "$0" que parecería un error. El OCR y la transcripción siguen costando: son servicios nuestros, los use quien los use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
9.9 KiB
Go
295 lines
9.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"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
)
|
|
|
|
// 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"`
|
|
EsAgenteBot bool `json:"es_agente_bot"`
|
|
TelegramConfigID *uint `json:"telegram_config_id"`
|
|
TenantID *uint `json:"tenant_id"`
|
|
TenantNombre string `json:"tenant_nombre"`
|
|
}
|
|
// Los nombres de los espacios, en una consulta: sin esto la pantalla
|
|
// mostraría "tenant 7" y nadie sabe de quién es el 7.
|
|
nombresTenant := models.NombresDeTenantsUmind()
|
|
|
|
safeItems := make([]safe, len(items))
|
|
for i, it := range items {
|
|
// El hint sale de la clave en claro: sobre el ciphertext mostraría
|
|
// los últimos 4 del hex, que no le sirven a nadie para reconocerla.
|
|
clave := it.ClaveEnClaro()
|
|
hint := "••••"
|
|
if len(clave) > 4 {
|
|
hint = "••••" + clave[len(clave)-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,
|
|
EsAgenteBot: it.EsAgenteBot,
|
|
TelegramConfigID: it.TelegramConfigID,
|
|
TenantID: it.TenantID,
|
|
}
|
|
if it.TenantID != nil {
|
|
safeItems[i].TenantNombre = nombresTenant[*it.TenantID]
|
|
}
|
|
}
|
|
|
|
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"`
|
|
EsAgenteBot bool `json:"es_agente_bot"`
|
|
TelegramConfigID *uint `json:"telegram_config_id"`
|
|
}
|
|
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: models.CifrarClaveAi(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, ",")),
|
|
EsAgenteBot: req.EsAgenteBot,
|
|
TelegramConfigID: req.TelegramConfigID,
|
|
}
|
|
if err := models.CreateAiConfig(&item); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if item.EsAgenteBot {
|
|
models.QuitarAgenteBotSalvo(item.ID)
|
|
}
|
|
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"`
|
|
EsAgenteBot bool `json:"es_agente_bot"`
|
|
TelegramConfigID *uint `json:"telegram_config_id"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
|
|
// Qué campos vinieron de verdad. Sin esto, un formulario que no manda
|
|
// es_agente_bot lo guardaba en false: editar cualquier config desde el
|
|
// panel dejaba al bot de Telegram y al chat del dashboard sin cerebro.
|
|
var presentes map[string]json.RawMessage
|
|
_ = json.Unmarshal(c.Body(), &presentes)
|
|
vino := func(campo string) bool { _, ok := presentes[campo]; return ok }
|
|
|
|
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 vino("es_agente_bot") {
|
|
updates["es_agente_bot"] = req.EsAgenteBot
|
|
}
|
|
if vino("telegram_config_id") {
|
|
updates["telegram_config_id"] = req.TelegramConfigID
|
|
}
|
|
if strings.TrimSpace(req.ApiKey) != "" {
|
|
updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey))
|
|
}
|
|
|
|
if req.EsAgenteBot && vino("es_agente_bot") {
|
|
models.QuitarAgenteBotSalvo(uint(id))
|
|
}
|
|
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
|
|
clave := item.ClaveEnClaro()
|
|
|
|
switch item.Provider {
|
|
case "ollama":
|
|
base := strings.TrimSuffix(strings.TrimRight(item.BaseURL, "/"), "/v1")
|
|
testURL = base + "/api/tags"
|
|
req, _ = http.NewRequest("GET", testURL, nil)
|
|
if clave != "" && clave != "ollama" {
|
|
req.SetBasicAuth("ollama", clave)
|
|
}
|
|
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", clave)
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
default:
|
|
// Mismo mapeo proveedor→URL que usa el chat/embeddings real
|
|
// (services.ProviderDefaultURL) — antes esta prueba tenía su propia
|
|
// copia desactualizada que no conocía proveedores nuevos como Gemini,
|
|
// así que "funcionaba" en el chat pero la prueba de conexión fallaba.
|
|
base := item.BaseURL
|
|
if base == "" {
|
|
base = services.ProviderDefaultURL(item.Provider)
|
|
}
|
|
testURL = base + "/models"
|
|
req, _ = http.NewRequest("GET", testURL, nil)
|
|
req.Header.Set("Authorization", "Bearer "+clave)
|
|
}
|
|
|
|
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})
|
|
}
|