Propuesta IA: cliente MiniMax con tool-calling y reintentos

El contrato se fuerza con el input_schema de la herramienta porque MiniMax no
soporta structured outputs. tool_choice no se envia: fijarlo solo en el reintento
le daria un prefijo distinto y se pagaria el contexto completo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-28 18:14:02 -06:00
co-authored by Claude Opus 5
parent 5bad273977
commit 877a181a03
5 changed files with 284 additions and 1 deletions
+8
View File
@@ -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
+73 -1
View File
@@ -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"
+1
View File
@@ -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",
+51
View File
@@ -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);
+151
View File
@@ -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<T>(
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}`
);
}