@orxataguy/tyr 1.0.29 → 1.0.31
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 +19 -10
- package/package.json +1 -1
- package/src/core/Container.ts +14 -0
- package/src/core/sys/config.ts +11 -2
- package/src/core/util/getenv.ts +57 -0
- package/src/lib/AIContextManager.ts +302 -0
- package/src/lib/AIVendorManager.ts +646 -0
- package/src/lib/FileSystemManager.ts +22 -2
- package/src/lib/JiraManager.ts +5 -2
- package/src/lib/MongoManager.ts +3 -2
- package/src/lib/PromptTemplateManager.ts +142 -0
- package/src/lib/SQLManager.ts +5 -4
- package/src/lib/TokenManager.ts +169 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
import { Logger } from '../core/Logger.js';
|
|
4
|
+
import { TyrError } from '../core/TyrError.js';
|
|
5
|
+
|
|
6
|
+
import {getEnvString, getEnvInt, getEnvDouble} from '../core/util/getenv.js';
|
|
7
|
+
|
|
8
|
+
export type AIVendor = 'anthropic' | 'openai' | 'gemini';
|
|
9
|
+
export type AIRole = 'system' | 'user' | 'assistant';
|
|
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
|
+
*/
|
|
21
|
+
export type AIContentBlock =
|
|
22
|
+
| { type: 'text'; text: string }
|
|
23
|
+
| { type: 'tool_use'; id: string; name: string; input: any }
|
|
24
|
+
| { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean };
|
|
25
|
+
|
|
26
|
+
export interface AIMessage {
|
|
27
|
+
role: AIRole;
|
|
28
|
+
/** Texto plano, o una lista de bloques cuando el mensaje incluye tool_use / tool_result. */
|
|
29
|
+
content: string | AIContentBlock[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Definición de herramienta en formato JSON-schema al estilo Anthropic; se traduce internamente
|
|
33
|
+
* al formato de cada vendor (function-calling de OpenAI, functionDeclarations de Gemini). */
|
|
34
|
+
export interface AITool {
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
input_schema: any;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AICompletionOptions {
|
|
41
|
+
vendor?: AIVendor;
|
|
42
|
+
model?: string;
|
|
43
|
+
temperature?: number;
|
|
44
|
+
maxTokens?: number;
|
|
45
|
+
maxRetries?: number;
|
|
46
|
+
/** Herramientas disponibles para que el modelo las invoque. Solo soportado en complete(),
|
|
47
|
+
* no en stream() — ver el guard al inicio de stream(). */
|
|
48
|
+
tools?: AITool[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AICompletionResult {
|
|
52
|
+
/** Texto plano concatenado de todos los bloques de tipo 'text' (conveniencia; equivalente al
|
|
53
|
+
* comportamiento anterior de esta clase, antes de soportar tools). */
|
|
54
|
+
content: string;
|
|
55
|
+
/** Contenido completo y normalizado, incluyendo bloques tool_use si el modelo pidió alguno.
|
|
56
|
+
* Necesario para reconstruir el mensaje de assistant en el siguiente turno de un bucle
|
|
57
|
+
* agente. */
|
|
58
|
+
blocks: AIContentBlock[];
|
|
59
|
+
vendor: AIVendor;
|
|
60
|
+
model: string;
|
|
61
|
+
promptTokens?: number;
|
|
62
|
+
completionTokens?: number;
|
|
63
|
+
/** Motivo de parada normalizado tal como lo reporta cada vendor (stop_reason / finish_reason
|
|
64
|
+
* / finishReason), sin normalizar entre vendors — úsalo solo informativamente. */
|
|
65
|
+
stopReason?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface VendorConfig {
|
|
69
|
+
vendor: AIVendor;
|
|
70
|
+
apiKey: string;
|
|
71
|
+
model: string;
|
|
72
|
+
temperature: number;
|
|
73
|
+
maxTokens: number;
|
|
74
|
+
maxRetries: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface VendorRequest {
|
|
78
|
+
url: string;
|
|
79
|
+
headers: Record<string, string>;
|
|
80
|
+
body: any;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DEFAULT_MODELS: Record<AIVendor, string> = {
|
|
84
|
+
anthropic: 'claude-sonnet-5',
|
|
85
|
+
openai: 'gpt-4o-mini',
|
|
86
|
+
gemini: 'gemini-2.5-flash',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const API_KEY_ENV: Record<AIVendor, string> = {
|
|
90
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
91
|
+
openai: 'OPENAI_API_KEY',
|
|
92
|
+
gemini: 'GEMINI_API_KEY',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const DEFAULT_TEMPERATURE = 0.3;
|
|
96
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
97
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
98
|
+
const RETRY_BASE_DELAY_MS = 500;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @class AIVendorManager
|
|
102
|
+
* @description Unified client for AI chat-completion APIs (Anthropic, OpenAI, Gemini).
|
|
103
|
+
* Resolves the API key and technical defaults (model, temperature, max tokens) from
|
|
104
|
+
* environment variables / Tyr configuration, retries transient failures with exponential
|
|
105
|
+
* backoff, and supports both blocking and streaming responses.
|
|
106
|
+
*
|
|
107
|
+
* Tool use (function calling) is supported in `complete()` for all three vendors, behind a
|
|
108
|
+
* vendor-agnostic representation (`AITool` / `AIContentBlock`). Each vendor's wire format is
|
|
109
|
+
* different — this class does the translation both ways (request and response) so callers never
|
|
110
|
+
* need to know which vendor is active. `stream()` does not support tools yet (see the guard at
|
|
111
|
+
* the top of that method).
|
|
112
|
+
*
|
|
113
|
+
* Environment variables:
|
|
114
|
+
* AI_VENDOR – 'anthropic' | 'openai' | 'gemini' (default: 'anthropic')
|
|
115
|
+
* ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY – API key for the selected vendor
|
|
116
|
+
* AI_MODEL – overrides the vendor's default model
|
|
117
|
+
* AI_TEMPERATURE – overrides the default temperature (0.3)
|
|
118
|
+
* AI_MAX_TOKENS – overrides the default max output tokens (4096)
|
|
119
|
+
* AI_MAX_RETRIES – overrides the default retry count (3)
|
|
120
|
+
*/
|
|
121
|
+
export class AIVendorManager {
|
|
122
|
+
private logger: Logger;
|
|
123
|
+
|
|
124
|
+
constructor(logger: Logger) {
|
|
125
|
+
this.logger = logger;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private resolveConfig(options?: AICompletionOptions): VendorConfig {
|
|
129
|
+
const vendor = (options?.vendor ?? (getEnvString('AI_VENDOR') as AIVendor | undefined) ?? 'anthropic')
|
|
130
|
+
.toString()
|
|
131
|
+
.toLowerCase() as AIVendor;
|
|
132
|
+
|
|
133
|
+
if (!DEFAULT_MODELS[vendor]) {
|
|
134
|
+
throw new TyrError(
|
|
135
|
+
`Unsupported AI vendor: '${vendor}'`,
|
|
136
|
+
null,
|
|
137
|
+
`Set AI_VENDOR to one of: ${Object.keys(DEFAULT_MODELS).join(', ')}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const apiKey = getEnvString(API_KEY_ENV[vendor]);
|
|
142
|
+
if (!apiKey) {
|
|
143
|
+
throw new TyrError(
|
|
144
|
+
`Missing API key for vendor '${vendor}'`,
|
|
145
|
+
null,
|
|
146
|
+
`Set ${API_KEY_ENV[vendor]} in ~/.tyr/.env`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
vendor,
|
|
152
|
+
apiKey,
|
|
153
|
+
model: options?.model ?? getEnvString('AI_MODEL') ?? DEFAULT_MODELS[vendor],
|
|
154
|
+
temperature: options?.temperature ?? getEnvDouble('AI_TEMPERATURE', DEFAULT_TEMPERATURE),
|
|
155
|
+
maxTokens: options?.maxTokens ?? getEnvInt('AI_MAX_TOKENS', DEFAULT_MAX_TOKENS),
|
|
156
|
+
maxRetries: options?.maxRetries ?? getEnvInt('AI_MAX_RETRIES', DEFAULT_MAX_RETRIES),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private splitSystem(messages: AIMessage[]): { system: string; turns: AIMessage[] } {
|
|
161
|
+
const system = messages
|
|
162
|
+
.filter(m => m.role === 'system')
|
|
163
|
+
.map(m => (typeof m.content === 'string' ? m.content : m.content.map(b => (b.type === 'text' ? b.text : '')).join('')))
|
|
164
|
+
.join('\n\n');
|
|
165
|
+
const turns = messages.filter(m => m.role !== 'system');
|
|
166
|
+
return { system, turns };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Traducción de herramientas por vendor -------------------------------------------------
|
|
170
|
+
|
|
171
|
+
private buildVendorTools(config: VendorConfig, tools?: AITool[]): any {
|
|
172
|
+
if (!tools || tools.length === 0) return undefined;
|
|
173
|
+
|
|
174
|
+
switch (config.vendor) {
|
|
175
|
+
case 'anthropic':
|
|
176
|
+
return tools.map(t => ({ name: t.name, description: t.description, input_schema: t.input_schema }));
|
|
177
|
+
case 'openai':
|
|
178
|
+
return tools.map(t => ({
|
|
179
|
+
type: 'function',
|
|
180
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
181
|
+
}));
|
|
182
|
+
case 'gemini':
|
|
183
|
+
return [{ functionDeclarations: tools.map(t => ({ name: t.name, description: t.description, parameters: t.input_schema })) }];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Traducción de mensajes por vendor ------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
private toAnthropicContent(content: string | AIContentBlock[]): any {
|
|
190
|
+
if (typeof content === 'string') return content;
|
|
191
|
+
return content.map(block => {
|
|
192
|
+
if (block.type === 'text') return { type: 'text', text: block.text };
|
|
193
|
+
if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input };
|
|
194
|
+
return { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content, ...(block.is_error ? { is_error: true } : {}) };
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private buildAnthropicMessages(turns: AIMessage[]): any[] {
|
|
199
|
+
return turns.map(m => ({ role: m.role, content: this.toAnthropicContent(m.content) }));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* OpenAI no tiene un bloque "tool_result" dentro de un mensaje de usuario: cada resultado de
|
|
204
|
+
* herramienta tiene que ir en su propio mensaje con role: 'tool'. Por eso, a diferencia de
|
|
205
|
+
* Anthropic, un único AIMessage de entrada puede expandirse a varios mensajes de salida.
|
|
206
|
+
*/
|
|
207
|
+
private buildOpenAIMessages(turns: AIMessage[]): any[] {
|
|
208
|
+
const result: any[] = [];
|
|
209
|
+
|
|
210
|
+
for (const m of turns) {
|
|
211
|
+
if (typeof m.content === 'string') {
|
|
212
|
+
result.push({ role: m.role, content: m.content });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (m.role === 'assistant') {
|
|
217
|
+
const text = m.content.filter(b => b.type === 'text').map(b => (b as any).text).join('');
|
|
218
|
+
const toolUses = m.content.filter(b => b.type === 'tool_use') as Array<Extract<AIContentBlock, { type: 'tool_use' }>>;
|
|
219
|
+
|
|
220
|
+
const msg: any = { role: 'assistant', content: text || null };
|
|
221
|
+
if (toolUses.length > 0) {
|
|
222
|
+
msg.tool_calls = toolUses.map(t => ({
|
|
223
|
+
id: t.id,
|
|
224
|
+
type: 'function',
|
|
225
|
+
function: { name: t.name, arguments: JSON.stringify(t.input ?? {}) },
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
result.push(msg);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const toolResults = m.content.filter(b => b.type === 'tool_result') as Array<Extract<AIContentBlock, { type: 'tool_result' }>>;
|
|
233
|
+
const text = m.content.filter(b => b.type === 'text').map(b => (b as any).text).join('');
|
|
234
|
+
|
|
235
|
+
for (const tr of toolResults) {
|
|
236
|
+
result.push({ role: 'tool', tool_call_id: tr.tool_use_id, content: tr.content });
|
|
237
|
+
}
|
|
238
|
+
if (text) {
|
|
239
|
+
result.push({ role: m.role, content: text });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Busca hacia atrás en TODO el historial (no solo en el turno actual) el nombre de la
|
|
247
|
+
* función asociada a un tool_use_id, porque Gemini necesita el `name` en el functionResponse
|
|
248
|
+
* y nosotros solo tenemos el id opaco que generamos al parsear la respuesta anterior. */
|
|
249
|
+
private findToolUseName(allMessages: AIMessage[], toolUseId: string): string | undefined {
|
|
250
|
+
for (const m of allMessages) {
|
|
251
|
+
if (!Array.isArray(m.content)) continue;
|
|
252
|
+
const match = m.content.find(b => b.type === 'tool_use' && b.id === toolUseId) as
|
|
253
|
+
| Extract<AIContentBlock, { type: 'tool_use' }>
|
|
254
|
+
| undefined;
|
|
255
|
+
if (match) return match.name;
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* NOTA: la API pública de Gemini espera los resultados de función en un content con
|
|
262
|
+
* role: 'function' (parts: [{ functionResponse: { name, response } }]). Esto puede variar
|
|
263
|
+
* entre versiones de la API — si Google cambia el contrato, este es el único sitio a tocar.
|
|
264
|
+
*/
|
|
265
|
+
private buildGeminiContents(turns: AIMessage[], allMessages: AIMessage[]): any[] {
|
|
266
|
+
const result: any[] = [];
|
|
267
|
+
|
|
268
|
+
for (const m of turns) {
|
|
269
|
+
if (typeof m.content === 'string') {
|
|
270
|
+
result.push({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (m.role === 'assistant') {
|
|
275
|
+
const parts: any[] = [];
|
|
276
|
+
for (const b of m.content) {
|
|
277
|
+
if (b.type === 'text' && b.text) parts.push({ text: b.text });
|
|
278
|
+
if (b.type === 'tool_use') parts.push({ functionCall: { name: b.name, args: b.input ?? {} } });
|
|
279
|
+
}
|
|
280
|
+
result.push({ role: 'model', parts });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const toolResults = m.content.filter(b => b.type === 'tool_result') as Array<Extract<AIContentBlock, { type: 'tool_result' }>>;
|
|
285
|
+
const textBlocks = m.content.filter(b => b.type === 'text') as Array<Extract<AIContentBlock, { type: 'text' }>>;
|
|
286
|
+
|
|
287
|
+
if (toolResults.length > 0) {
|
|
288
|
+
result.push({
|
|
289
|
+
role: 'function',
|
|
290
|
+
parts: toolResults.map(tr => ({
|
|
291
|
+
functionResponse: {
|
|
292
|
+
name: this.findToolUseName(allMessages, tr.tool_use_id) ?? 'unknown_function',
|
|
293
|
+
response: { content: tr.content },
|
|
294
|
+
},
|
|
295
|
+
})),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (textBlocks.length > 0) {
|
|
299
|
+
result.push({ role: 'user', parts: textBlocks.map(b => ({ text: b.text })) });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private buildRequest(config: VendorConfig, messages: AIMessage[], stream: boolean, tools?: AITool[]): VendorRequest {
|
|
307
|
+
const { system, turns } = this.splitSystem(messages);
|
|
308
|
+
const vendorTools = this.buildVendorTools(config, tools);
|
|
309
|
+
|
|
310
|
+
switch (config.vendor) {
|
|
311
|
+
case 'anthropic':
|
|
312
|
+
return {
|
|
313
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
314
|
+
headers: {
|
|
315
|
+
'x-api-key': config.apiKey,
|
|
316
|
+
'anthropic-version': '2023-06-01',
|
|
317
|
+
'content-type': 'application/json',
|
|
318
|
+
},
|
|
319
|
+
body: {
|
|
320
|
+
model: config.model,
|
|
321
|
+
system,
|
|
322
|
+
messages: this.buildAnthropicMessages(turns),
|
|
323
|
+
temperature: config.temperature,
|
|
324
|
+
max_tokens: config.maxTokens,
|
|
325
|
+
stream,
|
|
326
|
+
...(vendorTools ? { tools: vendorTools } : {}),
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
case 'openai':
|
|
331
|
+
return {
|
|
332
|
+
url: 'https://api.openai.com/v1/chat/completions',
|
|
333
|
+
headers: {
|
|
334
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
335
|
+
'content-type': 'application/json',
|
|
336
|
+
},
|
|
337
|
+
body: {
|
|
338
|
+
model: config.model,
|
|
339
|
+
messages: [
|
|
340
|
+
...(system ? [{ role: 'system', content: system }] : []),
|
|
341
|
+
...this.buildOpenAIMessages(turns),
|
|
342
|
+
],
|
|
343
|
+
temperature: config.temperature,
|
|
344
|
+
max_tokens: config.maxTokens,
|
|
345
|
+
stream,
|
|
346
|
+
...(stream ? { stream_options: { include_usage: true } } : {}),
|
|
347
|
+
...(vendorTools ? { tools: vendorTools } : {}),
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
case 'gemini': {
|
|
352
|
+
const action = stream ? 'streamGenerateContent?alt=sse&' : 'generateContent?';
|
|
353
|
+
return {
|
|
354
|
+
url: `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:${action}key=${config.apiKey}`,
|
|
355
|
+
headers: { 'content-type': 'application/json' },
|
|
356
|
+
body: {
|
|
357
|
+
...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
|
|
358
|
+
contents: this.buildGeminiContents(turns, messages),
|
|
359
|
+
generationConfig: {
|
|
360
|
+
temperature: config.temperature,
|
|
361
|
+
maxOutputTokens: config.maxTokens,
|
|
362
|
+
},
|
|
363
|
+
...(vendorTools ? { tools: vendorTools } : {}),
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
default:
|
|
369
|
+
throw new TyrError(`Unsupported AI vendor: '${config.vendor}'`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private sleep(ms: number): Promise<void> {
|
|
374
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private isRetryable(status: number | undefined): boolean {
|
|
378
|
+
return status === 429 || (status !== undefined && status >= 500);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async withRetries<T>(fn: () => Promise<T>, maxRetries: number): Promise<T> {
|
|
382
|
+
let lastError: unknown;
|
|
383
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
384
|
+
try {
|
|
385
|
+
return await fn();
|
|
386
|
+
} catch (e: any) {
|
|
387
|
+
lastError = e;
|
|
388
|
+
const status = e?.response?.status;
|
|
389
|
+
if (attempt === maxRetries || !this.isRetryable(status)) throw e;
|
|
390
|
+
const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
|
|
391
|
+
this.logger.warn(`AI request failed (status ${status ?? 'unknown'}). Retrying in ${delay}ms...`);
|
|
392
|
+
await this.sleep(delay);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
throw lastError;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private parseCompletion(config: VendorConfig, data: any): AICompletionResult {
|
|
399
|
+
switch (config.vendor) {
|
|
400
|
+
case 'anthropic': {
|
|
401
|
+
const blocks: AIContentBlock[] = (data.content ?? []).map((b: any) => {
|
|
402
|
+
if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input };
|
|
403
|
+
return { type: 'text', text: b.text ?? '' };
|
|
404
|
+
});
|
|
405
|
+
return {
|
|
406
|
+
content: blocks.filter(b => b.type === 'text').map((b: any) => b.text).join(''),
|
|
407
|
+
blocks,
|
|
408
|
+
vendor: config.vendor,
|
|
409
|
+
model: config.model,
|
|
410
|
+
promptTokens: data.usage?.input_tokens,
|
|
411
|
+
completionTokens: data.usage?.output_tokens,
|
|
412
|
+
stopReason: data.stop_reason,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
case 'openai': {
|
|
417
|
+
const message = data.choices?.[0]?.message ?? {};
|
|
418
|
+
const blocks: AIContentBlock[] = [];
|
|
419
|
+
|
|
420
|
+
if (message.content) blocks.push({ type: 'text', text: message.content });
|
|
421
|
+
|
|
422
|
+
for (const call of message.tool_calls ?? []) {
|
|
423
|
+
let input: any = {};
|
|
424
|
+
try {
|
|
425
|
+
input = JSON.parse(call.function?.arguments || '{}');
|
|
426
|
+
} catch {
|
|
427
|
+
input = {};
|
|
428
|
+
}
|
|
429
|
+
blocks.push({ type: 'tool_use', id: call.id, name: call.function?.name, input });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
content: message.content ?? '',
|
|
434
|
+
blocks,
|
|
435
|
+
vendor: config.vendor,
|
|
436
|
+
model: config.model,
|
|
437
|
+
promptTokens: data.usage?.prompt_tokens,
|
|
438
|
+
completionTokens: data.usage?.completion_tokens,
|
|
439
|
+
stopReason: data.choices?.[0]?.finish_reason,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
case 'gemini': {
|
|
444
|
+
const parts = data.candidates?.[0]?.content?.parts ?? [];
|
|
445
|
+
const blocks: AIContentBlock[] = [];
|
|
446
|
+
let callIndex = 0;
|
|
447
|
+
|
|
448
|
+
for (const p of parts) {
|
|
449
|
+
if (p.text) blocks.push({ type: 'text', text: p.text });
|
|
450
|
+
if (p.functionCall) {
|
|
451
|
+
// Gemini no da ids: generamos uno sintético que solo usamos internamente
|
|
452
|
+
// para poder emparejar el tool_result correspondiente más adelante.
|
|
453
|
+
blocks.push({
|
|
454
|
+
type: 'tool_use',
|
|
455
|
+
id: `gemini-call-${callIndex++}-${p.functionCall.name}`,
|
|
456
|
+
name: p.functionCall.name,
|
|
457
|
+
input: p.functionCall.args ?? {},
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
content: blocks.filter(b => b.type === 'text').map((b: any) => b.text).join(''),
|
|
464
|
+
blocks,
|
|
465
|
+
vendor: config.vendor,
|
|
466
|
+
model: config.model,
|
|
467
|
+
promptTokens: data.usageMetadata?.promptTokenCount,
|
|
468
|
+
completionTokens: data.usageMetadata?.candidatesTokenCount,
|
|
469
|
+
stopReason: data.candidates?.[0]?.finishReason,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* @method complete
|
|
477
|
+
* @description Sends a chat-completion request and returns the full response once ready.
|
|
478
|
+
* Retries automatically on rate limiting (429) or server errors (5xx). When `options.tools`
|
|
479
|
+
* is provided, the model may respond with one or more `tool_use` blocks in `result.blocks`
|
|
480
|
+
* instead of (or in addition to) text — the caller is responsible for executing those tools
|
|
481
|
+
* and feeding the results back as a follow-up message with 'tool_result' blocks.
|
|
482
|
+
* @param {AIMessage[]} messages - Conversation messages ('system', 'user', 'assistant').
|
|
483
|
+
* @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens, tools).
|
|
484
|
+
* @returns {Promise<AICompletionResult>} The generated content plus vendor/model/usage metadata.
|
|
485
|
+
* @example
|
|
486
|
+
* const result = await ai.complete([{ role: 'user', content: 'Explain this bug...' }]);
|
|
487
|
+
* console.log(result.content);
|
|
488
|
+
* @example
|
|
489
|
+
* // Tool use loop
|
|
490
|
+
* let result = await ai.complete(messages, { tools });
|
|
491
|
+
* while (result.blocks.some(b => b.type === 'tool_use')) {
|
|
492
|
+
* messages.push({ role: 'assistant', content: result.blocks });
|
|
493
|
+
* const toolResults = result.blocks
|
|
494
|
+
* .filter(b => b.type === 'tool_use')
|
|
495
|
+
* .map(b => ({ type: 'tool_result' as const, tool_use_id: b.id, content: runTool(b) }));
|
|
496
|
+
* messages.push({ role: 'user', content: toolResults });
|
|
497
|
+
* result = await ai.complete(messages, { tools });
|
|
498
|
+
* }
|
|
499
|
+
*/
|
|
500
|
+
public async complete(messages: AIMessage[], options?: AICompletionOptions): Promise<AICompletionResult> {
|
|
501
|
+
const config = this.resolveConfig(options);
|
|
502
|
+
const { url, headers, body } = this.buildRequest(config, messages, false, options?.tools);
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
const response = await this.withRetries(
|
|
506
|
+
() => axios.post(url, body, { headers }),
|
|
507
|
+
config.maxRetries
|
|
508
|
+
);
|
|
509
|
+
return this.parseCompletion(config, response.data);
|
|
510
|
+
} catch (e: any) {
|
|
511
|
+
if (e instanceof TyrError) throw e;
|
|
512
|
+
const status = e?.response?.status;
|
|
513
|
+
throw new TyrError(
|
|
514
|
+
`AI request to '${config.vendor}' failed (${status ?? 'network error'})`,
|
|
515
|
+
e,
|
|
516
|
+
'Check your API key, network connection, and the vendor status page.'
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* @method stream
|
|
523
|
+
* @description Sends a chat-completion request and streams the response as it is generated.
|
|
524
|
+
* Retries are only applied before any data has been received; once streaming has started,
|
|
525
|
+
* failures are surfaced immediately to avoid emitting duplicated content.
|
|
526
|
+
*
|
|
527
|
+
* Tool use is NOT supported here: streaming tool calls arrive as incremental JSON fragments
|
|
528
|
+
* (Anthropic's `input_json_delta`, OpenAI's indexed `tool_calls` deltas, Gemini's partial
|
|
529
|
+
* function-call parts) and this method does not accumulate them. Passing `options.tools`
|
|
530
|
+
* throws immediately rather than silently dropping tool calls the model might make mid-stream.
|
|
531
|
+
* @param {AIMessage[]} messages - Conversation messages ('system', 'user', 'assistant').
|
|
532
|
+
* @param {(chunk: string) => void} onChunk - Called with each incremental text fragment.
|
|
533
|
+
* @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens).
|
|
534
|
+
* @returns {Promise<AICompletionResult>} The full accumulated content plus vendor/model/usage metadata.
|
|
535
|
+
* @example
|
|
536
|
+
* const result = await ai.stream(messages, (chunk) => process.stdout.write(chunk));
|
|
537
|
+
*/
|
|
538
|
+
public async stream(
|
|
539
|
+
messages: AIMessage[],
|
|
540
|
+
onChunk: (chunk: string) => void,
|
|
541
|
+
options?: AICompletionOptions
|
|
542
|
+
): Promise<AICompletionResult> {
|
|
543
|
+
if (options?.tools && options.tools.length > 0) {
|
|
544
|
+
throw new TyrError(
|
|
545
|
+
'Tool use is not supported in streaming mode yet',
|
|
546
|
+
null,
|
|
547
|
+
'Use complete() instead of stream() when passing tools.'
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const config = this.resolveConfig(options);
|
|
552
|
+
const { url, headers, body } = this.buildRequest(config, messages, true);
|
|
553
|
+
|
|
554
|
+
let content = '';
|
|
555
|
+
let promptTokens: number | undefined;
|
|
556
|
+
let completionTokens: number | undefined;
|
|
557
|
+
let hasStreamedAny = false;
|
|
558
|
+
|
|
559
|
+
const attemptStream = async (): Promise<void> => {
|
|
560
|
+
const response = await axios.post(url, body, { headers, responseType: 'stream' });
|
|
561
|
+
let buffer = '';
|
|
562
|
+
|
|
563
|
+
for await (const chunk of response.data) {
|
|
564
|
+
hasStreamedAny = true;
|
|
565
|
+
buffer += chunk.toString('utf-8');
|
|
566
|
+
|
|
567
|
+
let boundary: number;
|
|
568
|
+
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
|
569
|
+
const rawEvent = buffer.slice(0, boundary);
|
|
570
|
+
buffer = buffer.slice(boundary + 2);
|
|
571
|
+
|
|
572
|
+
for (const line of rawEvent.split('\n')) {
|
|
573
|
+
const trimmed = line.trim();
|
|
574
|
+
if (!trimmed.startsWith('data:')) continue;
|
|
575
|
+
|
|
576
|
+
const payload = trimmed.slice(5).trim();
|
|
577
|
+
if (payload === '[DONE]') continue;
|
|
578
|
+
|
|
579
|
+
let event: any;
|
|
580
|
+
try {
|
|
581
|
+
event = JSON.parse(payload);
|
|
582
|
+
} catch {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (config.vendor === 'anthropic') {
|
|
587
|
+
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
588
|
+
content += event.delta.text;
|
|
589
|
+
onChunk(event.delta.text);
|
|
590
|
+
} else if (event.type === 'message_start') {
|
|
591
|
+
promptTokens = event.message?.usage?.input_tokens;
|
|
592
|
+
} else if (event.type === 'message_delta') {
|
|
593
|
+
completionTokens = event.usage?.output_tokens;
|
|
594
|
+
}
|
|
595
|
+
} else if (config.vendor === 'openai') {
|
|
596
|
+
const delta = event.choices?.[0]?.delta?.content;
|
|
597
|
+
if (delta) {
|
|
598
|
+
content += delta;
|
|
599
|
+
onChunk(delta);
|
|
600
|
+
}
|
|
601
|
+
if (event.usage) {
|
|
602
|
+
promptTokens = event.usage.prompt_tokens;
|
|
603
|
+
completionTokens = event.usage.completion_tokens;
|
|
604
|
+
}
|
|
605
|
+
} else if (config.vendor === 'gemini') {
|
|
606
|
+
const text = event.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
607
|
+
if (text) {
|
|
608
|
+
content += text;
|
|
609
|
+
onChunk(text);
|
|
610
|
+
}
|
|
611
|
+
if (event.usageMetadata) {
|
|
612
|
+
promptTokens = event.usageMetadata.promptTokenCount;
|
|
613
|
+
completionTokens = event.usageMetadata.candidatesTokenCount;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
let attempt = 0;
|
|
622
|
+
for (;;) {
|
|
623
|
+
try {
|
|
624
|
+
await attemptStream();
|
|
625
|
+
break;
|
|
626
|
+
} catch (e: any) {
|
|
627
|
+
const status = e?.response?.status;
|
|
628
|
+
if (hasStreamedAny || attempt >= config.maxRetries || !this.isRetryable(status)) {
|
|
629
|
+
throw new TyrError(
|
|
630
|
+
`AI streaming request to '${config.vendor}' failed (${status ?? 'network error'})`,
|
|
631
|
+
e,
|
|
632
|
+
'Check your API key, network connection, and the vendor status page.'
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
attempt++;
|
|
636
|
+
const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
|
|
637
|
+
this.logger.warn(`AI stream request failed (status ${status ?? 'unknown'}). Retrying in ${delay}ms...`);
|
|
638
|
+
await this.sleep(delay);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return { content, blocks: [{ type: 'text', text: content }], vendor: config.vendor, model: config.model, promptTokens, completionTokens };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export const AIVendorManagerTests = {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
|
-
import { existsSync } from 'fs';
|
|
2
|
+
import { existsSync, Dirent } from 'fs';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
|
|
@@ -29,7 +29,7 @@ export class FileSystemManager {
|
|
|
29
29
|
* @param {string} filePath - The path to expand.
|
|
30
30
|
* @returns {string} The expanded absolute path.
|
|
31
31
|
* @example
|
|
32
|
-
* const dir = fs.expandPath(
|
|
32
|
+
* const dir = fs.expandPath(getEnvString('INTEGRATIONS_DIR'));
|
|
33
33
|
* // "~/dev/datosBroker" → "/Users/mandreu/dev/datosBroker"
|
|
34
34
|
*/
|
|
35
35
|
public expandPath(filePath: string): string {
|
|
@@ -76,6 +76,26 @@ export class FileSystemManager {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* @method readdir
|
|
81
|
+
* @description Reads the content of a directory. Returns null if the directory does not exist.
|
|
82
|
+
* @param {string} dirPath - Path to the directory.
|
|
83
|
+
* @returns {Promise<string[]|null>} List of entries or null if it does not exist.
|
|
84
|
+
* @example
|
|
85
|
+
* const entries = await fs.readdir('./src');
|
|
86
|
+
*/
|
|
87
|
+
public async readdir(dirPath: string, options: { withFileTypes: true } & Record<string, any>): Promise<Dirent[]>;
|
|
88
|
+
public async readdir(dirPath: string, options?: ({ withFileTypes?: false } & Record<string, any>) | undefined): Promise<string[]>;
|
|
89
|
+
public async readdir(dirPath: string, options?: any): Promise<string[] | Dirent[]> {
|
|
90
|
+
const resolvedPath = this.resolvePath(dirPath);
|
|
91
|
+
try {
|
|
92
|
+
return await fs.readdir(resolvedPath, options);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
95
|
+
throw new TyrError(`Could not read directory: ${dirPath}`, e, 'Check that the directory exists and has read permissions.');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
/**
|
|
80
100
|
* @method delete
|
|
81
101
|
* @description Deletes a file if it exists.
|