@librechat/agents 3.1.75-dev.1 → 3.1.76

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.
Files changed (51) hide show
  1. package/dist/cjs/llm/openai/index.cjs +43 -0
  2. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  3. package/dist/cjs/llm/openai/utils/index.cjs +19 -10
  4. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  5. package/dist/cjs/messages/format.cjs +67 -10
  6. package/dist/cjs/messages/format.cjs.map +1 -1
  7. package/dist/cjs/tools/search/search.cjs +55 -66
  8. package/dist/cjs/tools/search/search.cjs.map +1 -1
  9. package/dist/cjs/tools/search/tavily-scraper.cjs +189 -0
  10. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -0
  11. package/dist/cjs/tools/search/tavily-search.cjs +372 -0
  12. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -0
  13. package/dist/cjs/tools/search/tool.cjs +26 -4
  14. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  15. package/dist/cjs/tools/search/utils.cjs +10 -3
  16. package/dist/cjs/tools/search/utils.cjs.map +1 -1
  17. package/dist/esm/llm/openai/index.mjs +43 -0
  18. package/dist/esm/llm/openai/index.mjs.map +1 -1
  19. package/dist/esm/llm/openai/utils/index.mjs +19 -10
  20. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  21. package/dist/esm/messages/format.mjs +67 -10
  22. package/dist/esm/messages/format.mjs.map +1 -1
  23. package/dist/esm/tools/search/search.mjs +55 -66
  24. package/dist/esm/tools/search/search.mjs.map +1 -1
  25. package/dist/esm/tools/search/tavily-scraper.mjs +186 -0
  26. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -0
  27. package/dist/esm/tools/search/tavily-search.mjs +370 -0
  28. package/dist/esm/tools/search/tavily-search.mjs.map +1 -0
  29. package/dist/esm/tools/search/tool.mjs +26 -4
  30. package/dist/esm/tools/search/tool.mjs.map +1 -1
  31. package/dist/esm/tools/search/utils.mjs +10 -3
  32. package/dist/esm/tools/search/utils.mjs.map +1 -1
  33. package/dist/types/messages/format.d.ts +4 -1
  34. package/dist/types/tools/search/tavily-scraper.d.ts +19 -0
  35. package/dist/types/tools/search/tavily-search.d.ts +4 -0
  36. package/dist/types/tools/search/types.d.ts +99 -5
  37. package/dist/types/tools/search/utils.d.ts +2 -2
  38. package/package.json +1 -1
  39. package/src/llm/custom-chat-models.smoke.test.ts +175 -1
  40. package/src/llm/openai/index.ts +124 -0
  41. package/src/llm/openai/utils/index.ts +23 -14
  42. package/src/llm/openai/utils/messages.test.ts +159 -0
  43. package/src/messages/format.ts +90 -13
  44. package/src/messages/formatAgentMessages.test.ts +166 -1
  45. package/src/tools/search/search.ts +83 -73
  46. package/src/tools/search/tavily-scraper.ts +235 -0
  47. package/src/tools/search/tavily-search.ts +424 -0
  48. package/src/tools/search/tavily.test.ts +965 -0
  49. package/src/tools/search/tool.ts +36 -26
  50. package/src/tools/search/types.ts +134 -11
  51. package/src/tools/search/utils.ts +13 -5
