Files
cotizador/src/lib/propuesta/cliente-ia.ts
T
urieljarethandClaude Opus 5 877a181a03 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]>
2026-07-28 18:14:02 -06:00

152 lines
4.8 KiB
TypeScript

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}`
);
}