@librechat/agents 3.7.9 → 3.7.10

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.
@@ -1,6 +1,7 @@
1
1
  import axios from 'axios';
2
2
  import type * as t from './types';
3
3
  import { createDefaultLogger, formatErrorForLog } from './utils';
4
+ import { createSearchMetrics } from './metrics';
4
5
 
5
6
  const DEFAULT_JINA_API_URL = 'https://api.jina.ai/v1/rerank';
6
7
 
@@ -17,16 +18,27 @@ const getDefaultJinaApiUrl = (): string =>
17
18
  export abstract class BaseReranker {
18
19
  protected apiKey: string | undefined;
19
20
  protected logger: t.Logger;
21
+ /** Public so a caller that fails before reaching `rerank` can still
22
+ * attribute the attempt to the configured reranker. */
23
+ abstract readonly provider: string;
24
+ private ownMetrics?: t.SearchMetrics;
20
25
 
21
26
  constructor(logger?: t.Logger) {
22
27
  // Each specific reranker will set its API key
23
28
  this.logger = logger || createDefaultLogger();
24
29
  }
25
30
 
31
+ /**
32
+ * A search reranks once per scraped source, so nothing here logs per call:
33
+ * every exit records one observation through `metrics` and the enclosing
34
+ * search emits a single summary. `metrics` is optional only for direct use
35
+ * of a reranker, which falls back to an auto-flushing collector.
36
+ */
26
37
  abstract rerank(
27
38
  query: string,
28
39
  documents: string[],
29
- topK?: number
40
+ topK?: number,
41
+ metrics?: t.SearchMetrics
30
42
  ): Promise<t.Highlight[]>;
31
43
 
32
44
  protected getDefaultRanking(
@@ -37,9 +49,76 @@ export abstract class BaseReranker {
37
49
  .slice(0, Math.min(topK, documents.length))
38
50
  .map((doc) => ({ text: doc, score: 0 }));
39
51
  }
52
+
53
+ /** A direct caller has no enclosing search to fold into, so one
54
+ * auto-flushing collector per instance emits that call's summary on its
55
+ * own; record and flush run synchronously, so concurrent calls on the same
56
+ * instance cannot share a total. */
57
+ private localMetrics(): t.SearchMetrics {
58
+ this.ownMetrics ??= createSearchMetrics(this.logger, true);
59
+ return this.ownMetrics;
60
+ }
61
+
62
+ /** Opens the per-call state every exit path records against. */
63
+ protected beginRerank(
64
+ documents: string[],
65
+ topK: number,
66
+ metrics?: t.SearchMetrics
67
+ ): t.RerankRun {
68
+ return {
69
+ metrics: metrics ?? this.localMetrics(),
70
+ documents,
71
+ topK,
72
+ startedAt: Date.now(),
73
+ };
74
+ }
75
+
76
+ private record(
77
+ run: t.RerankRun,
78
+ highlights: t.Highlight[],
79
+ reason?: t.RerankFallback,
80
+ error?: t.SafeErrorLog
81
+ ): t.Highlight[] {
82
+ run.metrics.recordRerank({
83
+ provider: this.provider,
84
+ chunks: run.documents.length,
85
+ results: highlights.length,
86
+ durationMs: Date.now() - run.startedAt,
87
+ model: run.model,
88
+ units: run.units,
89
+ dropped: run.dropped,
90
+ topK: run.topKLimit != null ? run.topK : undefined,
91
+ topKLimit: run.topKLimit,
92
+ reason,
93
+ error,
94
+ });
95
+ return highlights;
96
+ }
97
+
98
+ protected complete(
99
+ run: t.RerankRun,
100
+ highlights: t.Highlight[]
101
+ ): t.Highlight[] {
102
+ return this.record(run, highlights);
103
+ }
104
+
105
+ /** Records the call as a fallback and returns the candidates' input order. */
106
+ protected fallback(
107
+ run: t.RerankRun,
108
+ reason: t.RerankFallback,
109
+ error?: t.SafeErrorLog
110
+ ): t.Highlight[] {
111
+ return this.record(
112
+ run,
113
+ this.getDefaultRanking(run.documents, run.topK),
114
+ reason,
115
+ error
116
+ );
117
+ }
40
118
  }
41
119
 
42
120
  export class JinaReranker extends BaseReranker {
121
+ readonly provider = 'jina';
43
122
  private apiUrl: string;
44
123
  private timeout: number;
45
124
  private httpAgent?: t.HttpAgent;
@@ -69,16 +148,14 @@ export class JinaReranker extends BaseReranker {
69
148
  async rerank(
70
149
  query: string,
71
150
  documents: string[],
72
- topK: number = 5
151
+ topK: number = 5,
152
+ metrics?: t.SearchMetrics
73
153
  ): Promise<t.Highlight[]> {
74
- this.logger.debug(
75
- `Reranking ${documents.length} chunks with Jina using API URL: ${this.apiUrl}`
76
- );
154
+ const run = this.beginRerank(documents, topK, metrics);
77
155
 
78
156
  try {
79
157
  if (this.apiKey == null || this.apiKey === '') {
80
- this.logger.warn('JINA_API_KEY is not set. Using default ranking.');
81
- return this.getDefaultRanking(documents, topK);
158
+ return this.fallback(run, 'no_api_key');
82
159
  }
83
160
 
84
161
  const requestData = {
@@ -103,11 +180,17 @@ export class JinaReranker extends BaseReranker {
103
180
  }
104
181
  );
105
182
 
106
- this.logger.debug('Jina API Model:', response.data?.model);
107
- this.logger.debug('Jina API Usage:', response.data?.usage);
183
+ run.model = response.data?.model;
184
+ run.units = response.data?.usage?.total_tokens;
108
185
 
109
- if (response.data && response.data.results.length) {
110
- return response.data.results.map((result) => {
186
+ const results = response.data?.results;
187
+ if (!Array.isArray(results) || results.length === 0) {
188
+ return this.fallback(run, 'bad_response');
189
+ }
190
+
191
+ return this.complete(
192
+ run,
193
+ results.map((result) => {
111
194
  const docIndex = result.index;
112
195
  const score = result.relevance_score;
113
196
  let text = '';
@@ -126,22 +209,16 @@ export class JinaReranker extends BaseReranker {
126
209
  }
127
210
 
128
211
  return { text, score };
129
- });
130
- } else {
131
- this.logger.warn(
132
- 'Unexpected response format from Jina API. Using default ranking.'
133
- );
134
- return this.getDefaultRanking(documents, topK);
135
- }
212
+ })
213
+ );
136
214
  } catch (error) {
137
- this.logger.error('Error using Jina reranker', formatErrorForLog(error));
138
- // Fallback to default ranking on error
139
- return this.getDefaultRanking(documents, topK);
215
+ return this.fallback(run, 'error', formatErrorForLog(error));
140
216
  }
141
217
  }
142
218
  }
143
219
 
144
220
  export class CohereReranker extends BaseReranker {
221
+ readonly provider = 'cohere';
145
222
  private timeout: number;
146
223
  private httpAgent?: t.HttpAgent;
147
224
  private httpsAgent?: t.HttpsAgent;
@@ -167,18 +244,19 @@ export class CohereReranker extends BaseReranker {
167
244
  async rerank(
168
245
  query: string,
169
246
  documents: string[],
170
- topK: number = 5
247
+ topK: number = 5,
248
+ metrics?: t.SearchMetrics
171
249
  ): Promise<t.Highlight[]> {
172
- this.logger.debug(`Reranking ${documents.length} chunks with Cohere`);
250
+ const run = this.beginRerank(documents, topK, metrics);
173
251
 
174
252
  try {
175
253
  if (this.apiKey == null || this.apiKey === '') {
176
- this.logger.warn('COHERE_API_KEY is not set. Using default ranking.');
177
- return this.getDefaultRanking(documents, topK);
254
+ return this.fallback(run, 'no_api_key');
178
255
  }
179
256
 
257
+ const model = 'rerank-v3.5';
180
258
  const requestData = {
181
- model: 'rerank-v3.5',
259
+ model,
182
260
  query: query,
183
261
  top_n: topK,
184
262
  documents: documents,
@@ -198,29 +276,25 @@ export class CohereReranker extends BaseReranker {
198
276
  }
199
277
  );
200
278
 
201
- this.logger.debug('Cohere API ID:', response.data?.id);
202
- this.logger.debug('Cohere API Meta:', response.data?.meta);
279
+ run.model = model;
280
+ run.units = response.data?.meta?.billed_units?.search_units;
203
281
 
204
- if (response.data && response.data.results.length) {
205
- return response.data.results.map((result) => {
282
+ const results = response.data?.results;
283
+ if (!Array.isArray(results) || results.length === 0) {
284
+ return this.fallback(run, 'bad_response');
285
+ }
286
+
287
+ return this.complete(
288
+ run,
289
+ results.map((result) => {
206
290
  const docIndex = result.index;
207
291
  const score = result.relevance_score;
208
292
  const text = documents[docIndex];
209
293
  return { text, score };
210
- });
211
- } else {
212
- this.logger.warn(
213
- 'Unexpected response format from Cohere API. Using default ranking.'
214
- );
215
- return this.getDefaultRanking(documents, topK);
216
- }
217
- } catch (error) {
218
- this.logger.error(
219
- 'Error using Cohere reranker',
220
- formatErrorForLog(error)
294
+ })
221
295
  );
222
- // Fallback to default ranking on error
223
- return this.getDefaultRanking(documents, topK);
296
+ } catch (error) {
297
+ return this.fallback(run, 'error', formatErrorForLog(error));
224
298
  }
225
299
  }
226
300
  }
@@ -244,27 +318,23 @@ const toRagApiCandidate = (
244
318
 
245
319
  /** A single source split into overlapping chunks routinely exceeds the
246
320
  * contract limit, so documents are capped before any candidate is built:
247
- * only the submittable window is ever allocated. */
321
+ * only the submittable window is ever allocated. The overflow is reported
322
+ * through the run's `dropped` counter rather than a per-call log. */
248
323
  const buildRagApiCandidates = (
249
- documents: string[],
250
- logger: t.Logger
251
- ): t.RagApiRerankCandidate[] => {
252
- if (documents.length <= RAG_API_MAX_CANDIDATES) {
253
- return documents.map(toRagApiCandidate);
254
- }
255
- logger.debug(
256
- `rag_api fast-v1 accepts at most ${RAG_API_MAX_CANDIDATES} candidates; truncating ${documents.length} to ${RAG_API_MAX_CANDIDATES}.`
257
- );
258
- return documents.slice(0, RAG_API_MAX_CANDIDATES).map(toRagApiCandidate);
259
- };
260
-
261
- const clampRagApiTopN = (topN: number, logger: t.Logger): number => {
324
+ documents: string[]
325
+ ): t.RagApiRerankCandidate[] =>
326
+ documents.length <= RAG_API_MAX_CANDIDATES
327
+ ? documents.map(toRagApiCandidate)
328
+ : documents.slice(0, RAG_API_MAX_CANDIDATES).map(toRagApiCandidate);
329
+
330
+ /** Reports the clamp on `run` rather than logging it per call: a search
331
+ * configured above the contract limit returns fewer highlights than asked
332
+ * for, and the summary is the only place that now says so. */
333
+ const clampRagApiTopN = (topN: number, run: t.RerankRun): number => {
262
334
  if (topN <= RAG_API_MAX_TOP_N) {
263
335
  return topN;
264
336
  }
265
- logger.debug(
266
- `rag_api fast-v1 accepts top_n <= ${RAG_API_MAX_TOP_N}; clamping ${topN} to ${RAG_API_MAX_TOP_N}.`
267
- );
337
+ run.topKLimit = RAG_API_MAX_TOP_N;
268
338
  return RAG_API_MAX_TOP_N;
269
339
  };
270
340
 
@@ -349,6 +419,7 @@ const withRerankDeadline = <T>(
349
419
  * to the candidates' original order via {@link BaseReranker.getDefaultRanking}.
350
420
  */
351
421
  export class RagApiReranker extends BaseReranker {
422
+ readonly provider = 'rag-api';
352
423
  private baseUrl?: string;
353
424
  private tokenSupplier?: t.RagApiTokenSupplier;
354
425
  private profile: string;
@@ -383,32 +454,28 @@ export class RagApiReranker extends BaseReranker {
383
454
  async rerank(
384
455
  query: string,
385
456
  documents: string[],
386
- topK: number = 5
457
+ topK: number = 5,
458
+ metrics?: t.SearchMetrics
387
459
  ): Promise<t.Highlight[]> {
388
460
  if (documents.length === 0) {
389
461
  return [];
390
462
  }
391
463
 
392
- this.logger.debug(
393
- `Reranking ${documents.length} chunks with rag_api (${this.profile}) using base URL: ${this.baseUrl}`
394
- );
464
+ const run = this.beginRerank(documents, topK, metrics);
395
465
 
396
466
  const baseUrl = this.baseUrl;
397
467
  if (baseUrl == null || baseUrl === '') {
398
- this.logger.warn('RAG_API_URL is not set. Using default ranking.');
399
- return this.getDefaultRanking(documents, topK);
468
+ return this.fallback(run, 'no_base_url');
400
469
  }
401
470
 
402
471
  const tokenSupplier = this.tokenSupplier;
403
472
  if (tokenSupplier == null) {
404
- this.logger.warn(
405
- 'No rag_api token supplier configured. Using default ranking.'
406
- );
407
- return this.getDefaultRanking(documents, topK);
473
+ return this.fallback(run, 'no_token_supplier');
408
474
  }
409
475
 
410
- const candidates = buildRagApiCandidates(documents, this.logger);
411
- const topN = clampRagApiTopN(Math.max(0, topK), this.logger);
476
+ const candidates = buildRagApiCandidates(documents);
477
+ const topN = clampRagApiTopN(Math.max(0, topK), run);
478
+ run.dropped = documents.length - candidates.length;
412
479
  const requestData: t.RagApiRerankRequestBody = {
413
480
  profile: this.profile,
414
481
  query,
@@ -436,40 +503,35 @@ export class RagApiReranker extends BaseReranker {
436
503
  return response.data;
437
504
  }, this.timeout);
438
505
 
439
- this.logger.debug('rag_api rerank model:', data?.model);
506
+ run.model = data?.model;
440
507
 
441
508
  const rawResults = data?.results;
442
509
  if (!Array.isArray(rawResults) || rawResults.length === 0) {
443
- this.logger.warn(
444
- 'Unexpected response format from rag_api rerank. Using default ranking.'
445
- );
446
- return this.getDefaultRanking(documents, topK);
510
+ return this.fallback(run, 'bad_response');
447
511
  }
448
512
 
449
513
  if (!isValidRagApiBatch(rawResults, candidates.length)) {
450
- this.logger.warn(
451
- 'rag_api rerank response contained no valid results. Using default ranking.'
452
- );
453
- return this.getDefaultRanking(documents, topK);
514
+ return this.fallback(run, 'invalid_results');
454
515
  }
455
516
 
456
- return sortRagApiResults(rawResults)
457
- .slice(0, topN)
458
- .map((result) => ({
459
- text: documents[result.index],
460
- score: result.score,
461
- }));
462
- } catch (error) {
463
- this.logger.error(
464
- 'Error using rag_api reranker',
465
- formatErrorForLog(error)
517
+ return this.complete(
518
+ run,
519
+ sortRagApiResults(rawResults)
520
+ .slice(0, topN)
521
+ .map((result) => ({
522
+ text: documents[result.index],
523
+ score: result.score,
524
+ }))
466
525
  );
467
- return this.getDefaultRanking(documents, topK);
526
+ } catch (error) {
527
+ return this.fallback(run, 'error', formatErrorForLog(error));
468
528
  }
469
529
  }
470
530
  }
471
531
 
472
532
  export class InfinityReranker extends BaseReranker {
533
+ readonly provider = 'infinity';
534
+
473
535
  constructor(logger?: t.Logger) {
474
536
  super(logger);
475
537
  // No API key needed for the placeholder implementation
@@ -478,13 +540,14 @@ export class InfinityReranker extends BaseReranker {
478
540
  async rerank(
479
541
  query: string,
480
542
  documents: string[],
481
- topK: number = 5
543
+ topK: number = 5,
544
+ metrics?: t.SearchMetrics
482
545
  ): Promise<t.Highlight[]> {
483
- this.logger.debug(
484
- `Reranking ${documents.length} chunks with Infinity (placeholder)`
485
- );
486
546
  // This would be replaced with actual Infinity reranker implementation
487
- return this.getDefaultRanking(documents, topK);
547
+ return this.fallback(
548
+ this.beginRerank(documents, topK, metrics),
549
+ 'placeholder'
550
+ );
488
551
  }
489
552
  }
490
553