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:
Lizandro Guarnizo
2026-08-20 23:08:43 -05:00
co-authored by Claude Opus 5
parent 195590f5e4
commit 926bbde9fb
13 changed files with 636 additions and 30 deletions
+2
View File
@@ -3,6 +3,7 @@ import Home from './views/Home.vue'
import TenantAgentes from './views/TenantAgentes.vue'
import AgenteDetail from './views/AgenteDetail.vue'
import Uso from './views/Uso.vue'
import AiPropia from './views/AiPropia.vue'
import { contexto } from './lib/contexto.js'
const router = createRouter({
@@ -11,6 +12,7 @@ const router = createRouter({
{ path: '/', name: 'home', component: Home },
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
{ path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true },
{ path: '/tenants/:id/ia', name: 'ai-propia', component: AiPropia, props: true },
{
path: '/tenants/:tenantId/agentes/:agenteId',
name: 'agente-detail',
+255
View File
@@ -0,0 +1,255 @@
<script setup>
import { ref, watch } from 'vue'
import { api } from '../lib/api.js'
import { apiUmind } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
const props = defineProps({ id: { type: String, required: true } })
const items = ref([])
const cargando = ref(true)
const error = ref('')
const showForm = ref(false)
const editando = ref(null)
const guardando = ref(false)
const probando = ref(null)
const resultado = ref({})
// Los proveedores que acepta el backend. El texto de ayuda de cada uno importa
// más que la lista: nadie recuerda de memoria el nombre exacto de un modelo.
const proveedores = [
{ valor: 'openai', nombre: 'OpenAI', modelo: 'gpt-4o-mini', donde: 'platform.openai.com/api-keys' },
{ valor: 'anthropic', nombre: 'Anthropic (Claude)', modelo: 'claude-sonnet-4-20250514', donde: 'console.anthropic.com' },
{ valor: 'gemini', nombre: 'Google Gemini', modelo: 'gemini-2.0-flash', donde: 'aistudio.google.com/apikey' },
{ valor: 'groq', nombre: 'Groq', modelo: 'llama-3.3-70b-versatile', donde: 'console.groq.com/keys' },
{ valor: 'deepseek', nombre: 'DeepSeek', modelo: 'deepseek-chat', donde: 'platform.deepseek.com' },
{ valor: 'qwen', nombre: 'Qwen (Alibaba)', modelo: 'qwen2.5-72b-instruct', donde: 'dashscope.console.aliyun.com' },
{ valor: 'ollama', nombre: 'Ollama (tu servidor)', modelo: 'llama3.1', donde: '' },
]
function vacio() {
return { nombre: '', provider: 'openai', api_key: '', base_url: '', model_name: '' }
}
const form = ref(vacio())
function proveedorDe(valor) {
return proveedores.find((p) => p.valor === valor) || proveedores[0]
}
async function cargar() {
cargando.value = true
error.value = ''
try {
const r = await api.get(apiUmind(`/umind/ai-propia?tenant_id=${props.id}`))
items.value = r.items || []
} catch (e) {
error.value = e.message
} finally {
cargando.value = false
}
}
watch(() => props.id, cargar, { immediate: true })
function nueva() {
editando.value = null
form.value = vacio()
showForm.value = true
}
function editar(c) {
editando.value = c
// La clave no vuelve del servidor: se deja en blanco y solo se manda si la
// persona escribe una nueva.
form.value = {
nombre: c.nombre,
provider: c.provider,
api_key: '',
base_url: c.base_url || '',
model_name: c.model_name || '',
}
showForm.value = true
}
async function guardar() {
guardando.value = true
error.value = ''
try {
const cuerpo = { tenant_id: Number(props.id), ...form.value }
if (editando.value) {
await api.put(apiUmind(`/umind/ai-propia/${editando.value.ID}`), cuerpo)
} else {
await api.post(apiUmind('/umind/ai-propia'), cuerpo)
}
showForm.value = false
await cargar()
} catch (e) {
error.value = e.message
} finally {
guardando.value = false
}
}
// Probar contra el proveedor de verdad. Una clave vencida no se nota hasta que
// alguien escribe: esto lo adelanta.
async function probar(c) {
probando.value = c.ID
resultado.value = { ...resultado.value, [c.ID]: null }
try {
const r = await api.post(apiUmind(`/umind/ai-propia/${c.ID}/probar`), {})
resultado.value = { ...resultado.value, [c.ID]: { ok: true, texto: r.respuesta } }
} catch (e) {
resultado.value = { ...resultado.value, [c.ID]: { ok: false, texto: e.message } }
} finally {
probando.value = null
}
}
async function borrar(c) {
if (!confirm(`¿Eliminar "${c.nombre}"?`)) return
error.value = ''
try {
await api.del(apiUmind(`/umind/ai-propia/${c.ID}`))
await cargar()
} catch (e) {
error.value = e.message
}
}
</script>
<template>
<div>
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<router-link :to="`/tenants/${id}`" class="text-sm text-tenue hover:text-texto"> Agentes</router-link>
<h1 class="text-xl font-semibold text-texto mt-1">Tu cuenta de IA</h1>
<p class="text-sm text-tenue mt-1 max-w-xl">
Podés usar tu propia cuenta de OpenAI, Claude o Gemini en vez de la nuestra.
El consumo se factura directo con el proveedor y no pasa por tu plan.
</p>
</div>
<button class="btn-primary shrink-0" @click="nueva">+ Conectar cuenta</button>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div v-if="cargando" class="text-sm text-tenue">Cargando</div>
<UiEmptyState
v-else-if="items.length === 0"
class="card"
titulo="Estás usando la IA de uMind"
detalle="Funciona sin que hagas nada. Conectá tu propia cuenta solo si querés facturar el consumo por tu lado o usar un modelo puntual."
>
<button class="btn-primary" @click="nueva">Conectar mi cuenta</button>
</UiEmptyState>
<div v-else class="card divide-y divide-borde">
<div v-for="c in items" :key="c.ID" class="p-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-sm text-texto font-medium">{{ c.nombre }}</div>
<div class="text-xs text-tenue mt-0.5">
{{ proveedorDe(c.provider).nombre }} · {{ c.model_name }} ·
<span class="font-mono">{{ c.api_key_pista }}</span>
</div>
<p
v-if="resultado[c.ID]"
class="text-xs mt-1.5"
:class="resultado[c.ID].ok ? 'text-brand' : 'text-red-600 dark:text-red-400'"
>
{{ resultado[c.ID].ok ? `Responde bien — dijo "${resultado[c.ID].texto}"` : resultado[c.ID].texto }}
</p>
</div>
<div class="flex items-center gap-3 shrink-0 text-sm">
<button
class="text-brand hover:underline disabled:opacity-40"
:disabled="probando === c.ID"
@click="probar(c)"
>{{ probando === c.ID ? 'Probando…' : 'Probar' }}</button>
<button class="text-tenue hover:text-texto" @click="editar(c)">Editar</button>
<button class="text-red-500 hover:text-red-700" @click="borrar(c)">Eliminar</button>
</div>
</div>
</div>
</div>
<p v-if="items.length" class="text-xs text-tenue mt-3">
Para que un agente la use, elegila en su campo <strong>Config de IA</strong>.
</p>
<!-- Alta / edición -->
<div
v-if="showForm"
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="showForm = false"
>
<div class="card w-full max-w-lg p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="font-semibold text-texto mb-4">
{{ editando ? 'Editar cuenta de IA' : 'Conectar tu cuenta de IA' }}
</h2>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="label">Nombre</label>
<input v-model="form.nombre" required placeholder="ej: Mi cuenta de OpenAI" class="input" />
</div>
<div>
<label class="label">Proveedor</label>
<select v-model="form.provider" class="input">
<option v-for="p in proveedores" :key="p.valor" :value="p.valor">{{ p.nombre }}</option>
</select>
<p v-if="proveedorDe(form.provider).donde" class="text-xs text-tenue mt-1">
La clave se saca de <span class="font-mono">{{ proveedorDe(form.provider).donde }}</span>
</p>
</div>
<div>
<label class="label">Modelo</label>
<input
v-model="form.model_name"
required
:placeholder="`ej: ${proveedorDe(form.provider).modelo}`"
class="input font-mono"
/>
</div>
<div v-if="form.provider !== 'ollama'">
<label class="label">
Clave de API
<span v-if="editando" class="text-tenue font-normal"> dejala vacía para no cambiarla</span>
</label>
<input
v-model="form.api_key"
type="password"
autocomplete="new-password"
:required="!editando"
placeholder="sk-..."
class="input font-mono"
/>
<p class="text-xs text-tenue mt-1">Se guarda cifrada y no se vuelve a mostrar.</p>
</div>
<div v-if="form.provider === 'ollama'">
<label class="label">URL de tu servidor</label>
<input
v-model="form.base_url"
required
placeholder="https://ollama.tudominio.com/v1"
class="input font-mono"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showForm = false">Cancelar</button>
<button type="submit" class="btn-primary" :disabled="guardando">
{{ guardando ? 'Guardando…' : 'Guardar' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
+2 -1
View File
@@ -167,7 +167,8 @@ watch(() => props.id, cargar, { immediate: true })
<h1 class="text-xl font-semibold text-texto">{{ tenant.nombre }}</h1>
<p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
</div>
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost">📊 Consumo</router-link>
<router-link :to="`/tenants/${tenantId ?? id}/ia`" class="btn-ghost">Tu IA</router-link>
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost">📊 Consumo</router-link>
</div>
</div>
+21
View File
@@ -156,6 +156,27 @@ func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) {
return items, nil
}
// GetAiConfigsPorTenant lista las configs que cargó un cliente para su espacio.
// No incluye las globales del staff a propósito: el cliente las puede usar,
// pero no administrarlas, y mezclarlas acá invitaría a intentarlo.
func GetAiConfigsPorTenant(tenantID uint) ([]AiConfig, error) {
var items []AiConfig
err := app.Http.Database.DB.
Where("tenant_id = ?", tenantID).
Order("nombre ASC").Find(&items).Error
return items, err
}
// ContarAgentesConAiConfig dice cuántos agentes dependen de una config. Sin
// esto, borrarla los dejaría apuntando a algo inexistente y cayendo al
// proveedor global sin que nadie se entere.
func ContarAgentesConAiConfig(aiConfigID uint) (int64, error) {
var n int64
err := app.Http.Database.DB.Model(&UmindAgente{}).
Where("ai_config_id = ?", aiConfigID).Count(&n).Error
return n, err
}
// QuitarAgenteBotSalvo deja como cerebro del agente solo a la config indicada.
// GetAgenteBotAiConfig hace First() sobre es_agente_bot: con dos marcadas, cuál
// gana depende del orden de la tabla, que no es una forma de elegir nada.
+29
View File
@@ -38,3 +38,32 @@ func CompletarTextoIA(servicio, sistema, usuario string) (string, error) {
}
return texto, nil
}
// ProbarAiConfig manda una consulta mínima a una configuración concreta y
// devuelve lo que contestó.
//
// A diferencia de CompletarTextoIA, no resuelve la config por módulo: recibe la
// que hay que probar. Es lo que necesita alguien que acaba de cargar su cuenta
// y quiere saber si la clave sirve, antes de que lo descubra un cliente real.
func ProbarAiConfig(cfg *models.AiConfig) (string, error) {
if cfg == nil {
return "", fmt.Errorf("no hay configuración que probar")
}
if strings.TrimSpace(cfg.ModelName) == "" {
return "", fmt.Errorf("falta el modelo en esta configuración")
}
msg, _, err := callAI(cfg, []agentMessage{
{Role: "system", Content: "Respondé con una sola palabra."},
{Role: "user", Content: "Decí \"listo\" si me estás leyendo."},
}, nil)
if err != nil {
return "", fmt.Errorf("%s rechazó la consulta: %w", cfg.Provider, err)
}
texto, _ := msg.Content.(string)
if strings.TrimSpace(texto) == "" {
return "", fmt.Errorf("%s respondió, pero sin texto: revisá el modelo", cfg.Provider)
}
return strings.TrimSpace(texto), nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-Cv_aH8gm.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-Bk6phDz-.css">
<script type="module" crossorigin src="/orchestrator/assets/index-YAZk1p08.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DjoN7J4s.css">
</head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la
@@ -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})
}
+55
View File
@@ -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")
}
}
+8
View File
@@ -69,5 +69,13 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
g.Get("/umind/eventos", r(controllers.GetUmindEventosHandler)...)
g.Get("/umind/uso", r(controllers.GetUmindUsoHandler)...)
g.Get("/umind/ai-configs", r(controllers.GetUmindAiConfigsHandler)...)
// La cuenta de IA propia del cliente: la carga él, queda atada a su espacio
// y nadie más la ve. Las configs globales del staff no se tocan desde acá.
g.Get("/umind/ai-propia", r(controllers.ListarAiConfigsPropiasHandler)...)
g.Post("/umind/ai-propia", w(controllers.CrearAiConfigPropiaHandler)...)
g.Put("/umind/ai-propia/:id", w(controllers.ActualizarAiConfigPropiaHandler)...)
g.Delete("/umind/ai-propia/:id", w(controllers.BorrarAiConfigPropiaHandler)...)
g.Post("/umind/ai-propia/:id/probar", w(controllers.ProbarAiConfigPropiaHandler)...)
g.Get("/umind/reporte.xlsx", r(controllers.GetUmindReporteHandler)...)
}