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:
co-authored by
Claude Opus 5
parent
5bad273977
commit
877a181a03
@@ -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}`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user