feat: version 4.1.0 -- Consumo de IA por usuario en Configuracion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 17:48:49 -05:00
parent f497da836c
commit 120e54283e
17 changed files with 158 additions and 5 deletions
@@ -0,0 +1,15 @@
-- Consumo de la IA por llamada (para el resumen por usuario en Configuracion -> IA). El costo es
-- una estimacion con los precios por millon de tokens vigentes al registrar. Idempotente.
CREATE TABLE IF NOT EXISTS ai_usage (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
action VARCHAR(40) NOT NULL,
model VARCHAR(60) NOT NULL,
input_tokens INT UNSIGNED NOT NULL DEFAULT 0,
output_tokens INT UNSIGNED NOT NULL DEFAULT 0,
cost_usd DECIMAL(12,6) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_ai_usage_created (created_at),
KEY idx_ai_usage_user (user_id),
CONSTRAINT fk_ai_usage_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "4.0.5",
"version": "4.1.0",
"private": true,
"type": "module",
"main": "src/server.js",
+2
View File
@@ -119,6 +119,7 @@ export async function generateProjectPlan(projectId, description, lang, user) {
`Propon entre 3 y ${MAX_ACTIVITIES} actividades nuevas.`,
].filter(Boolean).join('\n'),
toolName: 'plan_de_proyecto',
usage: { userId: user.id, action: 'project_plan' },
schema: { type: 'object', properties: { activities: ACTIVITIES_SCHEMA }, required: ['activities'] },
maxTokens: 12000,
});
@@ -179,6 +180,7 @@ export async function proposeNewProject({ objective, startDate, lang }, user) {
`Si es un proyecto: propone un nombre corto (max 80 caracteres); un objetivo (una sola oracion que empiece con verbo en infinitivo y diga QUE se quiere lograr y para que, max 250 caracteres); una descripcion (alcance y contexto: que incluye y como se hara, 2-3 oraciones); y entre 3 y ${MAX_ACTIVITIES} actividades.`,
].filter(Boolean).join('\n'),
toolName: 'propuesta_de_proyecto',
usage: { userId: user.id, action: 'new_project' },
schema: {
type: 'object',
properties: {
@@ -20,3 +20,5 @@ export const putAi = asyncHandler(async (req, res) => {
export const postTestAi = asyncHandler(async (req, res) => {
ok(res, await service.testAiConfig(req.body, req.currentUser.id));
});
export const getAiUsage = asyncHandler(async (req, res) => ok(res, await service.getAiUsage(req.query.period)));
@@ -40,3 +40,16 @@ export async function setManyIfVersion(entries, versionKey, editVersion) {
conn.release();
}
}
// Consumo de IA agrupado por usuario; del mes actual salvo all=true.
export async function aiUsageByUser(all) {
const [rows] = await pool.query(
`SELECT au.user_id, COALESCE(u.name, '(usuario eliminado)') AS user_name, COUNT(*) AS calls,
SUM(au.input_tokens) AS input_tokens, SUM(au.output_tokens) AS output_tokens,
SUM(au.cost_usd) AS cost_usd, MAX(au.created_at) AS last_used
FROM ai_usage au LEFT JOIN users u ON u.id = au.user_id
${all ? '' : "WHERE au.created_at >= DATE_FORMAT(CURDATE(), '%Y-%m-01')"}
GROUP BY au.user_id, u.name ORDER BY cost_usd DESC`
);
return rows;
}
+2 -1
View File
@@ -1,7 +1,7 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth, requireRole, CAN_MANAGE_SMTP } from '../../middleware/auth.js';
import { getSmtp, putSmtp, postTestSmtp, getAi, putAi, postTestAi } from './controller.js';
import { getSmtp, putSmtp, postTestSmtp, getAi, putAi, postTestAi, getAiUsage } from './controller.js';
const router = Router();
@@ -13,6 +13,7 @@ router.get('/smtp', getSmtp);
router.put('/smtp', putSmtp);
router.post('/smtp/test', testLimiter, postTestSmtp);
router.get('/ai', getAi);
router.get('/ai/usage', getAiUsage);
router.put('/ai', putAi);
router.post('/ai/test', testLimiter, postTestAi);
+16
View File
@@ -166,7 +166,23 @@ export async function testAiConfig(payload, actorId) {
schema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] },
maxTokens: 50,
configOverride: { apiKey: typed || stored.apiKey, model },
usage: { userId: actorId, action: 'test' },
});
await logAudit({ userId: actorId, action: 'test', entityType: 'settings:ai', entityId: null });
return { ok: !!result.ok, model };
}
// Resumen de consumo por usuario. period: 'month' (mes actual) | 'all'.
export async function getAiUsage(period) {
const rows = await repo.aiUsageByUser(period === 'all');
const users = rows.map((r) => ({
userId: r.user_id, userName: r.user_name, calls: Number(r.calls),
inputTokens: Number(r.input_tokens), outputTokens: Number(r.output_tokens),
costUsd: Number(r.cost_usd), lastUsed: r.last_used,
}));
const total = users.reduce((t, u) => ({
calls: t.calls + u.calls, inputTokens: t.inputTokens + u.inputTokens,
outputTokens: t.outputTokens + u.outputTokens, costUsd: t.costUsd + u.costUsd,
}), { calls: 0, inputTokens: 0, outputTokens: 0, costUsd: 0 });
return { period: period === 'all' ? 'all' : 'month', users, total };
}
+22 -1
View File
@@ -1,12 +1,31 @@
import { getAiConfigInternal } from '../modules/settings/service.js';
import { HttpError } from './http.js';
import { pool } from '../config/db.js';
// USD por millon de tokens (entrada, salida), para estimar el costo de cada llamada.
const PRICES = { 'claude-haiku-5-5': [0.10, 0.50], 'claude-sonnet-5-5': [2.00, 10.00] };
// Registra el consumo; nunca debe romper la respuesta al usuario.
async function recordUsage(usage, model, tokens) {
if (!usage) return;
const [inPrice, outPrice] = PRICES[model] || [0, 0];
const input = tokens?.input_tokens || 0;
const output = tokens?.output_tokens || 0;
const cost = (input * inPrice + output * outPrice) / 1e6;
try {
await pool.query('INSERT INTO ai_usage (user_id, action, model, input_tokens, output_tokens, cost_usd) VALUES (?, ?, ?, ?, ?, ?)',
[usage.userId || null, usage.action, model, input, output, cost]);
} catch (err) {
console.error('[ai] no se pudo registrar el consumo:', err.message);
}
}
const API_URL = 'https://api.anthropic.com/v1/messages';
const TIMEOUT_MS = 60_000;
// Llama a la API de Mensajes forzando una herramienta, para recibir JSON con el esquema pedido
// (la IA solo sugiere: quien llama valida y el usuario revisa antes de guardar).
export async function askStructured({ system, prompt, toolName, schema, maxTokens = 2000, configOverride }) {
export async function askStructured({ system, prompt, toolName, schema, maxTokens = 2000, configOverride, usage }) {
const config = configOverride || await getAiConfigInternal();
if (!config) throw new HttpError(400, 'La integracion con IA no esta configurada');
const controller = new AbortController();
@@ -40,6 +59,8 @@ export async function askStructured({ system, prompt, toolName, schema, maxToken
throw new HttpError(502, `Servicio de IA: ${detail}`);
}
// Consumo real de cada llamada (para estimar costos: ver precios del modelo en la consola).
// Se cobra aunque la respuesta venga cortada: se registra antes de validar.
await recordUsage(usage, config.model, body?.usage);
console.log(`[ai] ${config.model} tokens entrada=${body?.usage?.input_tokens} salida=${body?.usage?.output_tokens} fin=${body?.stop_reason}`);
// Respuesta cortada por el tope de tokens: el JSON de la herramienta llega incompleto.
if (body?.stop_reason === 'max_tokens') throw new HttpError(502, 'La respuesta de la IA fue demasiado larga y quedo incompleta. Intenta con un objetivo mas acotado.');