diff --git a/.env.example b/.env.example index 121e905..ba086b5 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,11 @@ SEED_ADMIN_NAME=Administrador SEED_ASESOR_EMAIL= SEED_ASESOR_PASSWORD= SEED_ASESOR_NAME= + +# --- Propuesta consultiva con IA (MiniMax) --- +# Clave del panel de MiniMax. NUNCA se commitea: .env esta en .gitignore. +# Ojo con el prefijo: NO uses ANTHROPIC_API_KEY. El SDK la lee por su cuenta y +# mandaria la clave de MiniMax a api.anthropic.com. +MINIMAX_API_KEY= +MINIMAX_BASE_URL=https://api.minimax.io/anthropic +MINIMAX_MODEL=MiniMax-M3 diff --git a/package-lock.json b/package-lock.json index 1459fc2..86c1d12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "cotizador-e3", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.115.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "bcryptjs": "^3.0.3", @@ -22,6 +23,7 @@ "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { @@ -51,6 +53,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz", + "integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -243,6 +266,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2233,6 +2265,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5179,6 +5217,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -6364,6 +6408,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8554,6 +8611,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8890,6 +8957,12 @@ "node": "*" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9498,7 +9571,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index e738256..64f5830 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "seed": "npx tsx prisma/seed.ts" }, "dependencies": { + "@anthropic-ai/sdk": "^0.115.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "bcryptjs": "^3.0.3", diff --git a/scripts/verificar-propuesta-cliente.ts b/scripts/verificar-propuesta-cliente.ts new file mode 100644 index 0000000..8d908b5 --- /dev/null +++ b/scripts/verificar-propuesta-cliente.ts @@ -0,0 +1,51 @@ +import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia"; + +// No llama a la API. Comprueba el contrato local y que el fallo sin clave sea claro. + +let fallas = 0; +function check(n: string, ok: boolean, d = "") { + console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`); + if (!ok) fallas++; +} + +const guardada = process.env.MINIMAX_API_KEY; +const guardadaBase = process.env.MINIMAX_BASE_URL; + +delete process.env.MINIMAX_API_KEY; +let msg = ""; +try { + crearCliente(); +} catch (e) { + msg = e instanceof Error ? e.message : String(e); +} +check("sin clave falla en vez de llamar a ciegas", msg.length > 0); +check("el error nombra la variable correcta", msg.includes("MINIMAX_API_KEY")); +check("el error advierte del prefijo ANTHROPIC_", msg.includes("ANTHROPIC_API_KEY")); + +process.env.MINIMAX_API_KEY = "clave-de-prueba"; +delete process.env.MINIMAX_BASE_URL; +const c = crearCliente(); +check("baseURL apunta a MiniMax por defecto", String(c.baseURL).includes("api.minimax.io"), String(c.baseURL)); +check("modelo por defecto es MiniMax-M3", modelo() === "MiniMax-M3", modelo()); + +// Aunque el shell tenga ANTHROPIC_BASE_URL, el cliente no debe hacerle caso. +process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com"; +const c2 = crearCliente(); +check("ANTHROPIC_BASE_URL del shell no secuestra el baseURL", + !String(c2.baseURL).includes("api.anthropic.com"), String(c2.baseURL)); +delete process.env.ANTHROPIC_BASE_URL; + +process.env.MINIMAX_BASE_URL = "https://ejemplo.local/anthropic"; +check("MINIMAX_BASE_URL si se respeta", String(crearCliente().baseURL).includes("ejemplo.local")); + +process.env.MINIMAX_MODEL = "MiniMax-M2"; +check("MINIMAX_MODEL se respeta", modelo() === "MiniMax-M2"); +delete process.env.MINIMAX_MODEL; + +if (guardada) process.env.MINIMAX_API_KEY = guardada; +else delete process.env.MINIMAX_API_KEY; +if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase; +else delete process.env.MINIMAX_BASE_URL; + +console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`); +process.exit(fallas === 0 ? 0 : 1); diff --git a/src/lib/propuesta/cliente-ia.ts b/src/lib/propuesta/cliente-ia.ts new file mode 100644 index 0000000..ba5e8f5 --- /dev/null +++ b/src/lib/propuesta/cliente-ia.ts @@ -0,0 +1,151 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { z } from "zod"; +import { aJsonSchema } from "./schemas"; + +/** + * Cliente del proveedor de IA (MiniMax-M3 via su endpoint compatible con Anthropic) + * y runner que fuerza el contrato con tool-calling. + * + * MiniMax NO soporta structured outputs (`output_config` / `json_schema`), asi que + * el `input_schema` de la herramienta ES el contrato, y Zod valida siempre del lado + * del codigo. `tool_choice` no se envia: no esta documentado en MiniMax, y fijarlo + * solo en el reintento le daria al reintento un prefijo distinto — se pagaria el + * contexto completo justo cuando es mas grande. + */ + +export interface UsoTokens { + entrada: number; + salida: number; + cacheLectura: number; + cacheEscritura: number; +} + +export function crearCliente(): Anthropic { + const apiKey = process.env.MINIMAX_API_KEY; + if (!apiKey) { + throw new Error( + "Falta MINIMAX_API_KEY. Definela en .env. No uses ANTHROPIC_API_KEY: el SDK la " + + "leeria por su cuenta y mandaria la clave de MiniMax a api.anthropic.com." + ); + } + // apiKey y baseURL explicitos. Si se dejan al SDK, toma las ANTHROPIC_* del shell + // y la clave termina en el proveedor equivocado. + return new Anthropic({ + apiKey, + baseURL: process.env.MINIMAX_BASE_URL || "https://api.minimax.io/anthropic", + }); +} + +export function modelo(): string { + return process.env.MINIMAX_MODEL || "MiniMax-M3"; +} + +export interface BloqueSystem { + texto: string; + cachear: boolean; +} + +interface OpcionesLlamada { + system: BloqueSystem[]; + mensajeUsuario: string; + herramienta: { nombre: string; descripcion: string; schema: z.ZodType }; + maxIntentos: number; + maxTokens: number; +} + +export async function llamarConHerramienta( + opts: OpcionesLlamada +): Promise<{ datos: T; uso: UsoTokens; intentos: number }> { + const cliente = crearCliente(); + + const tools = [ + { + name: opts.herramienta.nombre, + description: opts.herramienta.descripcion, + input_schema: aJsonSchema(opts.herramienta.schema) as Anthropic.Tool["input_schema"], + }, + ]; + + const system = opts.system.map((b) => ({ + type: "text" as const, + text: b.texto, + ...(b.cachear ? { cache_control: { type: "ephemeral" as const } } : {}), + })); + + const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: opts.mensajeUsuario }]; + const uso: UsoTokens = { entrada: 0, salida: 0, cacheLectura: 0, cacheEscritura: 0 }; + let ultimoError = ""; + + for (let intento = 1; intento <= opts.maxIntentos; intento++) { + const res = await cliente.messages.create({ + model: modelo(), + max_tokens: opts.maxTokens, + system, + tools, + messages: mensajes, + }); + + const u = res.usage as { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + uso.entrada += u.input_tokens ?? 0; + uso.salida += u.output_tokens ?? 0; + uso.cacheLectura += u.cache_read_input_tokens ?? 0; + uso.cacheEscritura += u.cache_creation_input_tokens ?? 0; + + const bloquesTool = res.content.filter( + (b): b is Anthropic.ToolUseBlock => b.type === "tool_use" + ); + const correcto = bloquesTool.find((b) => b.name === opts.herramienta.nombre); + + if (correcto) { + const parsed = opts.herramienta.schema.safeParse(correcto.input); + if (parsed.success) { + return { datos: parsed.data as T, uso, intentos: intento }; + } + ultimoError = z.prettifyError(parsed.error); + + // Hubo tool_use: la API exige un tool_result por CADA uno antes de continuar. + // Un turno de usuario plano despues de un tool_use devuelve 400. + mensajes.push( + { role: "assistant", content: res.content }, + { + role: "user", + content: [ + ...bloquesTool.map((b) => ({ + type: "tool_result" as const, + tool_use_id: b.id, + is_error: true, + content: + b.id === correcto.id + ? ultimoError + : `Herramienta inesperada. Usa ${opts.herramienta.nombre}.`, + })), + { + type: "text" as const, + text: `La llamada no cumple el schema. Corrige EXACTAMENTE estos errores y vuelve a llamar a ${opts.herramienta.nombre}:\n\n${ultimoError}`, + }, + ], + } + ); + continue; + } + + // No llamo a ninguna herramienta: aqui si va un turno de usuario plano. + ultimoError = "El modelo respondio con prosa en vez de llamar a la herramienta."; + mensajes.push( + { role: "assistant", content: res.content }, + { + role: "user", + content: `Debes responder llamando a la herramienta ${opts.herramienta.nombre}. No escribas prosa suelta.`, + } + ); + } + + throw new Error( + `El paso "${opts.herramienta.nombre}" agoto ${opts.maxIntentos} intentos.\nUltimo error:\n${ultimoError}` + ); +}