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:
@@ -184,6 +184,7 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
||||
- `POST /api/ai/projects/:projectId/plan/apply` `{ activities }` → `{ created }` (crea actividades y tareas).
|
||||
- `POST /api/ai/new-project/propose` `{ objective, startDate, lang? }` → `{ name, objective, description, projectTypeId, projectTypeName, estimatedEndDate, activities }`; `400` si el objetivo es corto o la fecha inválida (sin llamar a la IA), `422` si no es un proyecto. 30/hora.
|
||||
- `POST /api/ai/new-project` `{ objective, startDate, name, description?, projectTypeId?, requestingDepartmentId, activities }` → `{ id, code }`. Roles que crean proyectos.
|
||||
- `GET /api/settings/ai/usage?period=month|all` → `{ period, users: [{ userId, userName, calls, inputTokens, outputTokens, costUsd, lastUsed }], total }` — solo superadmin.
|
||||
- `GET/PUT /api/settings/ai` `{ apiKey?, model, editVersion }` y `POST /api/settings/ai/test` — solo superadmin; la clave nunca se devuelve.
|
||||
|
||||
### Log de errores (`/api/error-logs`)
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
| `device_accounts` | cuentas que reporta el agente: sesión iniciada, usuarios locales y grupos locales (`kind`), reemplazadas en cada reporte (migración `027`) |
|
||||
| `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) |
|
||||
| `system_settings` (IA) | `ai_api_key_enc` (AES-256-GCM), `ai_model`, `ai_edit_version` (migración 031) |
|
||||
| `ai_usage` | consumo por llamada a la IA (`user_id`, `action`, `model`, `input_tokens`, `output_tokens`, `cost_usd` estimado, `created_at`) (migración 032) |
|
||||
| `user_modules` | acceso por módulo y usuario (`user_id`, `module`, `enabled`, `updated_by`); sin fila = habilitado (migración 030) |
|
||||
| `device_viewers` | usuarios con acceso de solo lectura compartido al menú Equipos (`user_id`, `granted_by`) |
|
||||
| `inventory_tokens` | tokens de ingesta del agente (solo hash sha256) |
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog - project.linkedcorp.net
|
||||
|
||||
## [4.1.0] - 2026-10-23
|
||||
### Added
|
||||
- Configuración → IA → "Consumo por usuario": consultas, tokens de entrada/salida, costo estimado
|
||||
y último uso por usuario, del mes actual o de todo el historial, con total. Cada llamada a la IA
|
||||
(planes, nuevo proyecto, prueba de conexión) se registra en `ai_usage` (migración 032), incluso
|
||||
si la respuesta llega cortada. Solo superadmin. El monto exacto sigue en la consola de Anthropic.
|
||||
|
||||
## [4.0.5] - 2026-10-23
|
||||
### Fixed
|
||||
- "No se pudo generar un plan con tareas": con 4.000 tokens de tope, una propuesta grande (con
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.0.5",
|
||||
"version": "4.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.0.5",
|
||||
"version": "4.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -51,6 +51,18 @@ export const messages = {
|
||||
ai_new_project_end: 'Fin estimado',
|
||||
ai_new_project_review: 'Desmarca las actividades que no quieras. Puedes cambiar el nombre aquí o después en el proyecto.',
|
||||
ai_new_project_create: 'Crear proyecto',
|
||||
ai_usage_title: 'Consumo por usuario',
|
||||
ai_usage_month: 'Este mes',
|
||||
ai_usage_all: 'Todo',
|
||||
ai_usage_user: 'Usuario',
|
||||
ai_usage_calls: 'Consultas',
|
||||
ai_usage_input: 'Tokens entrada',
|
||||
ai_usage_output: 'Tokens salida',
|
||||
ai_usage_cost: 'Costo (USD)',
|
||||
ai_usage_last: 'Último uso',
|
||||
ai_usage_total: 'Total',
|
||||
ai_usage_hint: 'costo estimado; el monto exacto está en la consola de Anthropic.',
|
||||
ai_usage_empty: 'Todavía no hay consumo registrado.',
|
||||
nav_projects: 'Proyectos',
|
||||
nav_organization: 'Organizacion',
|
||||
nav_config: 'Configuracion',
|
||||
@@ -619,6 +631,18 @@ export const messages = {
|
||||
ai_new_project_end: 'Estimated end',
|
||||
ai_new_project_review: 'Uncheck the activities you do not want. You can change the name here or later in the project.',
|
||||
ai_new_project_create: 'Create project',
|
||||
ai_usage_title: 'Usage by user',
|
||||
ai_usage_month: 'This month',
|
||||
ai_usage_all: 'All time',
|
||||
ai_usage_user: 'User',
|
||||
ai_usage_calls: 'Requests',
|
||||
ai_usage_input: 'Input tokens',
|
||||
ai_usage_output: 'Output tokens',
|
||||
ai_usage_cost: 'Cost (USD)',
|
||||
ai_usage_last: 'Last used',
|
||||
ai_usage_total: 'Total',
|
||||
ai_usage_hint: 'estimated cost; the exact amount is in the Anthropic console.',
|
||||
ai_usage_empty: 'No usage recorded yet.',
|
||||
nav_projects: 'Projects',
|
||||
nav_organization: 'Organization',
|
||||
nav_config: 'Settings',
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync } from '../../hooks/useAsync.js';
|
||||
import { getAiUsage } from '../../services/settings.js';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
|
||||
const fmtInt = (n) => Number(n || 0).toLocaleString();
|
||||
const fmtUsd = (n) => `$${Number(n || 0).toFixed(4)}`;
|
||||
|
||||
// Consumo de IA por usuario (registrado en cada llamada). El costo es estimado con los precios
|
||||
// por millon de tokens del modelo; el monto exacto esta en la consola de Anthropic.
|
||||
export default function AiUsagePanel() {
|
||||
const { t } = useUi();
|
||||
const [period, setPeriod] = useState('month');
|
||||
const { data } = useAsync(() => getAiUsage(period), [period]);
|
||||
|
||||
const columns = [
|
||||
{ key: 'userName', label: t('ai_usage_user') },
|
||||
{ key: 'calls', label: t('ai_usage_calls'), render: (r) => fmtInt(r.calls) },
|
||||
{ key: 'inputTokens', label: t('ai_usage_input'), render: (r) => fmtInt(r.inputTokens) },
|
||||
{ key: 'outputTokens', label: t('ai_usage_output'), render: (r) => fmtInt(r.outputTokens) },
|
||||
{ key: 'costUsd', label: t('ai_usage_cost'), render: (r) => fmtUsd(r.costUsd) },
|
||||
{ key: 'lastUsed', label: t('ai_usage_last'), render: (r) => (r.lastUsed ? new Date(r.lastUsed).toLocaleString() : '—') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="panel-card" style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<h4 style={{ margin: 0 }}>{t('ai_usage_title')}</h4>
|
||||
<select className="input" style={{ width: 'auto' }} value={period} onChange={(e) => setPeriod(e.target.value)}>
|
||||
<option value="month">{t('ai_usage_month')}</option>
|
||||
<option value="all">{t('ai_usage_all')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{!data ? <div className="text-muted">{t('common_loading')}</div> : (
|
||||
<>
|
||||
<p className="text-muted" style={{ fontSize: '.8rem' }}>
|
||||
{t('ai_usage_total')}: <strong>{fmtInt(data.total.calls)}</strong> {t('ai_usage_calls').toLowerCase()} · {fmtInt(data.total.inputTokens)} / {fmtInt(data.total.outputTokens)} tokens · <strong>{fmtUsd(data.total.costUsd)}</strong> — {t('ai_usage_hint')}
|
||||
</p>
|
||||
{data.users.length ? (
|
||||
<ListView columns={columns} rows={data.users} getRowKey={(r) => r.userId ?? 'deleted'} />
|
||||
) : <div className="text-muted">{t('ai_usage_empty')}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { CatalogEditor } from './CatalogEditor.jsx';
|
||||
import SmtpSettingsPanel from './SmtpSettingsPanel.jsx';
|
||||
import AiSettingsPanel from './AiSettingsPanel.jsx';
|
||||
import AiUsagePanel from './AiUsagePanel.jsx';
|
||||
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
||||
import UpdaterPanel from './UpdaterPanel.jsx';
|
||||
import ErrorLogsPanel from './ErrorLogsPanel.jsx';
|
||||
@@ -122,6 +123,7 @@ export default function ConfiguracionPage() {
|
||||
|
||||
<Expander icon={Sparkles} title={t('config_ai')} draftForms={['ai-config']}>
|
||||
<AiSettingsPanel />
|
||||
<AiUsagePanel />
|
||||
</Expander>
|
||||
|
||||
<div id="updates-panel">
|
||||
|
||||
@@ -6,3 +6,4 @@ export const testSmtpConfig = (data) => api.post('/settings/smtp/test', data);
|
||||
export const getAiConfig = () => api.get('/settings/ai');
|
||||
export const updateAiConfig = (data) => api.put('/settings/ai', data);
|
||||
export const testAiConfig = (data) => api.post('/settings/ai/test', data);
|
||||
export const getAiUsage = (period) => api.get(`/settings/ai/usage?period=${period}`);
|
||||
|
||||
Reference in New Issue
Block a user