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]>
152 lines
4.8 KiB
TypeScript
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}`
|
|
);
|
|
}
|