@orxataguy/tyr 1.0.31 → 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.
@@ -17,11 +17,15 @@ export type AIRole = 'system' | 'user' | 'assistant';
17
17
  * formato (en Gemini, por ejemplo, es un id sintético generado por este manager, ya que la
18
18
  * API de Gemini no da ids reales para las function calls).
19
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.
20
23
  */
21
24
  export type AIContentBlock =
22
25
  | { type: 'text'; text: string }
23
26
  | { type: 'tool_use'; id: string; name: string; input: any }
24
- | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean };
27
+ | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
28
+ | { type: 'image'; mediaType: string; data: string };
25
29
 
26
30
  export interface AIMessage {
27
31
  role: AIRole;
@@ -37,6 +41,24 @@ export interface AITool {
37
41
  input_schema: any;
38
42
  }
39
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
+
40
62
  export interface AICompletionOptions {
41
63
  vendor?: AIVendor;
42
64
  model?: string;
@@ -46,6 +68,9 @@ export interface AICompletionOptions {
46
68
  /** Herramientas disponibles para que el modelo las invoque. Solo soportado en complete(),
47
69
  * no en stream() — ver el guard al inicio de stream(). */
48
70
  tools?: AITool[];
71
+ /** Nivel de extended thinking / reasoning effort. Ver ThinkingEffort. 'none' (o ausente)
72
+ * desactiva el thinking explícitamente. */
73
+ thinking?: ThinkingEffort;
49
74
  }
50
75
 
51
76
  export interface AICompletionResult {
@@ -72,6 +97,7 @@ interface VendorConfig {
72
97
  temperature: number;
73
98
  maxTokens: number;
74
99
  maxRetries: number;
100
+ thinking: ThinkingEffort;
75
101
  }
76
102
 
77
103
  interface VendorRequest {
@@ -97,6 +123,58 @@ const DEFAULT_MAX_TOKENS = 4096;
97
123
  const DEFAULT_MAX_RETRIES = 3;
98
124
  const RETRY_BASE_DELAY_MS = 500;
99
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
+
100
178
  /**
101
179
  * @class AIVendorManager
102
180
  * @description Unified client for AI chat-completion APIs (Anthropic, OpenAI, Gemini).
@@ -154,9 +232,79 @@ export class AIVendorManager {
154
232
  temperature: options?.temperature ?? getEnvDouble('AI_TEMPERATURE', DEFAULT_TEMPERATURE),
155
233
  maxTokens: options?.maxTokens ?? getEnvInt('AI_MAX_TOKENS', DEFAULT_MAX_TOKENS),
156
234
  maxRetries: options?.maxRetries ?? getEnvInt('AI_MAX_RETRIES', DEFAULT_MAX_RETRIES),
235
+ thinking: options?.thinking ?? 'none',
236
+ };
237
+ }
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,
157
270
  };
158
271
  }
159
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
+
160
308
  private splitSystem(messages: AIMessage[]): { system: string; turns: AIMessage[] } {
161
309
  const system = messages
162
310
  .filter(m => m.role === 'system')
@@ -190,6 +338,7 @@ export class AIVendorManager {
190
338
  if (typeof content === 'string') return content;
191
339
  return content.map(block => {
192
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 } };
193
342
  if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input };
194
343
  return { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content, ...(block.is_error ? { is_error: true } : {}) };
195
344
  });
@@ -231,11 +380,19 @@ export class AIVendorManager {
231
380
 
232
381
  const toolResults = m.content.filter(b => b.type === 'tool_result') as Array<Extract<AIContentBlock, { type: 'tool_result' }>>;
233
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' }>>;
234
384
 
235
385
  for (const tr of toolResults) {
236
386
  result.push({ role: 'tool', tool_call_id: tr.tool_use_id, content: tr.content });
237
387
  }
238
- if (text) {
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) {
239
396
  result.push({ role: m.role, content: text });
240
397
  }
241
398
  }
@@ -283,6 +440,7 @@ export class AIVendorManager {
283
440
 
284
441
  const toolResults = m.content.filter(b => b.type === 'tool_result') as Array<Extract<AIContentBlock, { type: 'tool_result' }>>;
285
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' }>>;
286
444
 
287
445
  if (toolResults.length > 0) {
288
446
  result.push({
@@ -295,20 +453,51 @@ export class AIVendorManager {
295
453
  })),
296
454
  });
297
455
  }
298
- if (textBlocks.length > 0) {
299
- result.push({ role: 'user', parts: textBlocks.map(b => ({ text: b.text })) });
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
+ });
300
464
  }
301
465
  }
302
466
 
303
467
  return result;
304
468
  }
305
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
+
306
494
  private buildRequest(config: VendorConfig, messages: AIMessage[], stream: boolean, tools?: AITool[]): VendorRequest {
307
495
  const { system, turns } = this.splitSystem(messages);
308
496
  const vendorTools = this.buildVendorTools(config, tools);
309
497
 
310
498
  switch (config.vendor) {
311
- case 'anthropic':
499
+ case 'anthropic': {
500
+ const thinkingFields = this.anthropicThinkingFields(config);
312
501
  return {
313
502
  url: 'https://api.anthropic.com/v1/messages',
314
503
  headers: {
@@ -320,14 +509,15 @@ export class AIVendorManager {
320
509
  model: config.model,
321
510
  system,
322
511
  messages: this.buildAnthropicMessages(turns),
323
- temperature: config.temperature,
324
- max_tokens: config.maxTokens,
325
512
  stream,
513
+ ...thinkingFields,
326
514
  ...(vendorTools ? { tools: vendorTools } : {}),
327
515
  },
328
516
  };
517
+ }
329
518
 
330
- case 'openai':
519
+ case 'openai': {
520
+ const reasoningFields = this.openaiReasoningFields(config);
331
521
  return {
332
522
  url: 'https://api.openai.com/v1/chat/completions',
333
523
  headers: {
@@ -340,13 +530,14 @@ export class AIVendorManager {
340
530
  ...(system ? [{ role: 'system', content: system }] : []),
341
531
  ...this.buildOpenAIMessages(turns),
342
532
  ],
343
- temperature: config.temperature,
344
533
  max_tokens: config.maxTokens,
345
534
  stream,
535
+ ...reasoningFields,
346
536
  ...(stream ? { stream_options: { include_usage: true } } : {}),
347
537
  ...(vendorTools ? { tools: vendorTools } : {}),
348
538
  },
349
539
  };
540
+ }
350
541
 
351
542
  case 'gemini': {
352
543
  const action = stream ? 'streamGenerateContent?alt=sse&' : 'generateContent?';