diff --git a/API.md b/API.md index 5a6b02b..d0ef28c 100644 --- a/API.md +++ b/API.md @@ -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`) diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 1815cd9..cf4018b 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -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) | diff --git a/CHANGELOG.md b/CHANGELOG.md index bd5d804..b7557f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 7636e75..ee74734 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.0.5 +4.1.0 diff --git a/backend/database/migrations/032_ai_usage.sql b/backend/database/migrations/032_ai_usage.sql new file mode 100644 index 0000000..707d892 --- /dev/null +++ b/backend/database/migrations/032_ai_usage.sql @@ -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; diff --git a/backend/package.json b/backend/package.json index 9559f0b..bf365ad 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.0.5", + "version": "4.1.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/ai/service.js b/backend/src/modules/ai/service.js index 4e7428d..7efdb33 100644 --- a/backend/src/modules/ai/service.js +++ b/backend/src/modules/ai/service.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: { diff --git a/backend/src/modules/settings/controller.js b/backend/src/modules/settings/controller.js index fba8e3b..602f21f 100644 --- a/backend/src/modules/settings/controller.js +++ b/backend/src/modules/settings/controller.js @@ -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))); diff --git a/backend/src/modules/settings/repository.js b/backend/src/modules/settings/repository.js index 424733b..59c356f 100644 --- a/backend/src/modules/settings/repository.js +++ b/backend/src/modules/settings/repository.js @@ -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; +} diff --git a/backend/src/modules/settings/routes.js b/backend/src/modules/settings/routes.js index 327362e..482726d 100644 --- a/backend/src/modules/settings/routes.js +++ b/backend/src/modules/settings/routes.js @@ -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); diff --git a/backend/src/modules/settings/service.js b/backend/src/modules/settings/service.js index a5fbaeb..d9cf1b0 100644 --- a/backend/src/modules/settings/service.js +++ b/backend/src/modules/settings/service.js @@ -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 }; +} diff --git a/backend/src/utils/ai.js b/backend/src/utils/ai.js index 1d4c475..e670f63 100644 --- a/backend/src/utils/ai.js +++ b/backend/src/utils/ai.js @@ -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.'); diff --git a/frontend/package.json b/frontend/package.json index d3ce0b0..5ab48c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.0.5", + "version": "4.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 85a5149..51ec2df 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -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', diff --git a/frontend/src/pages/Configuracion/AiUsagePanel.jsx b/frontend/src/pages/Configuracion/AiUsagePanel.jsx new file mode 100644 index 0000000..b550d54 --- /dev/null +++ b/frontend/src/pages/Configuracion/AiUsagePanel.jsx @@ -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 ( +
+ {t('ai_usage_total')}: {fmtInt(data.total.calls)} {t('ai_usage_calls').toLowerCase()} · {fmtInt(data.total.inputTokens)} / {fmtInt(data.total.outputTokens)} tokens · {fmtUsd(data.total.costUsd)} — {t('ai_usage_hint')} +
+ {data.users.length ? ( +