vipcode 0.0.0-stage → 0.2.1

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/dist/router.js ADDED
@@ -0,0 +1,726 @@
1
+ import fs from "node:fs";
2
+ import OpenAI from "openai";
3
+ import { readAuth, resolveCredentials } from "./auth.js";
4
+ import { debugLog } from "./debug.js";
5
+ import { wildcardMatch } from "./match.js";
6
+ import { STATE_PATH } from "./paths.js";
7
+ export class NoSlotError extends Error {
8
+ }
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+ // Si el proveedor no manda nada en este tiempo, cortamos y probamos otro
11
+ const IDLE_MS = 120000;
12
+ // Estimación rápida de tokens (unos 3,6 caracteres por token; cada imagen cuenta como ~1.200)
13
+ export function estimateTokens(value) {
14
+ let chars = 0;
15
+ let images = 0;
16
+ const walk = (item) => {
17
+ if (typeof item === "string") {
18
+ if (item.startsWith("data:image/")) {
19
+ images++;
20
+ }
21
+ else {
22
+ chars += item.length;
23
+ }
24
+ }
25
+ else if (Array.isArray(item)) {
26
+ for (const child of item) {
27
+ walk(child);
28
+ }
29
+ }
30
+ else if (item && typeof item === "object") {
31
+ for (const [key, child] of Object.entries(item)) {
32
+ chars += key.length + 3;
33
+ walk(child);
34
+ }
35
+ }
36
+ else if (item !== undefined && item !== null) {
37
+ chars += String(item).length;
38
+ }
39
+ };
40
+ walk(value);
41
+ return Math.ceil(chars / 3.6) + images * 1200;
42
+ }
43
+ export function hasImages(messages) {
44
+ return messages.some((message) => Array.isArray(message.content) && message.content.some((part) => part?.type === "image_url"));
45
+ }
46
+ // Cambia las imágenes por un aviso de texto para los modelos que no ven
47
+ export function stripImages(messages) {
48
+ return messages.map((message) => {
49
+ if (!Array.isArray(message.content)) {
50
+ return message;
51
+ }
52
+ const parts = message.content.map((part) => {
53
+ if (part?.type === "image_url") {
54
+ return { type: "text", text: "[imagen omitida: este modelo no acepta imágenes]" };
55
+ }
56
+ return part;
57
+ });
58
+ return { ...message, content: parts };
59
+ });
60
+ }
61
+ // Id corto y alfanumérico: Mistral exige 9 caracteres y el resto acepta cualquier cosa
62
+ function shortId() {
63
+ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
64
+ let id = "";
65
+ for (let i = 0; i < 9; i++) {
66
+ id += chars[Math.floor(Math.random() * chars.length)];
67
+ }
68
+ return id;
69
+ }
70
+ function nextUtcMidnight() {
71
+ const now = new Date();
72
+ return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 0, 1);
73
+ }
74
+ function sleep(ms, signal) {
75
+ return new Promise((resolve, reject) => {
76
+ const timer = setTimeout(resolve, ms);
77
+ if (signal) {
78
+ signal.addEventListener("abort", () => {
79
+ clearTimeout(timer);
80
+ reject(new Error("aborted"));
81
+ });
82
+ }
83
+ });
84
+ }
85
+ function isAbort(err, signal) {
86
+ if (signal && signal.aborted) {
87
+ return true;
88
+ }
89
+ const name = err?.name || "";
90
+ return name === "AbortError" || name === "APIUserAbortError";
91
+ }
92
+ // "1m25.5s", "7.66s", "2h3m" → milisegundos
93
+ function parseDuration(text) {
94
+ let ms = 0;
95
+ const hours = /(\d+(?:\.\d+)?)h/.exec(text);
96
+ const minutes = /(\d+(?:\.\d+)?)m(?!s)/.exec(text);
97
+ const seconds = /(\d+(?:\.\d+)?)s/.exec(text);
98
+ if (hours) {
99
+ ms += Number(hours[1]) * 3600000;
100
+ }
101
+ if (minutes) {
102
+ ms += Number(minutes[1]) * 60000;
103
+ }
104
+ if (seconds) {
105
+ ms += Number(seconds[1]) * 1000;
106
+ }
107
+ return Math.ceil(ms);
108
+ }
109
+ function retryFromHeaders(headers) {
110
+ const h = headers;
111
+ if (!h || typeof h.get !== "function") {
112
+ return 0;
113
+ }
114
+ const ms = h.get("retry-after-ms");
115
+ if (ms && !Number.isNaN(Number(ms))) {
116
+ return Number(ms);
117
+ }
118
+ const retryAfter = h.get("retry-after");
119
+ if (retryAfter) {
120
+ if (!Number.isNaN(Number(retryAfter))) {
121
+ return Number(retryAfter) * 1000;
122
+ }
123
+ const date = Date.parse(retryAfter);
124
+ if (!Number.isNaN(date)) {
125
+ return Math.max(0, date - Date.now());
126
+ }
127
+ }
128
+ return 0;
129
+ }
130
+ // Gemini manda "retryDelay": "41s" y Groq "Please try again in 1m25.5s"
131
+ function retryFromMessage(message) {
132
+ const delay = /retry_?delay["'\s:=]+["']?(\d+(?:\.\d+)?s)/i.exec(message);
133
+ if (delay) {
134
+ return parseDuration(delay[1]);
135
+ }
136
+ const again = /try again in\s+((?:\d+(?:\.\d+)?h)?(?:\d+(?:\.\d+)?m(?!s))?(?:\d+(?:\.\d+)?s)?)/i.exec(message);
137
+ if (again && again[1]) {
138
+ return parseDuration(again[1]);
139
+ }
140
+ return 0;
141
+ }
142
+ // Decide qué hacer con un error del proveedor
143
+ function classify(err) {
144
+ let message = String(err?.message || err);
145
+ let status = 0;
146
+ let retryAfterMs = 0;
147
+ if (err instanceof OpenAI.APIConnectionError) {
148
+ return { kind: "server", retryAfterMs: 30000, detail: "sin conexión" };
149
+ }
150
+ if (err instanceof OpenAI.APIError) {
151
+ status = err.status || 0;
152
+ retryAfterMs = retryFromHeaders(err.headers);
153
+ if (err.error) {
154
+ message += ` ${JSON.stringify(err.error)}`;
155
+ }
156
+ }
157
+ if (!retryAfterMs) {
158
+ retryAfterMs = retryFromMessage(message);
159
+ }
160
+ const lower = message.toLowerCase();
161
+ const compact = lower.replace(/\s+/g, "");
162
+ if (status >= 400 && status < 500 && /stream_options|include_usage/.test(lower)) {
163
+ return { kind: "params", retryAfterMs: 0, detail: "no acepta stream_options" };
164
+ }
165
+ if (status === 401 ||
166
+ /api key not valid|invalid api key|incorrect api key|invalid_api_key|api_key_invalid|invalid authentication|authentication failed|missing or invalid api key/.test(lower)) {
167
+ return { kind: "auth", retryAfterMs: 0, detail: "clave inválida o sin permiso" };
168
+ }
169
+ if (status === 403) {
170
+ if (/model|region|not available|access to/.test(lower)) {
171
+ return { kind: "unsupported", retryAfterMs: DAY_MS, detail: "sin acceso a este modelo" };
172
+ }
173
+ return { kind: "auth", retryAfterMs: 0, detail: "clave inválida o sin permiso" };
174
+ }
175
+ if (status === 402 || /insufficient (credits|balance|quota)|credit balance|payment required/.test(lower)) {
176
+ return { kind: "daily", retryAfterMs, detail: "sin créditos" };
177
+ }
178
+ if (status === 429 || /rate.?limit|too many requests|quota exceeded|resource_exhausted|resource exhausted/.test(lower)) {
179
+ if (/limit:\s*0\b|"limit":\s*0\b|limit 0\b/.test(lower)) {
180
+ return { kind: "unsupported", retryAfterMs: 7 * DAY_MS, detail: "no incluido en el plan gratis" };
181
+ }
182
+ if (/per.?day|daily|rpd|tpd|requestsperday|tokensperday|free-models-per-day/.test(compact)) {
183
+ return { kind: "daily", retryAfterMs, detail: "límite diario agotado" };
184
+ }
185
+ if (/per.?month|monthly/.test(compact)) {
186
+ return { kind: "daily", retryAfterMs: retryAfterMs || DAY_MS, detail: "límite mensual agotado" };
187
+ }
188
+ return { kind: "rate", retryAfterMs, detail: "límite por minuto" };
189
+ }
190
+ if (status === 413 ||
191
+ /context.?length|context window|maximum context|too long|too large|reduce the length|max_tokens|prompt is too long|request too large|input is too long|exceeds the (maximum|limit)/.test(lower)) {
192
+ return { kind: "context", retryAfterMs: 0, detail: "conversación demasiado larga para este modelo" };
193
+ }
194
+ if (status === 404 ||
195
+ /model.{0,40}(not found|does not exist|not exist|no longer|decommissioned|deprecated|unknown|invalid|not available|not supported)|no endpoints found|unknown model|invalid model|model_not_found|does not support (tools|tool|function)|tools? (use |calling )?(is |are )?not supported|function calling is not|tool_choice.{0,30}not supported/.test(lower)) {
196
+ return { kind: "unsupported", retryAfterMs: DAY_MS, detail: "modelo no disponible o sin soporte de herramientas" };
197
+ }
198
+ if (status >= 500 || /overloaded|unavailable|timeout|timed out|econnreset|socket hang up|bad gateway|dejó de responder/.test(lower)) {
199
+ return { kind: "server", retryAfterMs: 30000, detail: status ? `error del servidor (${status})` : "el servidor no responde" };
200
+ }
201
+ if (/respuesta vacía/.test(lower)) {
202
+ return { kind: "other", retryAfterMs: 0, detail: "respuesta vacía" };
203
+ }
204
+ return { kind: "other", retryAfterMs: 0, detail: `${status || ""} ${message.slice(0, 160)}`.trim() };
205
+ }
206
+ // Cada proveedor quiere el historial un poco distinto
207
+ function prepareMessages(slot, messages) {
208
+ const gemini = slot.provider.baseURL.includes("generativelanguage.googleapis.com");
209
+ return messages.map((message) => {
210
+ if (message.role !== "assistant" || !Array.isArray(message.tool_calls)) {
211
+ return message;
212
+ }
213
+ const toolCalls = message.tool_calls.map((call) => {
214
+ const { extra_content, ...rest } = call;
215
+ if (!gemini) {
216
+ return rest;
217
+ }
218
+ // Gemini 3 exige la "firma de pensamiento" en cada llamada; si vino de otro modelo, usamos la de comodín
219
+ const signature = extra_content?.google?.thought_signature || "skip_thought_signature_validator";
220
+ return { ...rest, extra_content: { google: { thought_signature: signature } } };
221
+ });
222
+ return { ...message, tool_calls: toolCalls };
223
+ });
224
+ }
225
+ export class Router {
226
+ config;
227
+ ranking;
228
+ slots = [];
229
+ // Hasta cuándo está en pausa cada slot (se guarda en disco para recordar límites diarios)
230
+ cooldowns = {};
231
+ unsupported = {};
232
+ noStreamUsage = {};
233
+ // Slots descartados durante esta ejecución (clave mala…)
234
+ skipped = new Set();
235
+ // Si no es null, se usa ese slot primero
236
+ pinned = null;
237
+ lastSlot = null;
238
+ constructor(config, providers,
239
+ // Puntuaciones de LLM Stats para ordenar de mejor a peor (null = orden del catálogo)
240
+ ranking = null) {
241
+ this.config = config;
242
+ this.ranking = ranking;
243
+ this.loadState();
244
+ this.reload(providers);
245
+ }
246
+ setRanking(ranking, providers) {
247
+ this.ranking = ranking;
248
+ this.reload(providers);
249
+ }
250
+ // Rehace la lista de slots (al arrancar, al descubrir modelos o al añadir una clave)
251
+ reload(providers) {
252
+ const auth = readAuth();
253
+ const slots = [];
254
+ for (const provider of providers) {
255
+ if (provider.enabled === false || provider.models.length === 0) {
256
+ continue;
257
+ }
258
+ const credentials = resolveCredentials(provider, auth);
259
+ if (!credentials) {
260
+ continue;
261
+ }
262
+ const headers = { ...(provider.headers || {}) };
263
+ if (credentials.apiKey === "no-key") {
264
+ // Sin clave no se manda la cabecera: Kilo y OVHcloud rechazan un token inventado
265
+ headers.Authorization = null;
266
+ }
267
+ const client = new OpenAI({
268
+ apiKey: credentials.apiKey,
269
+ baseURL: credentials.baseURL,
270
+ defaultHeaders: headers,
271
+ maxRetries: 0, // reintentar lo hacemos nosotros cambiando de proveedor
272
+ timeout: 180000,
273
+ });
274
+ const anonymous = credentials.apiKey === "no-key" && !provider.local;
275
+ for (const model of provider.models) {
276
+ const rated = this.ranking ? this.ranking.score(model.id) : null;
277
+ slots.push({
278
+ key: `${provider.name}/${model.id}`,
279
+ provider,
280
+ model,
281
+ client,
282
+ score: rated ? rated.score : null,
283
+ ratedAs: rated ? rated.model.id : null,
284
+ anonymous,
285
+ });
286
+ }
287
+ }
288
+ this.slots = this.sortSlots(slots);
289
+ }
290
+ // Orden de la rotación: primero los patrones de "order" del config; después tus proveedores propios
291
+ // y luego todos los demás, de mejor a peor según LLM Stats (con "anonymousLast", los anónimos al final)
292
+ sortSlots(slots) {
293
+ const patterns = this.config.order || [];
294
+ const byScore = Boolean(this.ranking) && this.config.ranking?.enabled !== false;
295
+ const anonymousLast = this.config.ranking?.anonymousLast === true;
296
+ const patternRank = (slot) => {
297
+ const index = patterns.findIndex((pattern) => wildcardMatch(pattern, slot.key) || pattern === slot.provider.name);
298
+ if (index < 0) {
299
+ return patterns.length;
300
+ }
301
+ return index;
302
+ };
303
+ const group = (slot) => {
304
+ if (slot.provider.custom) {
305
+ return 0;
306
+ }
307
+ if (anonymousLast && slot.anonymous) {
308
+ return 2;
309
+ }
310
+ return 1;
311
+ };
312
+ // Los que no tienen nota van detrás de los que sí, en el orden del catálogo
313
+ const quality = (slot) => {
314
+ if (!byScore || slot.score === null) {
315
+ return -1;
316
+ }
317
+ return slot.score;
318
+ };
319
+ return slots
320
+ .map((slot, index) => ({ slot, index }))
321
+ .sort((a, b) => patternRank(a.slot) - patternRank(b.slot) ||
322
+ group(a.slot) - group(b.slot) ||
323
+ quality(b.slot) - quality(a.slot) ||
324
+ a.index - b.index)
325
+ .map((entry) => entry.slot);
326
+ }
327
+ loadState() {
328
+ try {
329
+ const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
330
+ this.cooldowns = data.cooldowns || {};
331
+ this.unsupported = data.unsupported || {};
332
+ this.noStreamUsage = data.noStreamUsage || {};
333
+ }
334
+ catch {
335
+ this.cooldowns = {};
336
+ this.unsupported = {};
337
+ this.noStreamUsage = {};
338
+ }
339
+ }
340
+ saveState() {
341
+ const now = Date.now();
342
+ // Quitamos las pausas que ya han pasado para que el fichero no crezca
343
+ for (const table of [this.cooldowns, this.unsupported]) {
344
+ for (const [key, until] of Object.entries(table)) {
345
+ if (until < now) {
346
+ delete table[key];
347
+ }
348
+ }
349
+ }
350
+ try {
351
+ const state = { cooldowns: this.cooldowns, unsupported: this.unsupported, noStreamUsage: this.noStreamUsage };
352
+ fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
353
+ }
354
+ catch {
355
+ // no es grave si no se puede guardar
356
+ }
357
+ }
358
+ setCooldown(slots, until) {
359
+ for (const slot of slots) {
360
+ this.cooldowns[slot.key] = until;
361
+ }
362
+ this.saveState();
363
+ }
364
+ slotFor(key) {
365
+ return this.slots.find((slot) => slot.key === key);
366
+ }
367
+ // Estado legible de cada slot para /models
368
+ describe() {
369
+ const now = Date.now();
370
+ return this.slots.map((slot) => {
371
+ let status = "disponible";
372
+ let available = true;
373
+ const until = this.cooldowns[slot.key] || 0;
374
+ const unsupportedUntil = this.unsupported[slot.key] || 0;
375
+ if (this.skipped.has(slot.key)) {
376
+ status = "descartado (clave inválida)";
377
+ available = false;
378
+ }
379
+ else if (unsupportedUntil > now) {
380
+ status = `no disponible hasta ${new Date(unsupportedUntil).toLocaleDateString()}`;
381
+ available = false;
382
+ }
383
+ else if (until > now) {
384
+ status = `en pausa hasta ${new Date(until).toLocaleTimeString()}`;
385
+ available = false;
386
+ }
387
+ if (this.pinned === slot.key) {
388
+ status += " (fijado)";
389
+ }
390
+ return {
391
+ key: slot.key,
392
+ label: slot.provider.label || slot.provider.name,
393
+ status,
394
+ available,
395
+ context: slot.model.context,
396
+ vision: Boolean(slot.model.vision),
397
+ score: slot.score,
398
+ };
399
+ });
400
+ }
401
+ availableCount() {
402
+ return this.describe().filter((row) => row.available).length;
403
+ }
404
+ resetCooldowns() {
405
+ this.cooldowns = {};
406
+ this.unsupported = {};
407
+ this.skipped.clear();
408
+ this.saveState();
409
+ }
410
+ // Slots utilizables ahora mismo para una petición de este tamaño
411
+ candidates(estTokens, excluded, needsVision, needsTools, prefer) {
412
+ const now = Date.now();
413
+ let list = this.slots.filter((slot) => {
414
+ if (this.skipped.has(slot.key) || excluded.has(slot.key)) {
415
+ return false;
416
+ }
417
+ if ((this.unsupported[slot.key] || 0) > now || (this.cooldowns[slot.key] || 0) > now) {
418
+ return false;
419
+ }
420
+ if (needsVision && !slot.model.vision) {
421
+ return false;
422
+ }
423
+ if (needsTools && slot.model.tools === false) {
424
+ return false;
425
+ }
426
+ return slot.model.context >= estTokens;
427
+ });
428
+ // El fijado con /model va el primero; detrás, el preferido del agente
429
+ for (const pattern of [prefer, this.pinned]) {
430
+ if (!pattern) {
431
+ continue;
432
+ }
433
+ const matching = list.filter((slot) => slot.key === pattern || wildcardMatch(pattern, slot.key));
434
+ if (matching.length > 0) {
435
+ list = [...matching, ...list.filter((slot) => !matching.includes(slot))];
436
+ }
437
+ }
438
+ return list;
439
+ }
440
+ // Cuánto falta para que vuelva el primer slot que cabe en la petición
441
+ soonestReturn(estTokens, excluded, needsVision, needsTools) {
442
+ const now = Date.now();
443
+ let soonest = Infinity;
444
+ for (const slot of this.slots) {
445
+ if (this.skipped.has(slot.key) || excluded.has(slot.key) || slot.model.context < estTokens) {
446
+ continue;
447
+ }
448
+ if ((needsVision && !slot.model.vision) || (needsTools && slot.model.tools === false)) {
449
+ continue;
450
+ }
451
+ if ((this.unsupported[slot.key] || 0) > now) {
452
+ continue;
453
+ }
454
+ const wait = (this.cooldowns[slot.key] || 0) - now;
455
+ if (wait < soonest) {
456
+ soonest = wait;
457
+ }
458
+ }
459
+ return soonest;
460
+ }
461
+ maxContext() {
462
+ let max = 0;
463
+ const now = Date.now();
464
+ for (const slot of this.slots) {
465
+ if (!this.skipped.has(slot.key) && (this.unsupported[slot.key] || 0) <= now && slot.model.context > max) {
466
+ max = slot.model.context;
467
+ }
468
+ }
469
+ return max;
470
+ }
471
+ async chat(request, signal, handlers = {}) {
472
+ if (this.slots.length === 0) {
473
+ throw new NoSlotError("No hay ningún proveedor configurado. Añade una clave con «vipcode auth login» o exporta, por ejemplo, GEMINI_API_KEY.");
474
+ }
475
+ let messages = request.messages;
476
+ const needsTools = Boolean(request.tools && request.tools.length > 0);
477
+ let needsVision = hasImages(messages);
478
+ if (needsVision && !this.slots.some((slot) => slot.model.vision && !this.skipped.has(slot.key))) {
479
+ messages = stripImages(messages);
480
+ needsVision = false;
481
+ handlers.onNotice?.("Ningún modelo configurado acepta imágenes; las envío sin ellas.");
482
+ }
483
+ let estTokens = estimateTokens(messages) + estimateTokens(request.tools || []);
484
+ // Slots que han fallado en esta petición por motivos no permanentes (contexto, error raro)
485
+ const excluded = new Set();
486
+ while (true) {
487
+ const list = this.candidates(estTokens, excluded, needsVision, needsTools, request.prefer);
488
+ if (list.length === 0) {
489
+ const wait = this.soonestReturn(estTokens, excluded, needsVision, needsTools);
490
+ if (wait > 0 && wait <= this.config.maxWaitMs) {
491
+ handlers.onNotice?.(`Todos los proveedores están en pausa; espero ${Math.ceil(wait / 1000)} s…`);
492
+ await sleep(wait + 500, signal);
493
+ continue;
494
+ }
495
+ if (needsVision) {
496
+ // Los que ven imágenes están agotados: seguimos con el resto sin las imágenes
497
+ messages = stripImages(messages);
498
+ needsVision = false;
499
+ estTokens = estimateTokens(messages) + estimateTokens(request.tools || []);
500
+ handlers.onNotice?.("Los modelos que aceptan imágenes no están disponibles; sigo sin las imágenes.");
501
+ continue;
502
+ }
503
+ let reason = "Todos los proveedores están agotados o en pausa.";
504
+ if (wait === Infinity) {
505
+ reason = `Ningún modelo disponible admite una conversación de ~${estTokens} tokens. Usa /compact o /new.`;
506
+ }
507
+ else if (wait > 0) {
508
+ reason += ` El primero vuelve a las ${new Date(Date.now() + wait).toLocaleTimeString()}.`;
509
+ }
510
+ throw new NoSlotError(reason);
511
+ }
512
+ const slot = list[0];
513
+ let streamedText = false;
514
+ handlers.onAttempt?.(slot.key);
515
+ try {
516
+ const result = await this.streamOnce(slot, { ...request, messages }, signal, {
517
+ onText: (text) => {
518
+ streamedText = true;
519
+ handlers.onText?.(text);
520
+ },
521
+ onReasoning: handlers.onReasoning,
522
+ });
523
+ if (this.lastSlot && this.lastSlot.key !== slot.key) {
524
+ handlers.onNotice?.(`usando ${slot.key}`);
525
+ }
526
+ this.lastSlot = slot;
527
+ return { ...result, slot };
528
+ }
529
+ catch (err) {
530
+ if (isAbort(err, signal)) {
531
+ throw err;
532
+ }
533
+ const info = classify(err);
534
+ debugLog("error", slot.key, info.kind, String(err?.message || err));
535
+ const now = Date.now();
536
+ // Si el límite es de toda la cuenta, se pausan todos los modelos del proveedor
537
+ let scope = [slot];
538
+ if (slot.provider.limitScope === "provider") {
539
+ scope = this.slots.filter((other) => other.provider === slot.provider);
540
+ }
541
+ let action = "";
542
+ if (info.kind === "params") {
543
+ // Reintentamos el mismo modelo sin stream_options
544
+ this.noStreamUsage[slot.provider.name] = true;
545
+ this.saveState();
546
+ continue;
547
+ }
548
+ else if (info.kind === "rate") {
549
+ let ms = info.retryAfterMs;
550
+ if (ms <= 0) {
551
+ ms = 60000;
552
+ }
553
+ this.setCooldown(scope, now + ms);
554
+ action = `pausa ${Math.ceil(ms / 1000)} s`;
555
+ }
556
+ else if (info.kind === "daily") {
557
+ let until = nextUtcMidnight();
558
+ if (info.retryAfterMs > 0) {
559
+ until = now + info.retryAfterMs;
560
+ }
561
+ this.setCooldown(scope, until);
562
+ action = `pausa hasta ${new Date(until).toLocaleString()}`;
563
+ }
564
+ else if (info.kind === "server") {
565
+ this.setCooldown([slot], now + info.retryAfterMs);
566
+ action = "pausa 30 s";
567
+ }
568
+ else if (info.kind === "auth") {
569
+ // Si la clave falla, fallan todos los modelos de ese proveedor
570
+ for (const other of this.slots) {
571
+ if (other.provider === slot.provider) {
572
+ this.skipped.add(other.key);
573
+ }
574
+ }
575
+ action = "proveedor descartado";
576
+ }
577
+ else if (info.kind === "unsupported") {
578
+ this.unsupported[slot.key] = now + (info.retryAfterMs || DAY_MS);
579
+ this.saveState();
580
+ action = "modelo descartado";
581
+ }
582
+ else {
583
+ excluded.add(slot.key);
584
+ action = "pruebo otro";
585
+ }
586
+ let prefix = "";
587
+ if (streamedText) {
588
+ prefix = "\n";
589
+ }
590
+ handlers.onNotice?.(`${prefix}${slot.key}: ${info.detail} → ${action}`);
591
+ }
592
+ }
593
+ }
594
+ // Una sola llamada en streaming; junta el texto y las llamadas a herramientas
595
+ async streamOnce(slot, request, signal, handlers) {
596
+ const params = {
597
+ model: slot.model.id,
598
+ messages: prepareMessages(slot, request.messages),
599
+ stream: true,
600
+ };
601
+ if (request.tools && request.tools.length > 0) {
602
+ params.tools = request.tools;
603
+ params.tool_choice = "auto";
604
+ }
605
+ if (request.temperature !== undefined) {
606
+ params.temperature = request.temperature;
607
+ }
608
+ if (slot.provider.streamUsage !== false && !this.noStreamUsage[slot.provider.name]) {
609
+ params.stream_options = { include_usage: true };
610
+ }
611
+ // Si el proveedor deja de mandar datos mucho rato, cortamos para probar otro
612
+ const idle = new AbortController();
613
+ let combined = idle.signal;
614
+ if (signal) {
615
+ combined = AbortSignal.any([signal, idle.signal]);
616
+ }
617
+ let idleTimer = setTimeout(() => idle.abort(), IDLE_MS);
618
+ const bump = () => {
619
+ clearTimeout(idleTimer);
620
+ idleTimer = setTimeout(() => idle.abort(), IDLE_MS);
621
+ };
622
+ let content = "";
623
+ let usage = null;
624
+ const calls = [];
625
+ const byIndex = new Map();
626
+ const byId = new Map();
627
+ try {
628
+ const stream = (await slot.client.chat.completions.create(params, { signal: combined }));
629
+ for await (const chunk of stream) {
630
+ bump();
631
+ // Algunos proveedores mandan el error dentro del stream
632
+ if (chunk && chunk.error) {
633
+ const apiError = new Error(chunk.error.message || JSON.stringify(chunk.error) || "error en el stream");
634
+ throw apiError;
635
+ }
636
+ const reported = chunk?.usage || chunk?.x_groq?.usage;
637
+ if (reported && typeof reported.prompt_tokens === "number") {
638
+ usage = { prompt: reported.prompt_tokens || 0, completion: reported.completion_tokens || 0 };
639
+ }
640
+ const choice = chunk?.choices?.[0];
641
+ if (!choice || !choice.delta) {
642
+ continue;
643
+ }
644
+ const delta = choice.delta;
645
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
646
+ if (typeof reasoning === "string" && reasoning.length > 0) {
647
+ handlers.onReasoning?.(reasoning);
648
+ }
649
+ if (typeof delta.content === "string" && delta.content.length > 0) {
650
+ content += delta.content;
651
+ handlers.onText(delta.content);
652
+ }
653
+ if (Array.isArray(delta.tool_calls)) {
654
+ for (const tc of delta.tool_calls) {
655
+ let entry;
656
+ if (typeof tc.index === "number") {
657
+ entry = byIndex.get(tc.index);
658
+ }
659
+ else if (tc.id) {
660
+ entry = byId.get(tc.id);
661
+ }
662
+ else {
663
+ entry = calls[calls.length - 1];
664
+ }
665
+ if (!entry) {
666
+ entry = { id: "", name: "", args: "" };
667
+ calls.push(entry);
668
+ if (typeof tc.index === "number") {
669
+ byIndex.set(tc.index, entry);
670
+ }
671
+ }
672
+ if (tc.id && !entry.id) {
673
+ entry.id = tc.id;
674
+ byId.set(tc.id, entry);
675
+ }
676
+ if (tc.function?.name && !entry.name) {
677
+ entry.name = tc.function.name;
678
+ }
679
+ if (tc.function?.arguments) {
680
+ entry.args += tc.function.arguments;
681
+ }
682
+ if (tc.extra_content) {
683
+ entry.extra = tc.extra_content;
684
+ }
685
+ }
686
+ }
687
+ }
688
+ }
689
+ catch (err) {
690
+ if (idle.signal.aborted && !signal?.aborted) {
691
+ throw new Error("el proveedor dejó de responder (timeout)");
692
+ }
693
+ throw err;
694
+ }
695
+ finally {
696
+ clearTimeout(idleTimer);
697
+ }
698
+ const toolCalls = [];
699
+ for (const call of calls) {
700
+ if (!call.name) {
701
+ continue;
702
+ }
703
+ let args = call.args;
704
+ if (args.trim() === "") {
705
+ args = "{}";
706
+ }
707
+ // Usamos ids propios para que el historial funcione al cambiar de proveedor
708
+ const toolCall = { id: shortId(), type: "function", function: { name: call.name, arguments: args } };
709
+ if (call.extra) {
710
+ toolCall.extra_content = call.extra;
711
+ }
712
+ toolCalls.push(toolCall);
713
+ }
714
+ if (toolCalls.length === 0 && content.trim() === "") {
715
+ throw new Error("respuesta vacía del modelo");
716
+ }
717
+ if (toolCalls.length > 0) {
718
+ let finalContent = null;
719
+ if (content.length > 0) {
720
+ finalContent = content;
721
+ }
722
+ return { message: { role: "assistant", content: finalContent, tool_calls: toolCalls }, usage };
723
+ }
724
+ return { message: { role: "assistant", content }, usage };
725
+ }
726
+ }