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, usage }) { const config = configOverride || await getAiConfigInternal(); if (!config) throw new HttpError(400, 'La integracion con IA no esta configurada'); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); let res; try { res = await fetch(API_URL, { method: 'POST', signal: controller.signal, headers: { 'content-type': 'application/json', 'x-api-key': config.apiKey, 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model: config.model, max_tokens: maxTokens, system: `${system}\nResponde SIEMPRE llamando a la herramienta ${toolName}.`, messages: [{ role: 'user', content: prompt }], tools: [{ name: toolName, description: 'Devuelve el resultado estructurado.', input_schema: schema }], // Forzar la herramienta (tool_choice 'tool') da 400 en Sonnet 5.5: se usa 'auto' + instruccion. tool_choice: { type: 'auto' }, }), }); } catch (err) { throw new HttpError(502, err.name === 'AbortError' ? 'La IA tardo demasiado en responder' : 'No se pudo conectar con el servicio de IA'); } finally { clearTimeout(timer); } const body = await res.json().catch(() => null); if (!res.ok) { // Mensaje del proveedor sin exponer la clave; 401 = clave invalida, 429 = limite, 400 = saldo/peticion. const detail = body?.error?.message || `HTTP ${res.status}`; console.error('[ai] error de la API:', res.status, detail); 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.'); const tool = body?.content?.find((c) => c.type === 'tool_use'); if (!tool?.input) throw new HttpError(502, 'La IA no devolvio un resultado valido'); return tool.input; }