@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.
@@ -291,7 +291,9 @@ export interface JinaRerankerResult {
291
291
  }
292
292
  export interface JinaRerankerResponse {
293
293
  model: string;
294
- usage: {
294
+ /** Telemetry only, and absent from some Jina-compatible endpoints — never
295
+ * dereference it on a path that would discard a usable ranking. */
296
+ usage?: {
295
297
  total_tokens: number;
296
298
  };
297
299
  results: JinaRerankerResult[];
@@ -303,12 +305,13 @@ export interface CohereRerankerResult {
303
305
  export interface CohereRerankerResponse {
304
306
  results: CohereRerankerResult[];
305
307
  id: string;
306
- meta: {
307
- api_version: {
308
+ /** Telemetry only; see {@link JinaRerankerResponse.usage}. */
309
+ meta?: {
310
+ api_version?: {
308
311
  version: string;
309
312
  is_experimental: boolean;
310
313
  };
311
- billed_units: {
314
+ billed_units?: {
312
315
  search_units: number;
313
316
  };
314
317
  };
@@ -343,6 +346,81 @@ export interface RagApiRerankResponse {
343
346
  }
344
347
  export type SafeSearchLevel = 0 | 1 | 2;
345
348
  export type Logger = WinstonLogger;
349
+ /** Compact, redacted view of a thrown error, safe to hand to a logger. */
350
+ export interface SafeErrorLog {
351
+ message: string;
352
+ name?: string;
353
+ code?: string;
354
+ status?: number;
355
+ method?: string;
356
+ url?: string;
357
+ responseDataSummary?: string;
358
+ value?: string;
359
+ }
360
+ /** Why a rerank returned the candidates' original order instead of a ranking. */
361
+ export type RerankFallback = 'no_api_key' | 'no_base_url' | 'no_token_supplier' | 'bad_response' | 'invalid_results' | 'chunk_error' | 'placeholder' | 'error';
362
+ /** One provider query. `results` is the row count that query contributed. */
363
+ export interface SearchObservation {
364
+ provider: string;
365
+ type: string;
366
+ results: number;
367
+ durationMs: number;
368
+ error?: string;
369
+ /** The query rejected rather than reporting failure in its response. Those
370
+ * were logged at error level before they were aggregated, so the summary
371
+ * has to carry the distinction to keep that severity. */
372
+ thrown?: boolean;
373
+ }
374
+ /** One scraped link. A failure carries `error`; a success carries the sizes
375
+ * the scrape produced, so the summary can report both without a second pass. */
376
+ export interface ScrapeObservation {
377
+ url: string;
378
+ chars?: number;
379
+ highlights?: number;
380
+ error?: string;
381
+ }
382
+ /** One reranker round trip. A search reranks once per scraped source, so
383
+ * these fold into a single summary rather than logging per source. */
384
+ export interface RerankObservation {
385
+ provider: string;
386
+ chunks: number;
387
+ results: number;
388
+ durationMs: number;
389
+ model?: string;
390
+ /** Provider-reported usage: Jina tokens, Cohere billed search units. */
391
+ units?: number;
392
+ /** Chunks a provider candidate cap dropped before submission. */
393
+ dropped?: number;
394
+ /** Set only when a provider cap reduced the requested result count, so a
395
+ * search returning fewer highlights than configured says why. */
396
+ topK?: number;
397
+ topKLimit?: number;
398
+ reason?: RerankFallback;
399
+ error?: SafeErrorLog;
400
+ }
401
+ /** Per-rerank state threaded from the start of a call to whichever exit it
402
+ * takes, so every path records exactly one observation. */
403
+ export interface RerankRun {
404
+ metrics: SearchMetrics;
405
+ documents: string[];
406
+ topK: number;
407
+ startedAt: number;
408
+ model?: string;
409
+ units?: number;
410
+ dropped?: number;
411
+ topKLimit?: number;
412
+ }
413
+ /**
414
+ * Fold-as-you-go counters for one `web_search` call. Recording is O(1) and
415
+ * allocation-free past a bounded reason map; {@link SearchMetrics.flush}
416
+ * emits at most one line per phase that actually ran.
417
+ */
418
+ export interface SearchMetrics {
419
+ recordSearch(observation: SearchObservation): void;
420
+ recordScrape(observation: ScrapeObservation): void;
421
+ recordRerank(observation: RerankObservation): void;
422
+ flush(): void;
423
+ }
346
424
  export interface SearchToolConfig extends SearchConfig, ProcessSourcesConfig, FirecrawlConfig {
347
425
  tavilyScraperOptions?: TavilyScraperConfig;
348
426
  crwScraperOptions?: CrwScraperConfig;
@@ -572,6 +650,9 @@ export interface FirecrawlScraperConfig extends BaseSearchProviderConfig {
572
650
  onlyMainContent?: boolean;
573
651
  changeTrackingOptions?: object;
574
652
  }
653
+ /** Result kind a parallel sub-search covers; `web` is the untyped main
654
+ * search, which every provider serves without a `type` parameter. */
655
+ export type SubSearchType = 'web' | 'images' | 'videos' | 'news';
575
656
  export type GetSourcesParams = {
576
657
  query: string;
577
658
  date?: DATE_RANGE;
@@ -870,6 +951,9 @@ export type ProcessSourcesFields = {
870
951
  news: boolean;
871
952
  proMode: boolean;
872
953
  onGetHighlights: SearchToolConfig['onGetHighlights'];
954
+ /** Collector owned by the caller; when omitted, one is created and flushed
955
+ * for this call so a direct `processSources` still summarizes itself. */
956
+ metrics?: SearchMetrics;
873
957
  };
874
958
  export interface SearchToolSchema {
875
959
  type: 'object';
@@ -1,14 +1,6 @@
1
+ import type { SafeErrorLog } from './types';
1
2
  import type * as t from './types';
2
- export interface SafeErrorLog {
3
- message: string;
4
- name?: string;
5
- code?: string;
6
- status?: number;
7
- method?: string;
8
- url?: string;
9
- responseDataSummary?: string;
10
- value?: string;
11
- }
3
+ export type { SafeErrorLog } from './types';
12
4
  /**
13
5
  * Creates a default logger that maps to console methods
14
6
  * Uses a singleton pattern to avoid creating multiple instances
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.7.9",
3
+ "version": "3.7.10",
4
4
  "reova": {
5
5
  "enabled": true,
6
6
  "endpoint": "https://telemetry.reo.dev/data"
@@ -0,0 +1,400 @@
1
+ import type * as t from './types';
2
+
3
+ /** Distinct failure reasons kept per phase before the tail folds into
4
+ * `other`: enough to characterize a mixed failure without letting a
5
+ * pathological run grow one map entry per source. */
6
+ const MAX_REASON_KEYS = 6;
7
+ /** Failing hosts named in a summary before the rest are counted only. */
8
+ const MAX_FAILURE_SAMPLES = 3;
9
+ /** Cap on the raw provider message carried alongside a summary. */
10
+ const MAX_DETAIL_LENGTH = 200;
11
+ /** Cap on a provider-supplied label placed inside a summary line, so a
12
+ * malformed response cannot stretch a fixed-width line without bound. */
13
+ const MAX_LABEL_LENGTH = 48;
14
+ const OTHER_REASON = 'other';
15
+
16
+ const HTTP_STATUS_REGEX = /\b(?:status(?:\scode)?|http)\D{0,3}(\d{3})\b/i;
17
+
18
+ /** Free-form provider messages have unbounded cardinality; the aggregate only
19
+ * keeps a normalized class so the reason map stays small and comparable. */
20
+ const REASON_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
21
+ [/ECONNABORTED|ETIMEDOUT|timeout|timed out/i, 'timeout'],
22
+ [/ENOTFOUND|EAI_AGAIN|getaddrinfo/i, 'dns'],
23
+ [/ECONNREFUSED|ECONNRESET|EPIPE|socket hang up/i, 'connection'],
24
+ [/CERT_|SSL|TLS|self[- ]signed/i, 'tls'],
25
+ [/abort|cancel/i, 'aborted'],
26
+ ];
27
+
28
+ export const classifyFailure = (message?: string): string => {
29
+ if (message == null || message === '') {
30
+ return 'unknown';
31
+ }
32
+ const status = HTTP_STATUS_REGEX.exec(message);
33
+ if (status != null) {
34
+ return `http_${status[1]}`;
35
+ }
36
+ for (const [pattern, reason] of REASON_PATTERNS) {
37
+ if (pattern.test(message)) {
38
+ return reason;
39
+ }
40
+ }
41
+ return OTHER_REASON;
42
+ };
43
+
44
+ const bump = (counts: Map<string, number>, rawKey: string): void => {
45
+ const key = label(rawKey);
46
+ const existing = counts.get(key);
47
+ if (existing != null) {
48
+ counts.set(key, existing + 1);
49
+ return;
50
+ }
51
+ if (counts.size >= MAX_REASON_KEYS) {
52
+ counts.set(OTHER_REASON, (counts.get(OTHER_REASON) ?? 0) + 1);
53
+ return;
54
+ }
55
+ counts.set(key, 1);
56
+ };
57
+
58
+ const hostOf = (url: string): string => {
59
+ try {
60
+ return new URL(url).hostname.replace(/^www\./, '');
61
+ } catch {
62
+ return url.slice(0, 60);
63
+ }
64
+ };
65
+
66
+ const formatCount = (value: number): string =>
67
+ value < 10000 ? String(value) : `${(value / 1000).toFixed(1)}k`;
68
+
69
+ const formatMs = (ms: number): string =>
70
+ ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
71
+
72
+ const formatMap = (counts: Map<string, number>): string => {
73
+ let out = '';
74
+ for (const [key, count] of counts) {
75
+ out += `${out === '' ? '' : ','}${key}:${count}`;
76
+ }
77
+ return `{${out}}`;
78
+ };
79
+
80
+ const truncate = (value: string, max: number): string =>
81
+ value.length <= max ? value : `${value.slice(0, max)}…`;
82
+
83
+ /** Newlines, bidi overrides, and other non-printing characters let a remote
84
+ * payload split one summary into several physical lines or forge a second
85
+ * apparent entry, so they never survive into a log message. */
86
+ const UNPRINTABLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}"]+/gu;
87
+
88
+ const sanitize = (value: string): string => value.replace(UNPRINTABLE, ' ');
89
+
90
+ /** Every provider-supplied string that reaches a summary line or a map key
91
+ * goes through here: the phases promise one bounded line, and neither a
92
+ * remote payload nor an external caller's label is safe on its own. */
93
+ const label = (value: string): string =>
94
+ truncate(sanitize(value), MAX_LABEL_LENGTH);
95
+
96
+ const detailOf = (value: string): string =>
97
+ truncate(sanitize(value), MAX_DETAIL_LENGTH);
98
+
99
+ /** A phase carries one provider label. Nothing in this package mixes them,
100
+ * but a wrong label is worse than an honest one if that ever changes. */
101
+ const mergeProvider = (current: string, incoming: string): string =>
102
+ current === incoming ? current : 'mixed';
103
+
104
+ interface SearchPhase {
105
+ provider: string;
106
+ queries: number;
107
+ failed: number;
108
+ errors: number;
109
+ durationMs: number;
110
+ types: Map<string, number>;
111
+ reasons: Map<string, number>;
112
+ detail?: string;
113
+ }
114
+
115
+ interface ScrapePhase {
116
+ links: number;
117
+ ok: number;
118
+ empty: number;
119
+ chars: number;
120
+ highlights: number;
121
+ failed: number;
122
+ reasons: Map<string, number>;
123
+ samples: string[];
124
+ detail?: string;
125
+ }
126
+
127
+ interface RerankPhase {
128
+ provider: string;
129
+ model?: string;
130
+ calls: number;
131
+ fallbacks: number;
132
+ errors: number;
133
+ chunks: number;
134
+ maxChunks: number;
135
+ dropped: number;
136
+ topK?: number;
137
+ topKLimit?: number;
138
+ results: number;
139
+ units: number;
140
+ durationMs: number;
141
+ maxDurationMs: number;
142
+ reasons: Map<string, number>;
143
+ error?: t.SafeErrorLog;
144
+ }
145
+
146
+ /**
147
+ * Creates the counter set for one `web_search` call.
148
+ *
149
+ * The pipeline reranks once per scraped source and scrapes once per link, so
150
+ * logging at those points scales the log with the result count. Every phase
151
+ * records into fixed-width counters instead and {@link t.SearchMetrics.flush}
152
+ * emits one line per phase, at the highest severity that phase reached.
153
+ *
154
+ * @param autoFlush emit on every record, for a caller with no enclosing search
155
+ * to fold into (a reranker used directly). Recording is synchronous, so the
156
+ * record/flush/reset cycle can never interleave with a concurrent call.
157
+ */
158
+ export const createSearchMetrics = (
159
+ logger: t.Logger,
160
+ autoFlush = false
161
+ ): t.SearchMetrics => {
162
+ let search: SearchPhase | undefined;
163
+ let scrape: ScrapePhase | undefined;
164
+ let rerank: RerankPhase | undefined;
165
+
166
+ const flushSearch = (phase: SearchPhase): void => {
167
+ let line =
168
+ `[web_search] search=${label(phase.provider)}` +
169
+ ` queries=${phase.queries}`;
170
+ if (phase.types.size > 0) {
171
+ line += ` results=${formatMap(phase.types)}`;
172
+ }
173
+ line += ` dur=${formatMs(phase.durationMs)}`;
174
+ if (phase.failed === 0) {
175
+ logger.debug(line);
176
+ return;
177
+ }
178
+ line += ` failed=${phase.failed} reasons=${formatMap(phase.reasons)}`;
179
+ /** A rejected query was an error-level event before it was aggregated;
180
+ * a provider that merely reported failure was not. */
181
+ if (phase.errors === 0) {
182
+ logger.warn(line);
183
+ return;
184
+ }
185
+ if (phase.detail == null) {
186
+ logger.error(line);
187
+ return;
188
+ }
189
+ logger.error(line, phase.detail);
190
+ };
191
+
192
+ const flushScrape = (phase: ScrapePhase): void => {
193
+ let line =
194
+ `[web_search] scrape links=${phase.links} ok=${phase.ok}` +
195
+ ` chars=${formatCount(phase.chars)} highlights=${phase.highlights}`;
196
+ if (phase.empty > 0) {
197
+ line += ` empty=${phase.empty}`;
198
+ }
199
+ if (phase.failed === 0) {
200
+ logger.debug(line);
201
+ return;
202
+ }
203
+ line += ` failed=${phase.failed} reasons=${formatMap(phase.reasons)}`;
204
+ if (phase.samples.length > 0) {
205
+ line += ` sample=${phase.samples.join(',')}`;
206
+ }
207
+ if (phase.detail == null) {
208
+ logger.error(line);
209
+ return;
210
+ }
211
+ logger.error(line, phase.detail);
212
+ };
213
+
214
+ const flushRerank = (phase: RerankPhase): void => {
215
+ let line =
216
+ `[web_search] rerank=${label(phase.provider)} calls=${phase.calls}` +
217
+ ` chunks=${phase.chunks} maxChunks=${phase.maxChunks}` +
218
+ ` results=${phase.results}` +
219
+ ` dur=${formatMs(phase.durationMs)}` +
220
+ ` maxDur=${formatMs(phase.maxDurationMs)}`;
221
+ if (phase.model != null) {
222
+ /** The only free-form remote value in the line, so it is the one field
223
+ * that gets delimited — a truncated payload cannot then read as
224
+ * further fields. */
225
+ line += ` model="${label(phase.model)}"`;
226
+ }
227
+ if (phase.units > 0) {
228
+ line += ` units=${phase.units}`;
229
+ }
230
+ if (phase.dropped > 0) {
231
+ line += ` dropped=${phase.dropped}`;
232
+ }
233
+ if (phase.topKLimit != null) {
234
+ line += ` topK=${phase.topK} topKLimit=${phase.topKLimit}`;
235
+ }
236
+ if (phase.fallbacks === 0) {
237
+ logger.debug(line);
238
+ return;
239
+ }
240
+ line += ` fallbacks=${phase.fallbacks} reasons=${formatMap(phase.reasons)}`;
241
+ /** A placeholder reranker returns input order by design, so a phase whose
242
+ * only fallbacks are placeholders is the configuration working — it stays
243
+ * at the debug level it had before these counters existed. */
244
+ const degraded = phase.fallbacks - (phase.reasons.get('placeholder') ?? 0);
245
+ if (degraded === 0 && phase.errors === 0) {
246
+ logger.debug(line);
247
+ return;
248
+ }
249
+ if (phase.errors === 0) {
250
+ logger.warn(line);
251
+ return;
252
+ }
253
+ if (phase.error == null) {
254
+ logger.error(line);
255
+ return;
256
+ }
257
+ logger.error(line, phase.error);
258
+ };
259
+
260
+ const flush = (): void => {
261
+ if (search != null) {
262
+ flushSearch(search);
263
+ search = undefined;
264
+ }
265
+ if (scrape != null) {
266
+ flushScrape(scrape);
267
+ scrape = undefined;
268
+ }
269
+ if (rerank != null) {
270
+ flushRerank(rerank);
271
+ rerank = undefined;
272
+ }
273
+ };
274
+
275
+ const recordSearch = (observation: t.SearchObservation): void => {
276
+ search ??= {
277
+ provider: observation.provider,
278
+ queries: 0,
279
+ failed: 0,
280
+ errors: 0,
281
+ durationMs: 0,
282
+ types: new Map(),
283
+ reasons: new Map(),
284
+ };
285
+ search.provider = mergeProvider(search.provider, observation.provider);
286
+ search.queries += 1;
287
+ /** Sub-searches run concurrently, so the phase lasts as long as its
288
+ * slowest query rather than the sum of all of them. */
289
+ search.durationMs = Math.max(search.durationMs, observation.durationMs);
290
+ if (observation.error != null) {
291
+ search.failed += 1;
292
+ /** Provider messages are prose: classify before counting, or equivalent
293
+ * failures never collapse and the raw text lands in the summary. */
294
+ bump(
295
+ search.reasons,
296
+ `${observation.type}:${classifyFailure(observation.error)}`
297
+ );
298
+ /** `detail` is only ever logged on the error path, so it is captured
299
+ * only from a thrown observation — otherwise an earlier soft failure's
300
+ * message would be attached to an exception it has nothing to do
301
+ * with, and the real one dropped. */
302
+ if (observation.thrown === true) {
303
+ search.errors += 1;
304
+ search.detail ??= detailOf(observation.error);
305
+ }
306
+ } else if (observation.results > 0) {
307
+ const type = label(observation.type);
308
+ const seen = search.types.get(type) ?? 0;
309
+ search.types.set(type, seen + observation.results);
310
+ }
311
+ if (autoFlush) {
312
+ flush();
313
+ }
314
+ };
315
+
316
+ const recordScrape = (observation: t.ScrapeObservation): void => {
317
+ scrape ??= {
318
+ links: 0,
319
+ ok: 0,
320
+ empty: 0,
321
+ chars: 0,
322
+ highlights: 0,
323
+ failed: 0,
324
+ reasons: new Map(),
325
+ samples: [],
326
+ detail: undefined,
327
+ };
328
+ scrape.links += 1;
329
+ if (observation.error != null) {
330
+ scrape.failed += 1;
331
+ const reason = classifyFailure(observation.error);
332
+ bump(scrape.reasons, reason);
333
+ if (scrape.samples.length < MAX_FAILURE_SAMPLES) {
334
+ scrape.samples.push(label(`${hostOf(observation.url)}:${reason}`));
335
+ }
336
+ scrape.detail ??= detailOf(observation.error);
337
+ } else {
338
+ scrape.ok += 1;
339
+ const chars = observation.chars ?? 0;
340
+ scrape.chars += chars;
341
+ scrape.highlights += observation.highlights ?? 0;
342
+ if (chars === 0) {
343
+ scrape.empty += 1;
344
+ }
345
+ }
346
+ if (autoFlush) {
347
+ flush();
348
+ }
349
+ };
350
+
351
+ const recordRerank = (observation: t.RerankObservation): void => {
352
+ rerank ??= {
353
+ provider: observation.provider,
354
+ calls: 0,
355
+ fallbacks: 0,
356
+ errors: 0,
357
+ chunks: 0,
358
+ maxChunks: 0,
359
+ dropped: 0,
360
+ results: 0,
361
+ units: 0,
362
+ durationMs: 0,
363
+ maxDurationMs: 0,
364
+ reasons: new Map(),
365
+ };
366
+ rerank.provider = mergeProvider(rerank.provider, observation.provider);
367
+ rerank.calls += 1;
368
+ rerank.chunks += observation.chunks;
369
+ rerank.maxChunks = Math.max(rerank.maxChunks, observation.chunks);
370
+ rerank.results += observation.results;
371
+ rerank.dropped += observation.dropped ?? 0;
372
+ rerank.topK ??= observation.topK;
373
+ rerank.topKLimit ??= observation.topKLimit;
374
+ rerank.units += observation.units ?? 0;
375
+ rerank.durationMs += observation.durationMs;
376
+ rerank.maxDurationMs = Math.max(
377
+ rerank.maxDurationMs,
378
+ observation.durationMs
379
+ );
380
+ if (observation.model != null) {
381
+ rerank.model =
382
+ rerank.model == null
383
+ ? observation.model
384
+ : mergeProvider(rerank.model, observation.model);
385
+ }
386
+ if (observation.reason != null) {
387
+ rerank.fallbacks += 1;
388
+ bump(rerank.reasons, observation.reason);
389
+ }
390
+ if (observation.error != null) {
391
+ rerank.errors += 1;
392
+ rerank.error ??= observation.error;
393
+ }
394
+ if (autoFlush) {
395
+ flush();
396
+ }
397
+ };
398
+
399
+ return { recordSearch, recordScrape, recordRerank, flush };
400
+ };