@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.
@@ -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
- content: string;
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.filter(m => m.role === 'system').map(m => m.content).join('\n\n');
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
- private buildRequest(config: VendorConfig, messages: AIMessage[], stream: boolean): VendorRequest {
125
- const { system, turns } = this.splitSystem(messages);
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: turns.map(m => ({ role: m.role, content: m.content })),
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
- ...turns.map(m => ({ role: m.role, content: m.content })),
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: turns.map(m => ({
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: (data.content ?? []).map((b: any) => b.text ?? '').join(''),
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
- case 'openai':
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: data.choices?.[0]?.message?.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
- case 'gemini':
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: (data.candidates?.[0]?.content?.parts ?? []).map((p: any) => p.text ?? '').join(''),
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 = {};