@@ -0,0 +1,965 @@
1
+ import axios from 'axios';
2
+ import type * as t from './types';
3
+ import { TavilyScraper, createTavilyScraper } from './tavily-scraper';
4
+ import { createSearchAPI } from './search';
5
+ import { createSearchTool } from './tool';
6
+
7
+ jest.mock('axios');
8
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
9
+
10
+ const mockLogger = {
11
+ error: jest.fn(),
12
+ warn: jest.fn(),
13
+ info: jest.fn(),
14
+ debug: jest.fn(),
15
+ } as unknown as t.Logger;
16
+
17
+ describe('Tavily search API', () => {
18
+ beforeEach(() => {
19
+ jest.clearAllMocks();
20
+ });
21
+
22
+ it('throws when Tavily API key is missing', () => {
23
+ expect(() =>
24
+ createSearchAPI({
25
+ searchProvider: 'tavily',
26
+ tavilyApiKey: '',
27
+ })
28
+ ).toThrow('TAVILY_API_KEY is required for Tavily API');
29
+ });
30
+
31
+ it('returns an error for empty Tavily search queries', async () => {
32
+ const searchAPI = createSearchAPI({
33
+ searchProvider: 'tavily',
34
+ tavilyApiKey: 'test-key',
35
+ });
36
+
37
+ const result = await searchAPI.getSources({ query: ' ' });
38
+
39
+ expect(result).toEqual({
40
+ success: false,
41
+ error: 'Query cannot be empty',
42
+ });
43
+ expect(mockedAxios.post).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it('returns an error when the Tavily search request fails', async () => {
47
+ mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
48
+
49
+ const searchAPI = createSearchAPI({
50
+ searchProvider: 'tavily',
51
+ tavilyApiKey: 'test-key',
52
+ });
53
+
54
+ const result = await searchAPI.getSources({ query: 'example query' });
55
+
56
+ expect(result.success).toBe(false);
57
+ expect(result.error).toBe('Tavily API request failed: Network error');
58
+ });
59
+
60
+ it('passes string-mode options and maps Tavily response fields', async () => {
61
+ mockedAxios.post.mockResolvedValueOnce({
62
+ data: {
63
+ answer: 'A concise answer.',
64
+ images: [
65
+ {
66
+ url: 'https://example.com/image.png',
67
+ description: 'Example image',
68
+ },
69
+ {
70
+ description: 'Skipped image',
71
+ },
72
+ 'https://example.com/second.png',
73
+ ],
74
+ results: [
75
+ {
76
+ title: 'Example',
77
+ url: 'https://example.com',
78
+ content: 'Example summary',
79
+ published_date: '2026-01-02',
80
+ },
81
+ ],
82
+ },
83
+ });
84
+
85
+ const searchAPI = createSearchAPI({
86
+ searchProvider: 'tavily',
87
+ tavilyApiKey: 'test-key',
88
+ tavilySearchOptions: {
89
+ includeAnswer: 'advanced',
90
+ includeRawContent: 'markdown',
91
+ includeImages: true,
92
+ includeImageDescriptions: true,
93
+ safeSearch: true,
94
+ timeRange: 'd',
95
+ },
96
+ });
97
+
98
+ const result = await searchAPI.getSources({
99
+ query: 'example query',
100
+ country: 'US',
101
+ });
102
+ const [, payload] = mockedAxios.post.mock.calls[0];
103
+
104
+ expect(payload).toMatchObject({
105
+ query: 'example query',
106
+ country: 'united states',
107
+ safe_search: true,
108
+ include_answer: 'advanced',
109
+ include_raw_content: 'markdown',
110
+ include_images: true,
111
+ include_image_descriptions: true,
112
+ time_range: 'day',
113
+ });
114
+ expect(result.success).toBe(true);
115
+ expect(result.data?.answerBox?.snippet).toBe('A concise answer.');
116
+ expect(result.data?.organic?.[0]).toMatchObject({
117
+ title: 'Example',
118
+ link: 'https://example.com',
119
+ snippet: 'Example summary',
120
+ date: '2026-01-02',
121
+ });
122
+ expect(result.data?.images?.[0]).toMatchObject({
123
+ imageUrl: 'https://example.com/image.png',
124
+ title: 'Example image',
125
+ position: 1,
126
+ });
127
+ expect(result.data?.images?.[1]).toMatchObject({
128
+ imageUrl: 'https://example.com/second.png',
129
+ position: 2,
130
+ });
131
+ });
132
+
133
+ it('omits country for Tavily news searches', async () => {
134
+ mockedAxios.post.mockResolvedValueOnce({
135
+ data: {
136
+ results: [],
137
+ },
138
+ });
139
+
140
+ const searchAPI = createSearchAPI({
141
+ searchProvider: 'tavily',
142
+ tavilyApiKey: 'test-key',
143
+ });
144
+
145
+ await searchAPI.getSources({
146
+ query: 'example query',
147
+ country: 'US',
148
+ news: true,
149
+ });
150
+ const [, payload] = mockedAxios.post.mock.calls[0];
151
+
152
+ expect(payload).toMatchObject({
153
+ query: 'example query',
154
+ topic: 'news',
155
+ });
156
+ expect(payload).not.toHaveProperty('country');
157
+ expect(payload).not.toHaveProperty('safe_search');
158
+ });
159
+
160
+ it('prioritizes request-level news mode over configured Tavily topic', async () => {
161
+ mockedAxios.post.mockResolvedValueOnce({
162
+ data: {
163
+ results: [
164
+ {
165
+ title: 'Market news',
166
+ url: 'https://example.com/news',
167
+ content: 'A news result',
168
+ published_date: '2026-01-03',
169
+ },
170
+ ],
171
+ },
172
+ });
173
+
174
+ const searchAPI = createSearchAPI({
175
+ searchProvider: 'tavily',
176
+ tavilyApiKey: 'test-key',
177
+ tavilySearchOptions: {
178
+ topic: 'finance',
179
+ },
180
+ });
181
+
182
+ const result = await searchAPI.getSources({
183
+ query: 'example query',
184
+ type: 'news',
185
+ });
186
+ const [, payload] = mockedAxios.post.mock.calls[0];
187
+
188
+ expect(payload).toMatchObject({
189
+ query: 'example query',
190
+ topic: 'news',
191
+ });
192
+ expect(result.data?.news?.[0]).toMatchObject({
193
+ title: 'Market news',
194
+ link: 'https://example.com/news',
195
+ });
196
+ });
197
+
198
+ it('maps ISO country codes to Tavily country enum values', async () => {
199
+ mockedAxios.post.mockResolvedValueOnce({
200
+ data: {
201
+ results: [],
202
+ },
203
+ });
204
+
205
+ const searchAPI = createSearchAPI({
206
+ searchProvider: 'tavily',
207
+ tavilyApiKey: 'test-key',
208
+ });
209
+
210
+ await searchAPI.getSources({
211
+ query: 'example query',
212
+ country: 'CZ',
213
+ });
214
+ const [, payload] = mockedAxios.post.mock.calls[0];
215
+
216
+ expect(payload).toMatchObject({
217
+ query: 'example query',
218
+ country: 'czech republic',
219
+ });
220
+ expect(payload).not.toHaveProperty('safe_search');
221
+ });
222
+
223
+ it('omits ISO country mappings that Tavily does not support', async () => {
224
+ mockedAxios.post.mockResolvedValueOnce({
225
+ data: {
226
+ results: [],
227
+ },
228
+ });
229
+
230
+ const searchAPI = createSearchAPI({
231
+ searchProvider: 'tavily',
232
+ tavilyApiKey: 'test-key',
233
+ });
234
+
235
+ await searchAPI.getSources({
236
+ query: 'example query',
237
+ country: 'CD',
238
+ });
239
+ const [, payload] = mockedAxios.post.mock.calls[0];
240
+
241
+ expect(payload).toMatchObject({
242
+ query: 'example query',
243
+ });
244
+ expect(payload).not.toHaveProperty('country');
245
+ expect(payload).not.toHaveProperty('safe_search');
246
+ });
247
+
248
+ it('omits safe search for unsupported Tavily search depths', async () => {
249
+ mockedAxios.post.mockResolvedValueOnce({
250
+ data: {
251
+ results: [],
252
+ },
253
+ });
254
+
255
+ const searchAPI = createSearchAPI({
256
+ searchProvider: 'tavily',
257
+ tavilyApiKey: 'test-key',
258
+ tavilySearchOptions: {
259
+ searchDepth: 'fast',
260
+ safeSearch: true,
261
+ },
262
+ });
263
+
264
+ await searchAPI.getSources({
265
+ query: 'example query',
266
+ });
267
+ const [, payload] = mockedAxios.post.mock.calls[0];
268
+
269
+ expect(payload).toMatchObject({
270
+ query: 'example query',
271
+ search_depth: 'fast',
272
+ });
273
+ expect(payload).not.toHaveProperty('safe_search');
274
+ });
275
+
276
+ it('only sends chunks per source for advanced Tavily searches', async () => {
277
+ mockedAxios.post.mockResolvedValue({
278
+ data: {
279
+ results: [],
280
+ },
281
+ });
282
+
283
+ const basicSearchAPI = createSearchAPI({
284
+ searchProvider: 'tavily',
285
+ tavilyApiKey: 'test-key',
286
+ tavilySearchOptions: {
287
+ chunksPerSource: 2,
288
+ },
289
+ });
290
+
291
+ await basicSearchAPI.getSources({
292
+ query: 'example query',
293
+ });
294
+ const [, basicPayload] = mockedAxios.post.mock.calls[0];
295
+
296
+ expect(basicPayload).toMatchObject({
297
+ query: 'example query',
298
+ search_depth: 'basic',
299
+ });
300
+ expect(basicPayload).not.toHaveProperty('chunks_per_source');
301
+
302
+ const advancedSearchAPI = createSearchAPI({
303
+ searchProvider: 'tavily',
304
+ tavilyApiKey: 'test-key',
305
+ tavilySearchOptions: {
306
+ searchDepth: 'advanced',
307
+ chunksPerSource: 2,
308
+ },
309
+ });
310
+
311
+ await advancedSearchAPI.getSources({
312
+ query: 'example query',
313
+ });
314
+ const [, advancedPayload] = mockedAxios.post.mock.calls[1];
315
+
316
+ expect(advancedPayload).toMatchObject({
317
+ query: 'example query',
318
+ search_depth: 'advanced',
319
+ chunks_per_source: 2,
320
+ });
321
+ });
322
+
323
+ it('uses explicit tool safe search config and skips Tavily video subqueries', async () => {
324
+ mockedAxios.post.mockResolvedValue({
325
+ data: {
326
+ results: [],
327
+ },
328
+ });
329
+
330
+ const searchTool = createSearchTool({
331
+ searchProvider: 'tavily',
332
+ tavilyApiKey: 'test-key',
333
+ scraperProvider: 'tavily',
334
+ rerankerType: 'none',
335
+ logger: mockLogger,
336
+ safeSearch: 2,
337
+ });
338
+
339
+ await searchTool.invoke({
340
+ query: 'example query',
341
+ videos: true,
342
+ });
343
+ const [, payload] = mockedAxios.post.mock.calls[0];
344
+
345
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1);
346
+ expect(payload).toMatchObject({
347
+ query: 'example query',
348
+ safe_search: true,
349
+ });
350
+ });
351
+
352
+ it('lets explicit tool safe search override Tavily option safe search', async () => {
353
+ mockedAxios.post.mockResolvedValue({
354
+ data: {
355
+ results: [],
356
+ },
357
+ });
358
+
359
+ const searchTool = createSearchTool({
360
+ searchProvider: 'tavily',
361
+ tavilyApiKey: 'test-key',
362
+ scraperProvider: 'tavily',
363
+ rerankerType: 'none',
364
+ logger: mockLogger,
365
+ safeSearch: 0,
366
+ tavilySearchOptions: {
367
+ safeSearch: true,
368
+ },
369
+ });
370
+
371
+ await searchTool.invoke({
372
+ query: 'example query',
373
+ });
374
+ const [, payload] = mockedAxios.post.mock.calls[0];
375
+
376
+ expect(payload).toMatchObject({
377
+ query: 'example query',
378
+ safe_search: false,
379
+ });
380
+ });
381
+
382
+ it('preserves Tavily scraper connection overrides in the search tool', async () => {
383
+ mockedAxios.post
384
+ .mockResolvedValueOnce({
385
+ data: {
386
+ results: [
387
+ {
388
+ title: 'Example',
389
+ url: 'https://example.com',
390
+ content: 'Example summary',
391
+ },
392
+ ],
393
+ },
394
+ })
395
+ .mockResolvedValueOnce({
396
+ data: {
397
+ results: [
398
+ {
399
+ url: 'https://example.com',
400
+ raw_content: 'Extracted content',
401
+ images: [],
402
+ },
403
+ ],
404
+ failed_results: [],
405
+ },
406
+ });
407
+
408
+ const searchTool = createSearchTool({
409
+ searchProvider: 'tavily',
410
+ tavilyApiKey: 'search-key',
411
+ scraperProvider: 'tavily',
412
+ tavilyScraperOptions: {
413
+ apiKey: 'scraper-key',
414
+ apiUrl: 'https://proxy.example.com/extract',
415
+ },
416
+ rerankerType: 'none',
417
+ logger: mockLogger,
418
+ });
419
+
420
+ await searchTool.invoke({
421
+ query: 'example query',
422
+ });
423
+ const [, , searchConfig] = mockedAxios.post.mock.calls[0];
424
+ const [extractUrl, , extractConfig] = mockedAxios.post.mock.calls[1];
425
+
426
+ expect(mockedAxios.post).toHaveBeenCalledTimes(2);
427
+ expect(searchConfig).toMatchObject({
428
+ headers: {
429
+ Authorization: 'Bearer search-key',
430
+ },
431
+ });
432
+ expect(extractUrl).toBe('https://proxy.example.com/extract');
433
+ expect(extractConfig).toMatchObject({
434
+ headers: {
435
+ Authorization: 'Bearer scraper-key',
436
+ },
437
+ });
438
+ });
439
+ });
440
+
441
+ describe('TavilyScraper', () => {
442
+ beforeEach(() => {
443
+ jest.clearAllMocks();
444
+ });
445
+
446
+ describe('constructor', () => {
447
+ it('warns when TAVILY_API_KEY is not set', () => {
448
+ const logger = { ...mockLogger, warn: jest.fn() } as unknown as t.Logger;
449
+ new TavilyScraper({ apiKey: '', logger });
450
+ expect(logger.warn).toHaveBeenCalledWith(
451
+ 'TAVILY_API_KEY is not set. Scraping will not work.'
452
+ );
453
+ });
454
+
455
+ it('uses TAVILY_EXTRACT_URL env var for apiUrl', async () => {
456
+ const original = process.env.TAVILY_EXTRACT_URL;
457
+ try {
458
+ process.env.TAVILY_EXTRACT_URL =
459
+ 'https://custom-proxy.example.com/extract';
460
+ mockedAxios.post.mockResolvedValueOnce({
461
+ data: { results: [], failed_results: [] },
462
+ });
463
+ const scraper = new TavilyScraper({
464
+ apiKey: 'test-key',
465
+ logger: mockLogger,
466
+ });
467
+
468
+ await scraper.scrapeUrl('https://example.com');
469
+
470
+ expect(mockedAxios.post.mock.calls[0][0]).toBe(
471
+ 'https://custom-proxy.example.com/extract'
472
+ );
473
+ } finally {
474
+ if (original !== undefined) {
475
+ process.env.TAVILY_EXTRACT_URL = original;
476
+ } else {
477
+ delete process.env.TAVILY_EXTRACT_URL;
478
+ }
479
+ }
480
+ });
481
+
482
+ it('defaults to https://api.tavily.com/extract', async () => {
483
+ const original = process.env.TAVILY_EXTRACT_URL;
484
+ try {
485
+ delete process.env.TAVILY_EXTRACT_URL;
486
+ mockedAxios.post.mockResolvedValueOnce({
487
+ data: { results: [], failed_results: [] },
488
+ });
489
+ const scraper = new TavilyScraper({
490
+ apiKey: 'test-key',
491
+ logger: mockLogger,
492
+ });
493
+
494
+ await scraper.scrapeUrl('https://example.com');
495
+
496
+ expect(mockedAxios.post.mock.calls[0][0]).toBe(
497
+ 'https://api.tavily.com/extract'
498
+ );
499
+ } finally {
500
+ if (original !== undefined) {
501
+ process.env.TAVILY_EXTRACT_URL = original;
502
+ }
503
+ }
504
+ });
505
+
506
+ it('defaults timeout to 15000ms', async () => {
507
+ mockedAxios.post.mockResolvedValueOnce({
508
+ data: { results: [], failed_results: [] },
509
+ });
510
+ const scraper = new TavilyScraper({
511
+ apiKey: 'test-key',
512
+ logger: mockLogger,
513
+ });
514
+
515
+ await scraper.scrapeUrl('https://example.com');
516
+
517
+ expect(mockedAxios.post.mock.calls[0][2]).toMatchObject({
518
+ timeout: 15000,
519
+ });
520
+ });
521
+
522
+ it('defaults advanced extraction timeout to 30000ms', async () => {
523
+ mockedAxios.post.mockResolvedValueOnce({
524
+ data: { results: [], failed_results: [] },
525
+ });
526
+ const scraper = new TavilyScraper({
527
+ apiKey: 'test-key',
528
+ extractDepth: 'advanced',
529
+ logger: mockLogger,
530
+ });
531
+
532
+ await scraper.scrapeUrl('https://example.com');
533
+
534
+ expect(mockedAxios.post.mock.calls[0][2]).toMatchObject({
535
+ timeout: 30000,
536
+ });
537
+ });
538
+ });
539
+
540
+ describe('scrapeUrl', () => {
541
+ it('returns error when API key is not set', async () => {
542
+ const scraper = createTavilyScraper({ apiKey: '', logger: mockLogger });
543
+ const [url, response] = await scraper.scrapeUrl('https://example.com');
544
+ expect(url).toBe('https://example.com');
545
+ expect(response.success).toBe(false);
546
+ expect(response.error).toBe('TAVILY_API_KEY is not set');
547
+ });
548
+
549
+ it('returns scraped content on success', async () => {
550
+ mockedAxios.post.mockResolvedValueOnce({
551
+ data: {
552
+ results: [
553
+ {
554
+ url: 'https://example.com',
555
+ raw_content: '# Hello World\nSome content here.',
556
+ images: ['https://example.com/img.png'],
557
+ favicon: 'https://example.com/favicon.ico',
558
+ },
559
+ ],
560
+ failed_results: [],
561
+ },
562
+ });
563
+
564
+ const scraper = createTavilyScraper({
565
+ apiKey: 'test-key',
566
+ logger: mockLogger,
567
+ });
568
+ const [url, response] = await scraper.scrapeUrl('https://example.com');
569
+
570
+ expect(url).toBe('https://example.com');
571
+ expect(response.success).toBe(true);
572
+ expect(response.data?.rawContent).toBe(
573
+ '# Hello World\nSome content here.'
574
+ );
575
+ expect(response.data?.images).toEqual(['https://example.com/img.png']);
576
+ expect(response.data?.favicon).toBe('https://example.com/favicon.ico');
577
+ });
578
+
579
+ it('applies per-call extract options to the Tavily payload', async () => {
580
+ mockedAxios.post.mockResolvedValueOnce({
581
+ data: {
582
+ results: [
583
+ {
584
+ url: 'https://example.com',
585
+ raw_content: 'Content',
586
+ images: [],
587
+ },
588
+ ],
589
+ failed_results: [],
590
+ },
591
+ });
592
+
593
+ const scraper = createTavilyScraper({
594
+ apiKey: 'test-key',
595
+ logger: mockLogger,
596
+ });
597
+ await scraper.scrapeUrl('https://example.com', {
598
+ includeFavicon: true,
599
+ format: 'text',
600
+ timeout: 2000,
601
+ });
602
+ const [, payload, config] = mockedAxios.post.mock.calls[0];
603
+
604
+ expect(payload).toMatchObject({
605
+ urls: ['https://example.com'],
606
+ include_favicon: true,
607
+ format: 'text',
608
+ timeout: 2,
609
+ });
610
+ expect(payload).not.toHaveProperty('chunks_per_source');
611
+ expect(config).toMatchObject({ timeout: 2000 });
612
+ });
613
+
614
+ it('omits extract timeout from the payload when using Tavily defaults', async () => {
615
+ mockedAxios.post.mockResolvedValueOnce({
616
+ data: {
617
+ results: [
618
+ {
619
+ url: 'https://example.com',
620
+ raw_content: 'Content',
621
+ images: [],
622
+ },
623
+ ],
624
+ failed_results: [],
625
+ },
626
+ });
627
+
628
+ const scraper = createTavilyScraper({
629
+ apiKey: 'test-key',
630
+ logger: mockLogger,
631
+ extractDepth: 'advanced',
632
+ });
633
+ await scraper.scrapeUrl('https://example.com');
634
+ const [, payload, config] = mockedAxios.post.mock.calls[0];
635
+
636
+ expect(payload).toMatchObject({
637
+ urls: ['https://example.com'],
638
+ extract_depth: 'advanced',
639
+ });
640
+ expect(payload).not.toHaveProperty('timeout');
641
+ expect(config).toMatchObject({ timeout: 30000 });
642
+ });
643
+
644
+ it('uses the advanced default client timeout for per-call extract depth', async () => {
645
+ mockedAxios.post.mockResolvedValueOnce({
646
+ data: {
647
+ results: [
648
+ {
649
+ url: 'https://example.com',
650
+ raw_content: 'Content',
651
+ images: [],
652
+ },
653
+ ],
654
+ failed_results: [],
655
+ },
656
+ });
657
+
658
+ const scraper = createTavilyScraper({
659
+ apiKey: 'test-key',
660
+ logger: mockLogger,
661
+ });
662
+ await scraper.scrapeUrl('https://example.com', {
663
+ extractDepth: 'advanced',
664
+ });
665
+ const [, payload, config] = mockedAxios.post.mock.calls[0];
666
+
667
+ expect(payload).toMatchObject({
668
+ urls: ['https://example.com'],
669
+ extract_depth: 'advanced',
670
+ });
671
+ expect(payload).not.toHaveProperty('timeout');
672
+ expect(config).toMatchObject({ timeout: 30000 });
673
+ });
674
+
675
+ it('handles API failure gracefully', async () => {
676
+ mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
677
+
678
+ const scraper = createTavilyScraper({
679
+ apiKey: 'test-key',
680
+ logger: mockLogger,
681
+ });
682
+ const [url, response] = await scraper.scrapeUrl('https://example.com');
683
+
684
+ expect(url).toBe('https://example.com');
685
+ expect(response.success).toBe(false);
686
+ expect(response.error).toContain('Tavily Extract API request failed');
687
+ expect(response.error).toContain('Network error');
688
+ });
689
+
690
+ it('reads error from failed_results when results is empty', async () => {
691
+ mockedAxios.post.mockResolvedValueOnce({
692
+ data: {
693
+ results: [],
694
+ failed_results: [
695
+ {
696
+ url: 'https://example.com',
697
+ error: 'Page is behind a paywall',
698
+ },
699
+ ],
700
+ },
701
+ });
702
+
703
+ const scraper = createTavilyScraper({
704
+ apiKey: 'test-key',
705
+ logger: mockLogger,
706
+ });
707
+ const [url, response] = await scraper.scrapeUrl('https://example.com');
708
+
709
+ expect(url).toBe('https://example.com');
710
+ expect(response.success).toBe(false);
711
+ expect(response.error).toBe('Page is behind a paywall');
712
+ });
713
+
714
+ it('matches normalized URLs from Tavily Extract responses', async () => {
715
+ mockedAxios.post.mockResolvedValueOnce({
716
+ data: {
717
+ results: [
718
+ {
719
+ url: 'https://example.com/article',
720
+ raw_content: 'Content',
721
+ images: [],
722
+ },
723
+ ],
724
+ failed_results: [],
725
+ },
726
+ });
727
+
728
+ const scraper = createTavilyScraper({
729
+ apiKey: 'test-key',
730
+ logger: mockLogger,
731
+ });
732
+ const [url, response] = await scraper.scrapeUrl(
733
+ 'https://example.com/article/'
734
+ );
735
+
736
+ expect(url).toBe('https://example.com/article/');
737
+ expect(response.success).toBe(true);
738
+ expect(response.data?.rawContent).toBe('Content');
739
+ });
740
+
741
+ it('returns descriptive error when URL not in results or failed_results', async () => {
742
+ mockedAxios.post.mockResolvedValueOnce({
743
+ data: { results: [], failed_results: [] },
744
+ });
745
+
746
+ const scraper = createTavilyScraper({
747
+ apiKey: 'test-key',
748
+ logger: mockLogger,
749
+ });
750
+ const [, response] = await scraper.scrapeUrl('https://missing.com');
751
+
752
+ expect(response.success).toBe(false);
753
+ expect(response.error).toBe('URL not found in Tavily Extract response');
754
+ });
755
+ });
756
+
757
+ describe('scrapeUrls (batch)', () => {
758
+ it('batches multiple URLs into a single API call', async () => {
759
+ const urls = [
760
+ 'https://example.com/1',
761
+ 'https://example.com/2',
762
+ 'https://example.com/3',
763
+ ];
764
+
765
+ mockedAxios.post.mockResolvedValueOnce({
766
+ data: {
767
+ results: urls.map((url) => ({
768
+ url,
769
+ raw_content: `Content for ${url}`,
770
+ images: [],
771
+ })),
772
+ failed_results: [],
773
+ },
774
+ });
775
+
776
+ const scraper = createTavilyScraper({
777
+ apiKey: 'test-key',
778
+ logger: mockLogger,
779
+ });
780
+ const results = await scraper.scrapeUrls(urls);
781
+
782
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1);
783
+ expect(results).toHaveLength(3);
784
+
785
+ for (let i = 0; i < results.length; i++) {
786
+ const [url, response] = results[i];
787
+ expect(url).toBe(urls[i]);
788
+ expect(response.success).toBe(true);
789
+ expect(response.data?.rawContent).toBe(`Content for ${urls[i]}`);
790
+ }
791
+ });
792
+
793
+ it('handles mixed success and failure results', async () => {
794
+ mockedAxios.post.mockResolvedValueOnce({
795
+ data: {
796
+ results: [
797
+ {
798
+ url: 'https://example.com/ok',
799
+ raw_content: 'Good content',
800
+ images: [],
801
+ },
802
+ ],
803
+ failed_results: [
804
+ {
805
+ url: 'https://example.com/fail',
806
+ error: 'Access denied',
807
+ },
808
+ ],
809
+ },
810
+ });
811
+
812
+ const scraper = createTavilyScraper({
813
+ apiKey: 'test-key',
814
+ logger: mockLogger,
815
+ });
816
+ const results = await scraper.scrapeUrls([
817
+ 'https://example.com/ok',
818
+ 'https://example.com/fail',
819
+ ]);
820
+
821
+ expect(results).toHaveLength(2);
822
+ expect(results[0][1].success).toBe(true);
823
+ expect(results[1][1].success).toBe(false);
824
+ expect(results[1][1].error).toBe('Access denied');
825
+ });
826
+
827
+ it('splits large batches into chunks of 20', async () => {
828
+ const urls = Array.from(
829
+ { length: 25 },
830
+ (_, i) => `https://example.com/${i}`
831
+ );
832
+
833
+ mockedAxios.post
834
+ .mockResolvedValueOnce({
835
+ data: {
836
+ results: urls.slice(0, 20).map((url) => ({
837
+ url,
838
+ raw_content: 'content',
839
+ images: [],
840
+ })),
841
+ failed_results: [],
842
+ },
843
+ })
844
+ .mockResolvedValueOnce({
845
+ data: {
846
+ results: urls.slice(20).map((url) => ({
847
+ url,
848
+ raw_content: 'content',
849
+ images: [],
850
+ })),
851
+ failed_results: [],
852
+ },
853
+ });
854
+
855
+ const scraper = createTavilyScraper({
856
+ apiKey: 'test-key',
857
+ logger: mockLogger,
858
+ });
859
+ const results = await scraper.scrapeUrls(urls);
860
+
861
+ expect(mockedAxios.post).toHaveBeenCalledTimes(2);
862
+ expect(results).toHaveLength(25);
863
+ });
864
+
865
+ it('returns errors for all URLs when API key is missing', async () => {
866
+ const scraper = createTavilyScraper({ apiKey: '', logger: mockLogger });
867
+ const results = await scraper.scrapeUrls([
868
+ 'https://a.com',
869
+ 'https://b.com',
870
+ ]);
871
+
872
+ expect(results).toHaveLength(2);
873
+ for (const [, response] of results) {
874
+ expect(response.success).toBe(false);
875
+ expect(response.error).toBe('TAVILY_API_KEY is not set');
876
+ }
877
+ });
878
+ });
879
+
880
+ describe('extractContent', () => {
881
+ it('returns content and image references', () => {
882
+ const scraper = createTavilyScraper({
883
+ apiKey: 'test-key',
884
+ logger: mockLogger,
885
+ });
886
+ const [content, references] = scraper.extractContent({
887
+ success: true,
888
+ data: {
889
+ rawContent: 'Hello world',
890
+ images: ['https://img.example.com/1.png'],
891
+ },
892
+ });
893
+
894
+ expect(content).toBe('Hello world');
895
+ expect(references).toBeDefined();
896
+ expect(references?.images).toHaveLength(1);
897
+ expect(references?.images[0].originalUrl).toBe(
898
+ 'https://img.example.com/1.png'
899
+ );
900
+ });
901
+
902
+ it('returns empty content for failed response', () => {
903
+ const scraper = createTavilyScraper({
904
+ apiKey: 'test-key',
905
+ logger: mockLogger,
906
+ });
907
+ const [content, references] = scraper.extractContent({
908
+ success: false,
909
+ error: 'Failed',
910
+ });
911
+
912
+ expect(content).toBe('');
913
+ expect(references).toBeUndefined();
914
+ });
915
+
916
+ it('returns undefined references when no images', () => {
917
+ const scraper = createTavilyScraper({
918
+ apiKey: 'test-key',
919
+ logger: mockLogger,
920
+ });
921
+ const [content, references] = scraper.extractContent({
922
+ success: true,
923
+ data: { rawContent: 'No images here', images: [] },
924
+ });
925
+
926
+ expect(content).toBe('No images here');
927
+ expect(references).toBeUndefined();
928
+ });
929
+ });
930
+
931
+ describe('extractMetadata', () => {
932
+ it('returns images_count for successful response', () => {
933
+ const scraper = createTavilyScraper({
934
+ apiKey: 'test-key',
935
+ logger: mockLogger,
936
+ });
937
+ const metadata = scraper.extractMetadata({
938
+ success: true,
939
+ data: {
940
+ rawContent: 'content',
941
+ images: ['a', 'b', 'c'],
942
+ favicon: 'https://example.com/favicon.ico',
943
+ },
944
+ });
945
+
946
+ expect(metadata).toEqual({
947
+ favicon: 'https://example.com/favicon.ico',
948
+ images_count: 3,
949
+ });
950
+ });
951
+
952
+ it('returns empty object for failed response', () => {
953
+ const scraper = createTavilyScraper({
954
+ apiKey: 'test-key',
955
+ logger: mockLogger,
956
+ });
957
+ const metadata = scraper.extractMetadata({
958
+ success: false,
959
+ error: 'Failed',
960
+ });
961
+
962
+ expect(metadata).toEqual({});
963
+ });
964
+ });
965
+ });