@orxataguy/tyr 1.0.30 → 1.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -10
- package/package.json +1 -1
- package/src/core/Container.ts +4 -1
- package/src/core/Kernel.ts +2 -0
- package/src/core/sys/chat.ts +73 -0
- package/src/core/sys/help.ts +1 -0
- package/src/index.ts +1 -0
- package/src/lib/AIContextManager.ts +1004 -27
- package/src/lib/AIVendorManager.ts +474 -25
- package/src/lib/ChatManager.ts +880 -0
- package/src/lib/PromptTemplateManager.ts +112 -1
- package/src/lib/TokenManager.ts +30 -2
|
@@ -8,25 +8,86 @@ import {getEnvString, getEnvInt, getEnvDouble} from '../core/util/getenv.js';
|
|
|
8
8
|
export type AIVendor = 'anthropic' | 'openai' | 'gemini';
|
|
9
9
|
export type AIRole = 'system' | 'user' | 'assistant';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Representación normalizada (independiente del vendor) del contenido de un mensaje.
|
|
13
|
+
*
|
|
14
|
+
* - 'text': texto plano.
|
|
15
|
+
* - 'tool_use': el modelo pide ejecutar una herramienta. `id` es opaco para el llamador: hay
|
|
16
|
+
* que devolverlo tal cual en el `tool_result` correspondiente, sin asumir nada sobre su
|
|
17
|
+
* formato (en Gemini, por ejemplo, es un id sintético generado por este manager, ya que la
|
|
18
|
+
* API de Gemini no da ids reales para las function calls).
|
|
19
|
+
* - 'tool_result': resultado de haber ejecutado una herramienta, para devolvérselo al modelo.
|
|
20
|
+
* - 'image': imagen adjunta (p.ej. un attachment de chat). `data` es el contenido en base64 SIN
|
|
21
|
+
* el prefijo `data:...;base64,`; `mediaType` es el MIME type (p.ej. 'image/png'). Solo válido
|
|
22
|
+
* en mensajes de rol 'user' — los vendors no devuelven imágenes en sus respuestas.
|
|
23
|
+
*/
|
|
24
|
+
export type AIContentBlock =
|
|
25
|
+
| { type: 'text'; text: string }
|
|
26
|
+
| { type: 'tool_use'; id: string; name: string; input: any }
|
|
27
|
+
| { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
|
|
28
|
+
| { type: 'image'; mediaType: string; data: string };
|
|
29
|
+
|
|
11
30
|
export interface AIMessage {
|
|
12
31
|
role: AIRole;
|
|
13
|
-
|
|
32
|
+
/** Texto plano, o una lista de bloques cuando el mensaje incluye tool_use / tool_result. */
|
|
33
|
+
content: string | AIContentBlock[];
|
|
14
34
|
}
|
|
15
35
|
|
|
36
|
+
/** Definición de herramienta en formato JSON-schema al estilo Anthropic; se traduce internamente
|
|
37
|
+
* al formato de cada vendor (function-calling de OpenAI, functionDeclarations de Gemini). */
|
|
38
|
+
export interface AITool {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
input_schema: any;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Nivel de "esfuerzo de razonamiento" (extended thinking) pedido al modelo, independiente del
|
|
46
|
+
* vendor concreto. Se traduce a `thinking.budget_tokens` en Anthropic y a `reasoning_effort` en
|
|
47
|
+
* los modelos de OpenAI que lo soportan (serie 'o'); Gemini no tiene un equivalente público en
|
|
48
|
+
* este momento y el valor se ignora silenciosamente para ese vendor.
|
|
49
|
+
*/
|
|
50
|
+
export type ThinkingEffort = 'none' | 'low' | 'medium' | 'high' | 'max';
|
|
51
|
+
|
|
52
|
+
/** Prioridad de una tarea, usada por resolvePriority()/completeWithPriority() para elegir modelo
|
|
53
|
+
* y esfuerzo de razonamiento sin que el llamador tenga que conocer nombres de modelo concretos. */
|
|
54
|
+
export type TaskPriority =
|
|
55
|
+
| 'baja-prioridad'
|
|
56
|
+
| 'baja-media-prioridad'
|
|
57
|
+
| 'media-prioridad'
|
|
58
|
+
| 'media-alta-prioridad'
|
|
59
|
+
| 'alta-prioridad'
|
|
60
|
+
| 'muy-alta-prioridad';
|
|
61
|
+
|
|
16
62
|
export interface AICompletionOptions {
|
|
17
63
|
vendor?: AIVendor;
|
|
18
64
|
model?: string;
|
|
19
65
|
temperature?: number;
|
|
20
66
|
maxTokens?: number;
|
|
21
67
|
maxRetries?: number;
|
|
68
|
+
/** Herramientas disponibles para que el modelo las invoque. Solo soportado en complete(),
|
|
69
|
+
* no en stream() — ver el guard al inicio de stream(). */
|
|
70
|
+
tools?: AITool[];
|
|
71
|
+
/** Nivel de extended thinking / reasoning effort. Ver ThinkingEffort. 'none' (o ausente)
|
|
72
|
+
* desactiva el thinking explícitamente. */
|
|
73
|
+
thinking?: ThinkingEffort;
|
|
22
74
|
}
|
|
23
75
|
|
|
24
76
|
export interface AICompletionResult {
|
|
77
|
+
/** Texto plano concatenado de todos los bloques de tipo 'text' (conveniencia; equivalente al
|
|
78
|
+
* comportamiento anterior de esta clase, antes de soportar tools). */
|
|
25
79
|
content: string;
|
|
80
|
+
/** Contenido completo y normalizado, incluyendo bloques tool_use si el modelo pidió alguno.
|
|
81
|
+
* Necesario para reconstruir el mensaje de assistant en el siguiente turno de un bucle
|
|
82
|
+
* agente. */
|
|
83
|
+
blocks: AIContentBlock[];
|
|
26
84
|
vendor: AIVendor;
|
|
27
85
|
model: string;
|
|
28
86
|
promptTokens?: number;
|
|
29
87
|
completionTokens?: number;
|
|
88
|
+
/** Motivo de parada normalizado tal como lo reporta cada vendor (stop_reason / finish_reason
|
|
89
|
+
* / finishReason), sin normalizar entre vendors — úsalo solo informativamente. */
|
|
90
|
+
stopReason?: string;
|
|
30
91
|
}
|
|
31
92
|
|
|
32
93
|
interface VendorConfig {
|
|
@@ -36,6 +97,7 @@ interface VendorConfig {
|
|
|
36
97
|
temperature: number;
|
|
37
98
|
maxTokens: number;
|
|
38
99
|
maxRetries: number;
|
|
100
|
+
thinking: ThinkingEffort;
|
|
39
101
|
}
|
|
40
102
|
|
|
41
103
|
interface VendorRequest {
|
|
@@ -61,6 +123,58 @@ const DEFAULT_MAX_TOKENS = 4096;
|
|
|
61
123
|
const DEFAULT_MAX_RETRIES = 3;
|
|
62
124
|
const RETRY_BASE_DELAY_MS = 500;
|
|
63
125
|
|
|
126
|
+
// --- Dynamic Model Routing ----------------------------------------------------------------------
|
|
127
|
+
//
|
|
128
|
+
// Three cost/capability tiers per vendor. Each is overridable via env var (AI_MODEL_CHEAP /
|
|
129
|
+
// AI_MODEL_BALANCED / AI_MODEL_FLAGSHIP) so operators can point a tier at a newer model without
|
|
130
|
+
// touching code. 'balanced' matches DEFAULT_MODELS on purpose — it's the vendor's normal default.
|
|
131
|
+
type ModelTier = 'cheap' | 'balanced' | 'flagship';
|
|
132
|
+
|
|
133
|
+
const MODEL_TIERS: Record<AIVendor, Record<ModelTier, string>> = {
|
|
134
|
+
anthropic: { cheap: 'claude-haiku-4-5', balanced: 'claude-sonnet-5', flagship: 'claude-opus-4-1' },
|
|
135
|
+
openai: { cheap: 'gpt-4o-mini', balanced: 'gpt-4o', flagship: 'gpt-4o' },
|
|
136
|
+
gemini: { cheap: 'gemini-2.5-flash', balanced: 'gemini-2.5-pro', flagship: 'gemini-2.5-pro' },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const MODEL_TIER_ENV: Record<ModelTier, string> = {
|
|
140
|
+
cheap: 'AI_MODEL_CHEAP',
|
|
141
|
+
balanced: 'AI_MODEL_BALANCED',
|
|
142
|
+
flagship: 'AI_MODEL_FLAGSHIP',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** budget_tokens sent to Anthropic's `thinking` param per effort level. 'none' disables thinking. */
|
|
146
|
+
const THINKING_BUDGETS: Record<ThinkingEffort, number> = {
|
|
147
|
+
none: 0,
|
|
148
|
+
low: 1024,
|
|
149
|
+
medium: 4096,
|
|
150
|
+
high: 8192,
|
|
151
|
+
max: 16384,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Task-priority → (model tier, thinking effort) matrix. This is the routing table requested by
|
|
156
|
+
* the architecture spec: callers pick a priority by *intent* ("this is a trivial file", "this
|
|
157
|
+
* previous attempt failed"), not by model name, and AIVendorManager decides what that costs.
|
|
158
|
+
*/
|
|
159
|
+
const PRIORITY_ROUTING: Record<TaskPriority, { tier: ModelTier; thinking: ThinkingEffort }> = {
|
|
160
|
+
'baja-prioridad': { tier: 'cheap', thinking: 'none' },
|
|
161
|
+
'baja-media-prioridad': { tier: 'cheap', thinking: 'max' },
|
|
162
|
+
'media-prioridad': { tier: 'balanced', thinking: 'none' },
|
|
163
|
+
'media-alta-prioridad': { tier: 'balanced', thinking: 'high' },
|
|
164
|
+
'alta-prioridad': { tier: 'balanced', thinking: 'max' },
|
|
165
|
+
'muy-alta-prioridad': { tier: 'flagship', thinking: 'max' },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** Ordered priority ladder, used by bumpPriority() to escalate one level at a time. */
|
|
169
|
+
const PRIORITY_LADDER: TaskPriority[] = [
|
|
170
|
+
'baja-prioridad',
|
|
171
|
+
'baja-media-prioridad',
|
|
172
|
+
'media-prioridad',
|
|
173
|
+
'media-alta-prioridad',
|
|
174
|
+
'alta-prioridad',
|
|
175
|
+
'muy-alta-prioridad',
|
|
176
|
+
];
|
|
177
|
+
|
|
64
178
|
/**
|
|
65
179
|
* @class AIVendorManager
|
|
66
180
|
* @description Unified client for AI chat-completion APIs (Anthropic, OpenAI, Gemini).
|
|
@@ -68,6 +182,12 @@ const RETRY_BASE_DELAY_MS = 500;
|
|
|
68
182
|
* environment variables / Tyr configuration, retries transient failures with exponential
|
|
69
183
|
* backoff, and supports both blocking and streaming responses.
|
|
70
184
|
*
|
|
185
|
+
* Tool use (function calling) is supported in `complete()` for all three vendors, behind a
|
|
186
|
+
* vendor-agnostic representation (`AITool` / `AIContentBlock`). Each vendor's wire format is
|
|
187
|
+
* different — this class does the translation both ways (request and response) so callers never
|
|
188
|
+
* need to know which vendor is active. `stream()` does not support tools yet (see the guard at
|
|
189
|
+
* the top of that method).
|
|
190
|
+
*
|
|
71
191
|
* Environment variables:
|
|
72
192
|
* AI_VENDOR – 'anthropic' | 'openai' | 'gemini' (default: 'anthropic')
|
|
73
193
|
* ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY – API key for the selected vendor
|
|
@@ -112,20 +232,272 @@ export class AIVendorManager {
|
|
|
112
232
|
temperature: options?.temperature ?? getEnvDouble('AI_TEMPERATURE', DEFAULT_TEMPERATURE),
|
|
113
233
|
maxTokens: options?.maxTokens ?? getEnvInt('AI_MAX_TOKENS', DEFAULT_MAX_TOKENS),
|
|
114
234
|
maxRetries: options?.maxRetries ?? getEnvInt('AI_MAX_RETRIES', DEFAULT_MAX_RETRIES),
|
|
235
|
+
thinking: options?.thinking ?? 'none',
|
|
115
236
|
};
|
|
116
237
|
}
|
|
117
238
|
|
|
239
|
+
/**
|
|
240
|
+
* @method resolvePriority
|
|
241
|
+
* @description Translates a TaskPriority into concrete AICompletionOptions (model + thinking
|
|
242
|
+
* effort) for the currently configured vendor (or the vendor passed in `overrides`), via the
|
|
243
|
+
* routing matrix in PRIORITY_ROUTING. This is the single place that maps "how important/hard
|
|
244
|
+
* is this task" to "which model do we pay for" — Manager code should route through this
|
|
245
|
+
* instead of hardcoding model names.
|
|
246
|
+
* @param {TaskPriority} priority - One of the six priority levels (see TaskPriority).
|
|
247
|
+
* @param {AICompletionOptions} overrides - Optional overrides merged on top (e.g. tools, maxTokens).
|
|
248
|
+
* @returns {AICompletionOptions} Options ready to pass to complete()/stream().
|
|
249
|
+
* @example
|
|
250
|
+
* const options = aiVendor.resolvePriority('media-alta-prioridad', { tools: AGENT_TOOLS });
|
|
251
|
+
* const result = await aiVendor.complete(messages, options);
|
|
252
|
+
*/
|
|
253
|
+
public resolvePriority(priority: TaskPriority, overrides: AICompletionOptions = {}): AICompletionOptions {
|
|
254
|
+
const vendor = (overrides.vendor ?? (getEnvString('AI_VENDOR') as AIVendor | undefined) ?? 'anthropic')
|
|
255
|
+
.toString()
|
|
256
|
+
.toLowerCase() as AIVendor;
|
|
257
|
+
|
|
258
|
+
const route = PRIORITY_ROUTING[priority];
|
|
259
|
+
if (!route) {
|
|
260
|
+
throw new TyrError(`Unknown task priority: '${priority}'`, null, `Use one of: ${PRIORITY_LADDER.join(', ')}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const tierModel = getEnvString(MODEL_TIER_ENV[route.tier]) ?? MODEL_TIERS[vendor][route.tier];
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
vendor,
|
|
267
|
+
model: tierModel,
|
|
268
|
+
thinking: route.thinking,
|
|
269
|
+
...overrides,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @method bumpPriority
|
|
275
|
+
* @description Escalates a priority one level up the ladder (baja-prioridad → ... →
|
|
276
|
+
* muy-alta-prioridad), capping at the top. Used for self-healing retries: each consecutive
|
|
277
|
+
* failure (e.g. a broken compile after an applied patch) buys the model more capability.
|
|
278
|
+
* @param {TaskPriority} priority - The current priority.
|
|
279
|
+
* @returns {TaskPriority} The next priority level, or the same value if already at the top.
|
|
280
|
+
* @example
|
|
281
|
+
* priority = aiVendor.bumpPriority(priority); // one more level of thinking/model tier
|
|
282
|
+
*/
|
|
283
|
+
public bumpPriority(priority: TaskPriority): TaskPriority {
|
|
284
|
+
const index = PRIORITY_LADDER.indexOf(priority);
|
|
285
|
+
if (index === -1 || index === PRIORITY_LADDER.length - 1) return priority;
|
|
286
|
+
return PRIORITY_LADDER[index + 1];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @method completeWithPriority
|
|
291
|
+
* @description Convenience wrapper around complete() that resolves model + thinking effort
|
|
292
|
+
* from a TaskPriority first. Equivalent to `complete(messages, resolvePriority(priority, options))`.
|
|
293
|
+
* @param {AIMessage[]} messages - Conversation messages.
|
|
294
|
+
* @param {TaskPriority} priority - Routing priority (see TaskPriority).
|
|
295
|
+
* @param {AICompletionOptions} options - Optional overrides merged on top of the routed defaults.
|
|
296
|
+
* @returns {Promise<AICompletionResult>}
|
|
297
|
+
* @example
|
|
298
|
+
* const result = await aiVendor.completeWithPriority(messages, 'alta-prioridad', { tools });
|
|
299
|
+
*/
|
|
300
|
+
public async completeWithPriority(
|
|
301
|
+
messages: AIMessage[],
|
|
302
|
+
priority: TaskPriority,
|
|
303
|
+
options: AICompletionOptions = {}
|
|
304
|
+
): Promise<AICompletionResult> {
|
|
305
|
+
return this.complete(messages, this.resolvePriority(priority, options));
|
|
306
|
+
}
|
|
307
|
+
|
|
118
308
|
private splitSystem(messages: AIMessage[]): { system: string; turns: AIMessage[] } {
|
|
119
|
-
const system = messages
|
|
309
|
+
const system = messages
|
|
310
|
+
.filter(m => m.role === 'system')
|
|
311
|
+
.map(m => (typeof m.content === 'string' ? m.content : m.content.map(b => (b.type === 'text' ? b.text : '')).join('')))
|
|
312
|
+
.join('\n\n');
|
|
120
313
|
const turns = messages.filter(m => m.role !== 'system');
|
|
121
314
|
return { system, turns };
|
|
122
315
|
}
|
|
123
316
|
|
|
124
|
-
|
|
125
|
-
|
|
317
|
+
// --- Traducción de herramientas por vendor -------------------------------------------------
|
|
318
|
+
|
|
319
|
+
private buildVendorTools(config: VendorConfig, tools?: AITool[]): any {
|
|
320
|
+
if (!tools || tools.length === 0) return undefined;
|
|
126
321
|
|
|
127
322
|
switch (config.vendor) {
|
|
128
323
|
case 'anthropic':
|
|
324
|
+
return tools.map(t => ({ name: t.name, description: t.description, input_schema: t.input_schema }));
|
|
325
|
+
case 'openai':
|
|
326
|
+
return tools.map(t => ({
|
|
327
|
+
type: 'function',
|
|
328
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
329
|
+
}));
|
|
330
|
+
case 'gemini':
|
|
331
|
+
return [{ functionDeclarations: tools.map(t => ({ name: t.name, description: t.description, parameters: t.input_schema })) }];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Traducción de mensajes por vendor ------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
private toAnthropicContent(content: string | AIContentBlock[]): any {
|
|
338
|
+
if (typeof content === 'string') return content;
|
|
339
|
+
return content.map(block => {
|
|
340
|
+
if (block.type === 'text') return { type: 'text', text: block.text };
|
|
341
|
+
if (block.type === 'image') return { type: 'image', source: { type: 'base64', media_type: block.mediaType, data: block.data } };
|
|
342
|
+
if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input };
|
|
343
|
+
return { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content, ...(block.is_error ? { is_error: true } : {}) };
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private buildAnthropicMessages(turns: AIMessage[]): any[] {
|
|
348
|
+
return turns.map(m => ({ role: m.role, content: this.toAnthropicContent(m.content) }));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* OpenAI no tiene un bloque "tool_result" dentro de un mensaje de usuario: cada resultado de
|
|
353
|
+
* herramienta tiene que ir en su propio mensaje con role: 'tool'. Por eso, a diferencia de
|
|
354
|
+
* Anthropic, un único AIMessage de entrada puede expandirse a varios mensajes de salida.
|
|
355
|
+
*/
|
|
356
|
+
private buildOpenAIMessages(turns: AIMessage[]): any[] {
|
|
357
|
+
const result: any[] = [];
|
|
358
|
+
|
|
359
|
+
for (const m of turns) {
|
|
360
|
+
if (typeof m.content === 'string') {
|
|
361
|
+
result.push({ role: m.role, content: m.content });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (m.role === 'assistant') {
|
|
366
|
+
const text = m.content.filter(b => b.type === 'text').map(b => (b as any).text).join('');
|
|
367
|
+
const toolUses = m.content.filter(b => b.type === 'tool_use') as Array<Extract<AIContentBlock, { type: 'tool_use' }>>;
|
|
368
|
+
|
|
369
|
+
const msg: any = { role: 'assistant', content: text || null };
|
|
370
|
+
if (toolUses.length > 0) {
|
|
371
|
+
msg.tool_calls = toolUses.map(t => ({
|
|
372
|
+
id: t.id,
|
|
373
|
+
type: 'function',
|
|
374
|
+
function: { name: t.name, arguments: JSON.stringify(t.input ?? {}) },
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
result.push(msg);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const toolResults = m.content.filter(b => b.type === 'tool_result') as Array<Extract<AIContentBlock, { type: 'tool_result' }>>;
|
|
382
|
+
const text = m.content.filter(b => b.type === 'text').map(b => (b as any).text).join('');
|
|
383
|
+
const images = m.content.filter(b => b.type === 'image') as Array<Extract<AIContentBlock, { type: 'image' }>>;
|
|
384
|
+
|
|
385
|
+
for (const tr of toolResults) {
|
|
386
|
+
result.push({ role: 'tool', tool_call_id: tr.tool_use_id, content: tr.content });
|
|
387
|
+
}
|
|
388
|
+
if (images.length > 0) {
|
|
389
|
+
const parts: any[] = [];
|
|
390
|
+
if (text) parts.push({ type: 'text', text });
|
|
391
|
+
for (const img of images) {
|
|
392
|
+
parts.push({ type: 'image_url', image_url: { url: `data:${img.mediaType};base64,${img.data}` } });
|
|
393
|
+
}
|
|
394
|
+
result.push({ role: m.role, content: parts });
|
|
395
|
+
} else if (text) {
|
|
396
|
+
result.push({ role: m.role, content: text });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Busca hacia atrás en TODO el historial (no solo en el turno actual) el nombre de la
|
|
404
|
+
* función asociada a un tool_use_id, porque Gemini necesita el `name` en el functionResponse
|
|
405
|
+
* y nosotros solo tenemos el id opaco que generamos al parsear la respuesta anterior. */
|
|
406
|
+
private findToolUseName(allMessages: AIMessage[], toolUseId: string): string | undefined {
|
|
407
|
+
for (const m of allMessages) {
|
|
408
|
+
if (!Array.isArray(m.content)) continue;
|
|
409
|
+
const match = m.content.find(b => b.type === 'tool_use' && b.id === toolUseId) as
|
|
410
|
+
| Extract<AIContentBlock, { type: 'tool_use' }>
|
|
411
|
+
| undefined;
|
|
412
|
+
if (match) return match.name;
|
|
413
|
+
}
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* NOTA: la API pública de Gemini espera los resultados de función en un content con
|
|
419
|
+
* role: 'function' (parts: [{ functionResponse: { name, response } }]). Esto puede variar
|
|
420
|
+
* entre versiones de la API — si Google cambia el contrato, este es el único sitio a tocar.
|
|
421
|
+
*/
|
|
422
|
+
private buildGeminiContents(turns: AIMessage[], allMessages: AIMessage[]): any[] {
|
|
423
|
+
const result: any[] = [];
|
|
424
|
+
|
|
425
|
+
for (const m of turns) {
|
|
426
|
+
if (typeof m.content === 'string') {
|
|
427
|
+
result.push({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] });
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (m.role === 'assistant') {
|
|
432
|
+
const parts: any[] = [];
|
|
433
|
+
for (const b of m.content) {
|
|
434
|
+
if (b.type === 'text' && b.text) parts.push({ text: b.text });
|
|
435
|
+
if (b.type === 'tool_use') parts.push({ functionCall: { name: b.name, args: b.input ?? {} } });
|
|
436
|
+
}
|
|
437
|
+
result.push({ role: 'model', parts });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const toolResults = m.content.filter(b => b.type === 'tool_result') as Array<Extract<AIContentBlock, { type: 'tool_result' }>>;
|
|
442
|
+
const textBlocks = m.content.filter(b => b.type === 'text') as Array<Extract<AIContentBlock, { type: 'text' }>>;
|
|
443
|
+
const imageBlocks = m.content.filter(b => b.type === 'image') as Array<Extract<AIContentBlock, { type: 'image' }>>;
|
|
444
|
+
|
|
445
|
+
if (toolResults.length > 0) {
|
|
446
|
+
result.push({
|
|
447
|
+
role: 'function',
|
|
448
|
+
parts: toolResults.map(tr => ({
|
|
449
|
+
functionResponse: {
|
|
450
|
+
name: this.findToolUseName(allMessages, tr.tool_use_id) ?? 'unknown_function',
|
|
451
|
+
response: { content: tr.content },
|
|
452
|
+
},
|
|
453
|
+
})),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (textBlocks.length > 0 || imageBlocks.length > 0) {
|
|
457
|
+
result.push({
|
|
458
|
+
role: 'user',
|
|
459
|
+
parts: [
|
|
460
|
+
...textBlocks.map(b => ({ text: b.text })),
|
|
461
|
+
...imageBlocks.map(b => ({ inlineData: { mimeType: b.mediaType, data: b.data } })),
|
|
462
|
+
],
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return result;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Anthropic's extended thinking requires temperature === 1 and max_tokens strictly greater
|
|
471
|
+
* than budget_tokens; both are enforced here so callers never have to remember it. */
|
|
472
|
+
private anthropicThinkingFields(config: VendorConfig): { thinking?: any; temperature: number; max_tokens: number } {
|
|
473
|
+
const budget = THINKING_BUDGETS[config.thinking];
|
|
474
|
+
if (!budget) return { temperature: config.temperature, max_tokens: config.maxTokens };
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
thinking: { type: 'enabled', budget_tokens: budget },
|
|
478
|
+
temperature: 1,
|
|
479
|
+
max_tokens: Math.max(config.maxTokens, budget + 1024),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** OpenAI's `reasoning_effort` only exists on the 'o' reasoning model family and only accepts
|
|
484
|
+
* low/medium/high (no 'none'/'max') — mapped from our vendor-agnostic ThinkingEffort. Those
|
|
485
|
+
* models also reject a custom `temperature`, so it's omitted whenever reasoning_effort is set. */
|
|
486
|
+
private openaiReasoningFields(config: VendorConfig): { reasoning_effort?: string; temperature?: number } {
|
|
487
|
+
const isReasoningModel = /^o\d/.test(config.model);
|
|
488
|
+
if (!isReasoningModel || config.thinking === 'none') return { temperature: config.temperature };
|
|
489
|
+
|
|
490
|
+
const effortMap: Partial<Record<ThinkingEffort, string>> = { low: 'low', medium: 'medium', high: 'high', max: 'high' };
|
|
491
|
+
return { reasoning_effort: effortMap[config.thinking] };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private buildRequest(config: VendorConfig, messages: AIMessage[], stream: boolean, tools?: AITool[]): VendorRequest {
|
|
495
|
+
const { system, turns } = this.splitSystem(messages);
|
|
496
|
+
const vendorTools = this.buildVendorTools(config, tools);
|
|
497
|
+
|
|
498
|
+
switch (config.vendor) {
|
|
499
|
+
case 'anthropic': {
|
|
500
|
+
const thinkingFields = this.anthropicThinkingFields(config);
|
|
129
501
|
return {
|
|
130
502
|
url: 'https://api.anthropic.com/v1/messages',
|
|
131
503
|
headers: {
|
|
@@ -136,14 +508,16 @@ export class AIVendorManager {
|
|
|
136
508
|
body: {
|
|
137
509
|
model: config.model,
|
|
138
510
|
system,
|
|
139
|
-
messages:
|
|
140
|
-
temperature: config.temperature,
|
|
141
|
-
max_tokens: config.maxTokens,
|
|
511
|
+
messages: this.buildAnthropicMessages(turns),
|
|
142
512
|
stream,
|
|
513
|
+
...thinkingFields,
|
|
514
|
+
...(vendorTools ? { tools: vendorTools } : {}),
|
|
143
515
|
},
|
|
144
516
|
};
|
|
517
|
+
}
|
|
145
518
|
|
|
146
|
-
case 'openai':
|
|
519
|
+
case 'openai': {
|
|
520
|
+
const reasoningFields = this.openaiReasoningFields(config);
|
|
147
521
|
return {
|
|
148
522
|
url: 'https://api.openai.com/v1/chat/completions',
|
|
149
523
|
headers: {
|
|
@@ -154,14 +528,16 @@ export class AIVendorManager {
|
|
|
154
528
|
model: config.model,
|
|
155
529
|
messages: [
|
|
156
530
|
...(system ? [{ role: 'system', content: system }] : []),
|
|
157
|
-
...
|
|
531
|
+
...this.buildOpenAIMessages(turns),
|
|
158
532
|
],
|
|
159
|
-
temperature: config.temperature,
|
|
160
533
|
max_tokens: config.maxTokens,
|
|
161
534
|
stream,
|
|
535
|
+
...reasoningFields,
|
|
162
536
|
...(stream ? { stream_options: { include_usage: true } } : {}),
|
|
537
|
+
...(vendorTools ? { tools: vendorTools } : {}),
|
|
163
538
|
},
|
|
164
539
|
};
|
|
540
|
+
}
|
|
165
541
|
|
|
166
542
|
case 'gemini': {
|
|
167
543
|
const action = stream ? 'streamGenerateContent?alt=sse&' : 'generateContent?';
|
|
@@ -170,14 +546,12 @@ export class AIVendorManager {
|
|
|
170
546
|
headers: { 'content-type': 'application/json' },
|
|
171
547
|
body: {
|
|
172
548
|
...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
|
|
173
|
-
contents:
|
|
174
|
-
role: m.role === 'assistant' ? 'model' : 'user',
|
|
175
|
-
parts: [{ text: m.content }],
|
|
176
|
-
})),
|
|
549
|
+
contents: this.buildGeminiContents(turns, messages),
|
|
177
550
|
generationConfig: {
|
|
178
551
|
temperature: config.temperature,
|
|
179
552
|
maxOutputTokens: config.maxTokens,
|
|
180
553
|
},
|
|
554
|
+
...(vendorTools ? { tools: vendorTools } : {}),
|
|
181
555
|
},
|
|
182
556
|
};
|
|
183
557
|
}
|
|
@@ -214,47 +588,109 @@ export class AIVendorManager {
|
|
|
214
588
|
|
|
215
589
|
private parseCompletion(config: VendorConfig, data: any): AICompletionResult {
|
|
216
590
|
switch (config.vendor) {
|
|
217
|
-
case 'anthropic':
|
|
591
|
+
case 'anthropic': {
|
|
592
|
+
const blocks: AIContentBlock[] = (data.content ?? []).map((b: any) => {
|
|
593
|
+
if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input };
|
|
594
|
+
return { type: 'text', text: b.text ?? '' };
|
|
595
|
+
});
|
|
218
596
|
return {
|
|
219
|
-
content: (
|
|
597
|
+
content: blocks.filter(b => b.type === 'text').map((b: any) => b.text).join(''),
|
|
598
|
+
blocks,
|
|
220
599
|
vendor: config.vendor,
|
|
221
600
|
model: config.model,
|
|
222
601
|
promptTokens: data.usage?.input_tokens,
|
|
223
602
|
completionTokens: data.usage?.output_tokens,
|
|
603
|
+
stopReason: data.stop_reason,
|
|
224
604
|
};
|
|
225
|
-
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
case 'openai': {
|
|
608
|
+
const message = data.choices?.[0]?.message ?? {};
|
|
609
|
+
const blocks: AIContentBlock[] = [];
|
|
610
|
+
|
|
611
|
+
if (message.content) blocks.push({ type: 'text', text: message.content });
|
|
612
|
+
|
|
613
|
+
for (const call of message.tool_calls ?? []) {
|
|
614
|
+
let input: any = {};
|
|
615
|
+
try {
|
|
616
|
+
input = JSON.parse(call.function?.arguments || '{}');
|
|
617
|
+
} catch {
|
|
618
|
+
input = {};
|
|
619
|
+
}
|
|
620
|
+
blocks.push({ type: 'tool_use', id: call.id, name: call.function?.name, input });
|
|
621
|
+
}
|
|
622
|
+
|
|
226
623
|
return {
|
|
227
|
-
content:
|
|
624
|
+
content: message.content ?? '',
|
|
625
|
+
blocks,
|
|
228
626
|
vendor: config.vendor,
|
|
229
627
|
model: config.model,
|
|
230
628
|
promptTokens: data.usage?.prompt_tokens,
|
|
231
629
|
completionTokens: data.usage?.completion_tokens,
|
|
630
|
+
stopReason: data.choices?.[0]?.finish_reason,
|
|
232
631
|
};
|
|
233
|
-
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
case 'gemini': {
|
|
635
|
+
const parts = data.candidates?.[0]?.content?.parts ?? [];
|
|
636
|
+
const blocks: AIContentBlock[] = [];
|
|
637
|
+
let callIndex = 0;
|
|
638
|
+
|
|
639
|
+
for (const p of parts) {
|
|
640
|
+
if (p.text) blocks.push({ type: 'text', text: p.text });
|
|
641
|
+
if (p.functionCall) {
|
|
642
|
+
// Gemini no da ids: generamos uno sintético que solo usamos internamente
|
|
643
|
+
// para poder emparejar el tool_result correspondiente más adelante.
|
|
644
|
+
blocks.push({
|
|
645
|
+
type: 'tool_use',
|
|
646
|
+
id: `gemini-call-${callIndex++}-${p.functionCall.name}`,
|
|
647
|
+
name: p.functionCall.name,
|
|
648
|
+
input: p.functionCall.args ?? {},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
234
653
|
return {
|
|
235
|
-
content: (
|
|
654
|
+
content: blocks.filter(b => b.type === 'text').map((b: any) => b.text).join(''),
|
|
655
|
+
blocks,
|
|
236
656
|
vendor: config.vendor,
|
|
237
657
|
model: config.model,
|
|
238
658
|
promptTokens: data.usageMetadata?.promptTokenCount,
|
|
239
659
|
completionTokens: data.usageMetadata?.candidatesTokenCount,
|
|
660
|
+
stopReason: data.candidates?.[0]?.finishReason,
|
|
240
661
|
};
|
|
662
|
+
}
|
|
241
663
|
}
|
|
242
664
|
}
|
|
243
665
|
|
|
244
666
|
/**
|
|
245
667
|
* @method complete
|
|
246
668
|
* @description Sends a chat-completion request and returns the full response once ready.
|
|
247
|
-
* Retries automatically on rate limiting (429) or server errors (5xx).
|
|
669
|
+
* Retries automatically on rate limiting (429) or server errors (5xx). When `options.tools`
|
|
670
|
+
* is provided, the model may respond with one or more `tool_use` blocks in `result.blocks`
|
|
671
|
+
* instead of (or in addition to) text — the caller is responsible for executing those tools
|
|
672
|
+
* and feeding the results back as a follow-up message with 'tool_result' blocks.
|
|
248
673
|
* @param {AIMessage[]} messages - Conversation messages ('system', 'user', 'assistant').
|
|
249
|
-
* @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens).
|
|
674
|
+
* @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens, tools).
|
|
250
675
|
* @returns {Promise<AICompletionResult>} The generated content plus vendor/model/usage metadata.
|
|
251
676
|
* @example
|
|
252
677
|
* const result = await ai.complete([{ role: 'user', content: 'Explain this bug...' }]);
|
|
253
678
|
* console.log(result.content);
|
|
679
|
+
* @example
|
|
680
|
+
* // Tool use loop
|
|
681
|
+
* let result = await ai.complete(messages, { tools });
|
|
682
|
+
* while (result.blocks.some(b => b.type === 'tool_use')) {
|
|
683
|
+
* messages.push({ role: 'assistant', content: result.blocks });
|
|
684
|
+
* const toolResults = result.blocks
|
|
685
|
+
* .filter(b => b.type === 'tool_use')
|
|
686
|
+
* .map(b => ({ type: 'tool_result' as const, tool_use_id: b.id, content: runTool(b) }));
|
|
687
|
+
* messages.push({ role: 'user', content: toolResults });
|
|
688
|
+
* result = await ai.complete(messages, { tools });
|
|
689
|
+
* }
|
|
254
690
|
*/
|
|
255
691
|
public async complete(messages: AIMessage[], options?: AICompletionOptions): Promise<AICompletionResult> {
|
|
256
692
|
const config = this.resolveConfig(options);
|
|
257
|
-
const { url, headers, body } = this.buildRequest(config, messages, false);
|
|
693
|
+
const { url, headers, body } = this.buildRequest(config, messages, false, options?.tools);
|
|
258
694
|
|
|
259
695
|
try {
|
|
260
696
|
const response = await this.withRetries(
|
|
@@ -278,6 +714,11 @@ export class AIVendorManager {
|
|
|
278
714
|
* @description Sends a chat-completion request and streams the response as it is generated.
|
|
279
715
|
* Retries are only applied before any data has been received; once streaming has started,
|
|
280
716
|
* failures are surfaced immediately to avoid emitting duplicated content.
|
|
717
|
+
*
|
|
718
|
+
* Tool use is NOT supported here: streaming tool calls arrive as incremental JSON fragments
|
|
719
|
+
* (Anthropic's `input_json_delta`, OpenAI's indexed `tool_calls` deltas, Gemini's partial
|
|
720
|
+
* function-call parts) and this method does not accumulate them. Passing `options.tools`
|
|
721
|
+
* throws immediately rather than silently dropping tool calls the model might make mid-stream.
|
|
281
722
|
* @param {AIMessage[]} messages - Conversation messages ('system', 'user', 'assistant').
|
|
282
723
|
* @param {(chunk: string) => void} onChunk - Called with each incremental text fragment.
|
|
283
724
|
* @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens).
|
|
@@ -290,6 +731,14 @@ export class AIVendorManager {
|
|
|
290
731
|
onChunk: (chunk: string) => void,
|
|
291
732
|
options?: AICompletionOptions
|
|
292
733
|
): Promise<AICompletionResult> {
|
|
734
|
+
if (options?.tools && options.tools.length > 0) {
|
|
735
|
+
throw new TyrError(
|
|
736
|
+
'Tool use is not supported in streaming mode yet',
|
|
737
|
+
null,
|
|
738
|
+
'Use complete() instead of stream() when passing tools.'
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
293
742
|
const config = this.resolveConfig(options);
|
|
294
743
|
const { url, headers, body } = this.buildRequest(config, messages, true);
|
|
295
744
|
|
|
@@ -381,8 +830,8 @@ export class AIVendorManager {
|
|
|
381
830
|
}
|
|
382
831
|
}
|
|
383
832
|
|
|
384
|
-
return { content, vendor: config.vendor, model: config.model, promptTokens, completionTokens };
|
|
833
|
+
return { content, blocks: [{ type: 'text', text: content }], vendor: config.vendor, model: config.model, promptTokens, completionTokens };
|
|
385
834
|
}
|
|
386
835
|
}
|
|
387
836
|
|
|
388
|
-
export const AIVendorManagerTests = {};
|
|
837
|
+
export const AIVendorManagerTests = {};
|