tavily-python 0.7.12__tar.gz → 0.7.14__tar.gz

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 (23) hide show
  1. {tavily_python-0.7.12 → tavily_python-0.7.14}/PKG-INFO +58 -3
  2. {tavily_python-0.7.12 → tavily_python-0.7.14}/README.md +56 -1
  3. {tavily_python-0.7.12 → tavily_python-0.7.14}/setup.py +2 -2
  4. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily/async_tavily.py +184 -21
  5. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily/errors.py +1 -1
  6. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily/tavily.py +193 -25
  7. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily_python.egg-info/PKG-INFO +58 -3
  8. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily_python.egg-info/SOURCES.txt +1 -0
  9. tavily_python-0.7.14/tests/test_research.py +149 -0
  10. {tavily_python-0.7.12 → tavily_python-0.7.14}/LICENSE +0 -0
  11. {tavily_python-0.7.12 → tavily_python-0.7.14}/setup.cfg +0 -0
  12. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily/__init__.py +0 -0
  13. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily/config.py +0 -0
  14. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily/hybrid_rag/__init__.py +0 -0
  15. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily/hybrid_rag/hybrid_rag.py +0 -0
  16. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily/utils.py +0 -0
  17. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily_python.egg-info/dependency_links.txt +0 -0
  18. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily_python.egg-info/requires.txt +0 -0
  19. {tavily_python-0.7.12 → tavily_python-0.7.14}/tavily_python.egg-info/top_level.txt +0 -0
  20. {tavily_python-0.7.12 → tavily_python-0.7.14}/tests/test_crawl.py +0 -0
  21. {tavily_python-0.7.12 → tavily_python-0.7.14}/tests/test_errors.py +0 -0
  22. {tavily_python-0.7.12 → tavily_python-0.7.14}/tests/test_map.py +0 -0
  23. {tavily_python-0.7.12 → tavily_python-0.7.14}/tests/test_search.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tavily-python
3
- Version: 0.7.12
3
+ Version: 0.7.14
4
4
  Summary: Python wrapper for the Tavily API
5
5
  Home-page: https://github.com/tavily-ai/tavily-python
6
6
  Author: Tavily AI
@@ -8,7 +8,7 @@ Author-email: support@tavily.com
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.6
11
+ Requires-Python: >=3.8
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: requests
@@ -32,7 +32,7 @@ Dynamic: summary
32
32
  [![License](https://img.shields.io/github/license/tavily-ai/tavily-python)](https://github.com/tavily-ai/tavily-python/blob/main/LICENSE)
33
33
  [![CI](https://github.com/tavily-ai/tavily-python/actions/workflows/tests.yml/badge.svg)](https://github.com/tavily-ai/tavily-python/actions)
34
34
 
35
- The Tavily Python wrapper allows for easy interaction with the Tavily API, offering the full range of our search and extract functionalities directly from your Python programs. Easily integrate smart search and content extraction capabilities into your applications, harnessing Tavily's powerful search and extract features.
35
+ The Tavily Python wrapper allows for easy interaction with the Tavily API, offering the full range of our search, extract, crawl, map, and research functionalities directly from your Python programs. Easily integrate smart search, content extraction, and research capabilities into your applications, harnessing Tavily's powerful features.
36
36
 
37
37
  ## Installing
38
38
 
@@ -41,6 +41,7 @@ pip install tavily-python
41
41
  ```
42
42
 
43
43
  # Tavily Search
44
+
44
45
  Search lets you search the web for a given query.
45
46
 
46
47
  ## Usage
@@ -99,6 +100,7 @@ print(answer)
99
100
  This is how you get accurate and concise answers to questions, in one line of code. Perfect for usage by LLMs!
100
101
 
101
102
  # Tavily Extract
103
+
102
104
  Extract web page content from one or more specified URLs.
103
105
 
104
106
  ## Usage
@@ -203,6 +205,59 @@ for result in response["results"]:
203
205
 
204
206
  ```
205
207
 
208
+ # Tavily Research
209
+
210
+ Research lets you create comprehensive research reports on any topic, with automatic source gathering, analysis, and structured output.
211
+
212
+ ## Usage
213
+
214
+ Below are some code snippets that demonstrate how to interact with our Research API. Each step and component of this code is explained in greater detail in the API Methods section below.
215
+
216
+ ### Creating a research task and retrieving results
217
+
218
+ ```python
219
+ from tavily import TavilyClient
220
+
221
+ # Step 1. Instantiating your TavilyClient
222
+ tavily_client = TavilyClient(api_key="tvly-YOUR_API_KEY")
223
+
224
+ # Step 2. Creating a research task
225
+ response = tavily_client.research(
226
+ input="Research the latest developments in AI",
227
+ model="pro",
228
+ citation_format="apa"
229
+ )
230
+
231
+ # Step 3. Retrieving the research results
232
+ request_id = response["request_id"]
233
+ result = tavily_client.get_research(request_id)
234
+
235
+ # Step 4. Printing the research report
236
+ print(f"Status: {result['status']}")
237
+ print(f"Content: {result['content']}")
238
+ print(f"Sources: {len(result['sources'])} sources found")
239
+ ```
240
+
241
+ ### Streaming research results
242
+
243
+ ```python
244
+ from tavily import TavilyClient
245
+
246
+ # Step 1. Instantiating your TavilyClient
247
+ tavily_client = TavilyClient(api_key="tvly-YOUR_API_KEY")
248
+
249
+ # Step 2. Creating a streaming research task
250
+ stream = tavily_client.research(
251
+ input="Research the latest developments in AI",
252
+ model="pro",
253
+ stream=True
254
+ )
255
+
256
+ # Step 3. Processing the stream as it arrives
257
+ for chunk in stream:
258
+ print(chunk.decode('utf-8'))
259
+ ```
260
+
206
261
  ## Documentation
207
262
 
208
263
  For a complete guide on how to use the different endpoints and their parameters, please head to our [Python API Reference](https://docs.tavily.com/sdk/python/reference).
@@ -5,7 +5,7 @@
5
5
  [![License](https://img.shields.io/github/license/tavily-ai/tavily-python)](https://github.com/tavily-ai/tavily-python/blob/main/LICENSE)
6
6
  [![CI](https://github.com/tavily-ai/tavily-python/actions/workflows/tests.yml/badge.svg)](https://github.com/tavily-ai/tavily-python/actions)
7
7
 
8
- The Tavily Python wrapper allows for easy interaction with the Tavily API, offering the full range of our search and extract functionalities directly from your Python programs. Easily integrate smart search and content extraction capabilities into your applications, harnessing Tavily's powerful search and extract features.
8
+ The Tavily Python wrapper allows for easy interaction with the Tavily API, offering the full range of our search, extract, crawl, map, and research functionalities directly from your Python programs. Easily integrate smart search, content extraction, and research capabilities into your applications, harnessing Tavily's powerful features.
9
9
 
10
10
  ## Installing
11
11
 
@@ -14,6 +14,7 @@ pip install tavily-python
14
14
  ```
15
15
 
16
16
  # Tavily Search
17
+
17
18
  Search lets you search the web for a given query.
18
19
 
19
20
  ## Usage
@@ -72,6 +73,7 @@ print(answer)
72
73
  This is how you get accurate and concise answers to questions, in one line of code. Perfect for usage by LLMs!
73
74
 
74
75
  # Tavily Extract
76
+
75
77
  Extract web page content from one or more specified URLs.
76
78
 
77
79
  ## Usage
@@ -176,6 +178,59 @@ for result in response["results"]:
176
178
 
177
179
  ```
178
180
 
181
+ # Tavily Research
182
+
183
+ Research lets you create comprehensive research reports on any topic, with automatic source gathering, analysis, and structured output.
184
+
185
+ ## Usage
186
+
187
+ Below are some code snippets that demonstrate how to interact with our Research API. Each step and component of this code is explained in greater detail in the API Methods section below.
188
+
189
+ ### Creating a research task and retrieving results
190
+
191
+ ```python
192
+ from tavily import TavilyClient
193
+
194
+ # Step 1. Instantiating your TavilyClient
195
+ tavily_client = TavilyClient(api_key="tvly-YOUR_API_KEY")
196
+
197
+ # Step 2. Creating a research task
198
+ response = tavily_client.research(
199
+ input="Research the latest developments in AI",
200
+ model="pro",
201
+ citation_format="apa"
202
+ )
203
+
204
+ # Step 3. Retrieving the research results
205
+ request_id = response["request_id"]
206
+ result = tavily_client.get_research(request_id)
207
+
208
+ # Step 4. Printing the research report
209
+ print(f"Status: {result['status']}")
210
+ print(f"Content: {result['content']}")
211
+ print(f"Sources: {len(result['sources'])} sources found")
212
+ ```
213
+
214
+ ### Streaming research results
215
+
216
+ ```python
217
+ from tavily import TavilyClient
218
+
219
+ # Step 1. Instantiating your TavilyClient
220
+ tavily_client = TavilyClient(api_key="tvly-YOUR_API_KEY")
221
+
222
+ # Step 2. Creating a streaming research task
223
+ stream = tavily_client.research(
224
+ input="Research the latest developments in AI",
225
+ model="pro",
226
+ stream=True
227
+ )
228
+
229
+ # Step 3. Processing the stream as it arrives
230
+ for chunk in stream:
231
+ print(chunk.decode('utf-8'))
232
+ ```
233
+
179
234
  ## Documentation
180
235
 
181
236
  For a complete guide on how to use the different endpoints and their parameters, please head to our [Python API Reference](https://docs.tavily.com/sdk/python/reference).
@@ -5,7 +5,7 @@ with open('README.md', 'r', encoding='utf-8') as f:
5
5
 
6
6
  setup(
7
7
  name='tavily-python',
8
- version='0.7.12',
8
+ version='0.7.14',
9
9
  url='https://github.com/tavily-ai/tavily-python',
10
10
  author='Tavily AI',
11
11
  author_email='support@tavily.com',
@@ -19,5 +19,5 @@ setup(
19
19
  'License :: OSI Approved :: MIT License',
20
20
  'Operating System :: OS Independent',
21
21
  ],
22
- python_requires='>=3.6',
22
+ python_requires='>=3.8',
23
23
  )
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  import json
3
3
  import os
4
- from typing import Literal, Sequence, Optional, List, Union
4
+ from typing import Literal, Sequence, Optional, List, Union, AsyncGenerator, Awaitable
5
5
 
6
6
  import httpx
7
7
 
@@ -66,7 +66,7 @@ class AsyncTavilyClient:
66
66
  include_answer: Union[bool, Literal["basic", "advanced"]] = None,
67
67
  include_raw_content: Union[bool, Literal["markdown", "text"]] = None,
68
68
  include_images: bool = None,
69
- timeout: int = 60,
69
+ timeout: float = 60,
70
70
  country: str = None,
71
71
  auto_parameters: bool = None,
72
72
  include_favicon: bool = None,
@@ -141,7 +141,7 @@ class AsyncTavilyClient:
141
141
  include_answer: Union[bool, Literal["basic", "advanced"]] = None,
142
142
  include_raw_content: Union[bool, Literal["markdown", "text"]] = None,
143
143
  include_images: bool = None,
144
- timeout: int = 60,
144
+ timeout: float = 60,
145
145
  country: str = None,
146
146
  auto_parameters: bool = None,
147
147
  include_favicon: bool = None,
@@ -183,7 +183,7 @@ class AsyncTavilyClient:
183
183
  include_images: bool = None,
184
184
  extract_depth: Literal["basic", "advanced"] = None,
185
185
  format: Literal["markdown", "text"] = None,
186
- timeout: int = 30,
186
+ timeout: float = 30,
187
187
  include_favicon: bool = None,
188
188
  **kwargs
189
189
  ) -> dict:
@@ -205,8 +205,6 @@ class AsyncTavilyClient:
205
205
  if kwargs:
206
206
  data.update(kwargs)
207
207
 
208
- timeout = min(timeout, 120)
209
-
210
208
  async with self._client_creator() as client:
211
209
  try:
212
210
  response = await client.post("/extract", content=json.dumps(data), timeout=timeout)
@@ -239,7 +237,7 @@ class AsyncTavilyClient:
239
237
  include_images: bool = None,
240
238
  extract_depth: Literal["basic", "advanced"] = None,
241
239
  format: Literal["markdown", "text"] = None,
242
- timeout: int = 30,
240
+ timeout: float = 30,
243
241
  include_favicon: bool = None,
244
242
  **kwargs, # Accept custom arguments
245
243
  ) -> dict:
@@ -247,7 +245,6 @@ class AsyncTavilyClient:
247
245
  Combined extract method.
248
246
  include_favicon: If True, include the favicon in the extraction results.
249
247
  """
250
- timeout = min(timeout, 120)
251
248
  response_dict = await self._extract(urls,
252
249
  include_images,
253
250
  extract_depth,
@@ -279,7 +276,7 @@ class AsyncTavilyClient:
279
276
  include_images: bool = None,
280
277
  extract_depth: Literal["basic", "advanced"] = None,
281
278
  format: Literal["markdown", "text"] = None,
282
- timeout: int = 60,
279
+ timeout: float = 150,
283
280
  include_favicon: bool = None,
284
281
  **kwargs
285
282
  ) -> dict:
@@ -300,6 +297,7 @@ class AsyncTavilyClient:
300
297
  "include_images": include_images,
301
298
  "extract_depth": extract_depth,
302
299
  "format": format,
300
+ "timeout": timeout,
303
301
  "include_favicon": include_favicon,
304
302
  }
305
303
 
@@ -308,8 +306,6 @@ class AsyncTavilyClient:
308
306
 
309
307
  data = {k: v for k, v in data.items() if v is not None}
310
308
 
311
- timeout = min(timeout, 120)
312
-
313
309
  async with self._client_creator() as client:
314
310
  try:
315
311
  response = await client.post("/crawl", content=json.dumps(data), timeout=timeout)
@@ -350,7 +346,7 @@ class AsyncTavilyClient:
350
346
  extract_depth: Literal["basic", "advanced"] = None,
351
347
  include_images: bool = None,
352
348
  format: Literal["markdown", "text"] = None,
353
- timeout: int = 60,
349
+ timeout: float = 150,
354
350
  include_favicon: bool = None,
355
351
  **kwargs
356
352
  ) -> dict:
@@ -358,7 +354,6 @@ class AsyncTavilyClient:
358
354
  Combined crawl method.
359
355
 
360
356
  """
361
- timeout = min(timeout, 120)
362
357
  response_dict = await self._crawl(url,
363
358
  max_depth=max_depth,
364
359
  max_breadth=max_breadth,
@@ -390,7 +385,7 @@ class AsyncTavilyClient:
390
385
  exclude_domains: Sequence[str] = None,
391
386
  allow_external: bool = None,
392
387
  include_images: bool = None,
393
- timeout: int = 60,
388
+ timeout: float = 150,
394
389
  **kwargs
395
390
  ) -> dict:
396
391
  """
@@ -408,6 +403,7 @@ class AsyncTavilyClient:
408
403
  "exclude_domains": exclude_domains,
409
404
  "allow_external": allow_external,
410
405
  "include_images": include_images,
406
+ "timeout": timeout,
411
407
  }
412
408
 
413
409
  if kwargs:
@@ -415,8 +411,6 @@ class AsyncTavilyClient:
415
411
 
416
412
  data = {k: v for k, v in data.items() if v is not None}
417
413
 
418
- timeout = min(timeout, 120)
419
-
420
414
  async with self._client_creator() as client:
421
415
  try:
422
416
  response = await client.post("/map", content=json.dumps(data), timeout=timeout)
@@ -455,14 +449,13 @@ class AsyncTavilyClient:
455
449
  exclude_domains: Sequence[str] = None,
456
450
  allow_external: bool = None,
457
451
  include_images: bool = None,
458
- timeout: int = 60,
452
+ timeout: float = 150,
459
453
  **kwargs
460
454
  ) -> dict:
461
455
  """
462
456
  Combined map method.
463
457
 
464
458
  """
465
- timeout = min(timeout, 120)
466
459
  response_dict = await self._map(url,
467
460
  max_depth=max_depth,
468
461
  max_breadth=max_breadth,
@@ -488,7 +481,7 @@ class AsyncTavilyClient:
488
481
  include_domains: Sequence[str] = None,
489
482
  exclude_domains: Sequence[str] = None,
490
483
  max_tokens: int = 4000,
491
- timeout: int = 60,
484
+ timeout: float = 60,
492
485
  country: str = None,
493
486
  include_favicon: bool = None,
494
487
  **kwargs, # Accept custom arguments
@@ -529,7 +522,7 @@ class AsyncTavilyClient:
529
522
  max_results: int = 5,
530
523
  include_domains: Sequence[str] = None,
531
524
  exclude_domains: Sequence[str] = None,
532
- timeout: int = 60,
525
+ timeout: float = 60,
533
526
  country: str = None,
534
527
  include_favicon: bool = None,
535
528
  **kwargs, # Accept custom arguments
@@ -559,7 +552,7 @@ class AsyncTavilyClient:
559
552
  query: str,
560
553
  search_depth: Literal["basic", "advanced"] = "advanced",
561
554
  max_results: int = 5,
562
- timeout: int = 60,
555
+ timeout: float = 60,
563
556
  country: str = None,
564
557
  ) -> Sequence[dict]:
565
558
  """ Company information search method. Search depth is advanced by default to get the best answer. """
@@ -583,3 +576,173 @@ class AsyncTavilyClient:
583
576
  sorted_results = sorted(all_results, key=lambda x: x["score"], reverse=True)[:max_results]
584
577
 
585
578
  return sorted_results
579
+
580
+ def _research(self,
581
+ input: str,
582
+ model: Literal["mini", "pro", "auto"] = None,
583
+ output_schema: dict = None,
584
+ stream: bool = False,
585
+ citation_format: Literal["numbered", "mla", "apa", "chicago"] = "numbered",
586
+ timeout: Optional[float] = None,
587
+ **kwargs
588
+ ) -> Union[AsyncGenerator[bytes, None], Awaitable[dict]]:
589
+ """
590
+ Internal research method to send the request to the API.
591
+ """
592
+ data = {
593
+ "input": input,
594
+ "model": model,
595
+ "output_schema": output_schema,
596
+ "stream": stream,
597
+ "citation_format": citation_format,
598
+ }
599
+
600
+ data = {k: v for k, v in data.items() if v is not None}
601
+
602
+ if kwargs:
603
+ data.update(kwargs)
604
+
605
+ if stream:
606
+ async def stream_generator() -> AsyncGenerator[bytes, None]:
607
+ try:
608
+ async with self._client_creator() as client:
609
+ async with client.stream(
610
+ "POST",
611
+ "/research",
612
+ content=json.dumps(data),
613
+ timeout=timeout
614
+ ) as response:
615
+ if response.status_code != 200:
616
+ try:
617
+ error_text = await response.aread()
618
+ error_text = error_text.decode('utf-8') if isinstance(error_text, bytes) else error_text
619
+ except Exception:
620
+ error_text = "Unknown error"
621
+
622
+ if response.status_code == 429:
623
+ raise UsageLimitExceededError(error_text)
624
+ elif response.status_code in [403,432,433]:
625
+ raise ForbiddenError(error_text)
626
+ elif response.status_code == 401:
627
+ raise InvalidAPIKeyError(error_text)
628
+ elif response.status_code == 400:
629
+ raise BadRequestError(error_text)
630
+ else:
631
+ raise Exception(f"Error {response.status_code}: {error_text}")
632
+
633
+ async for chunk in response.aiter_bytes():
634
+ if chunk:
635
+ yield chunk
636
+ except httpx.TimeoutException:
637
+ raise TimeoutError(timeout)
638
+ except Exception as e:
639
+ raise Exception(f"Error during research stream: {str(e)}")
640
+
641
+ return stream_generator()
642
+ else:
643
+ async def _make_request():
644
+ async with self._client_creator() as client:
645
+ try:
646
+ response = await client.post("/research", content=json.dumps(data), timeout=timeout)
647
+ except httpx.TimeoutException:
648
+ raise TimeoutError(timeout)
649
+
650
+ if response.status_code == 200:
651
+ return response.json()
652
+ else:
653
+ detail = ""
654
+ try:
655
+ detail = response.json().get("detail", {}).get("error", None)
656
+ except Exception:
657
+ pass
658
+
659
+ if response.status_code == 429:
660
+ raise UsageLimitExceededError(detail)
661
+ elif response.status_code in [403,432,433]:
662
+ raise ForbiddenError(detail)
663
+ elif response.status_code == 401:
664
+ raise InvalidAPIKeyError(detail)
665
+ elif response.status_code == 400:
666
+ raise BadRequestError(detail)
667
+ else:
668
+ raise response.raise_for_status()
669
+
670
+ return _make_request()
671
+
672
+ async def research(self,
673
+ input: str,
674
+ model: Literal["mini", "pro", "auto"] = None,
675
+ output_schema: dict = None,
676
+ stream: bool = False,
677
+ citation_format: Literal["numbered", "mla", "apa", "chicago"] = "numbered",
678
+ timeout: Optional[float] = None,
679
+ **kwargs
680
+ ) -> Union[dict, AsyncGenerator[bytes, None]]:
681
+ """
682
+ Research method to create a research task.
683
+
684
+ Args:
685
+ input: The research task description (required).
686
+ model: Research depth - must be either 'mini', 'pro', or 'auto'.
687
+ output_schema: Schema for the 'structured_output' response format (JSON Schema dict).
688
+ stream: Whether to stream the research task.
689
+ citation_format: Citation format - must be either 'numbered', 'mla', 'apa', or 'chicago'.
690
+ timeout: Optional HTTP request timeout in seconds.
691
+ **kwargs: Additional custom arguments.
692
+
693
+ Returns:
694
+ When stream=False: dict - the response dictionary.
695
+ When stream=True: AsyncGenerator[bytes, None] - iterate over this to get streaming chunks.
696
+ """
697
+ result = self._research(
698
+ input=input,
699
+ model=model,
700
+ output_schema=output_schema,
701
+ stream=stream,
702
+ citation_format=citation_format,
703
+ timeout=timeout,
704
+ **kwargs
705
+ )
706
+ if stream:
707
+ return result # Don't await the result, it's an AsyncGenerator that will be lazy and only execute when iterated over with async for
708
+ else:
709
+ return await result
710
+
711
+ async def get_research(self,
712
+ request_id: str
713
+ ) -> dict:
714
+ """
715
+ Get research results by request_id.
716
+
717
+ Args:
718
+ request_id: The research request ID.
719
+
720
+ Returns:
721
+ dict: Research response containing request_id, created_at, completed_at, status, content, and sources.
722
+ """
723
+ async with self._client_creator() as client:
724
+ try:
725
+ response = await client.get(f"/research/{request_id}")
726
+ except Exception as e:
727
+ raise Exception(f"Error getting research: {e}")
728
+
729
+ if response.status_code in (200, 202):
730
+ data = response.json()
731
+ return data
732
+ else:
733
+ detail = ""
734
+ try:
735
+ detail = response.json().get("detail", {}).get("error", None)
736
+ except Exception:
737
+ pass
738
+
739
+ if response.status_code == 429:
740
+ raise UsageLimitExceededError(detail)
741
+ elif response.status_code in [403,432,433]:
742
+ raise ForbiddenError(detail)
743
+ elif response.status_code == 401:
744
+ raise InvalidAPIKeyError(detail)
745
+ elif response.status_code == 400:
746
+ raise BadRequestError(detail)
747
+ else:
748
+ raise response.raise_for_status()
@@ -17,7 +17,7 @@ class InvalidAPIKeyError(Exception):
17
17
  super().__init__(message)
18
18
 
19
19
  class TimeoutError(Exception):
20
- def __init__(self, timeout: int):
20
+ def __init__(self, timeout: float):
21
21
  super().__init__(f"Request timed out after {timeout} seconds.")
22
22
 
23
23
  class MissingAPIKeyError(Exception):
@@ -2,7 +2,7 @@ import requests
2
2
  import json
3
3
  import warnings
4
4
  import os
5
- from typing import Literal, Sequence, Optional, List, Union
5
+ from typing import Literal, Sequence, Optional, List, Union, Generator
6
6
  from concurrent.futures import ThreadPoolExecutor, as_completed
7
7
  from .utils import get_max_items_from_list
8
8
  from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError
@@ -49,7 +49,7 @@ class TavilyClient:
49
49
  include_answer: Union[bool, Literal["basic", "advanced"]] = None,
50
50
  include_raw_content: Union[bool, Literal["markdown", "text"]] = None,
51
51
  include_images: bool = None,
52
- timeout: int = 60,
52
+ timeout: float = 60,
53
53
  country: str = None,
54
54
  auto_parameters: bool = None,
55
55
  include_favicon: bool = None,
@@ -126,7 +126,7 @@ class TavilyClient:
126
126
  include_answer: Union[bool, Literal["basic", "advanced"]] = None,
127
127
  include_raw_content: Union[bool, Literal["markdown", "text"]] = None,
128
128
  include_images: bool = None,
129
- timeout: int = 60,
129
+ timeout: float = 60,
130
130
  country: str = None,
131
131
  auto_parameters: bool = None,
132
132
  include_favicon: bool = None,
@@ -135,7 +135,7 @@ class TavilyClient:
135
135
  """
136
136
  Combined search method.
137
137
  """
138
- timeout = min(timeout, 120)
138
+
139
139
  response_dict = self._search(query,
140
140
  search_depth=search_depth,
141
141
  topic=topic,
@@ -167,7 +167,7 @@ class TavilyClient:
167
167
  include_images: bool = None,
168
168
  extract_depth: Literal["basic", "advanced"] = None,
169
169
  format: Literal["markdown", "text"] = None,
170
- timeout: int = 30,
170
+ timeout: float = 30,
171
171
  include_favicon: bool = None,
172
172
  **kwargs
173
173
  ) -> dict:
@@ -188,8 +188,6 @@ class TavilyClient:
188
188
  if kwargs:
189
189
  data.update(kwargs)
190
190
 
191
- timeout = min(timeout, 120)
192
-
193
191
  try:
194
192
  response = requests.post(self.base_url + "/extract", data=json.dumps(data), headers=self.headers, timeout=timeout, proxies=self.proxies)
195
193
  except requests.exceptions.Timeout:
@@ -220,14 +218,13 @@ class TavilyClient:
220
218
  include_images: bool = None,
221
219
  extract_depth: Literal["basic", "advanced"] = None,
222
220
  format: Literal["markdown", "text"] = None,
223
- timeout: int = 30,
221
+ timeout: float = 30,
224
222
  include_favicon: bool = None,
225
223
  **kwargs, # Accept custom arguments
226
224
  ) -> dict:
227
225
  """
228
226
  Combined extract method.
229
227
  """
230
- timeout = min(timeout, 120)
231
228
  response_dict = self._extract(urls,
232
229
  include_images,
233
230
  extract_depth,
@@ -258,7 +255,7 @@ class TavilyClient:
258
255
  include_images: bool = None,
259
256
  extract_depth: Literal["basic", "advanced"] = None,
260
257
  format: Literal["markdown", "text"] = None,
261
- timeout: int = 60,
258
+ timeout: float = 150,
262
259
  include_favicon: bool = None,
263
260
  **kwargs
264
261
  ) -> dict:
@@ -280,6 +277,7 @@ class TavilyClient:
280
277
  "include_images": include_images,
281
278
  "extract_depth": extract_depth,
282
279
  "format": format,
280
+ "timeout": timeout,
283
281
  "include_favicon": include_favicon,
284
282
  }
285
283
 
@@ -287,8 +285,6 @@ class TavilyClient:
287
285
  data.update(kwargs)
288
286
 
289
287
  data = {k: v for k, v in data.items() if v is not None}
290
-
291
- timeout = min(timeout, 120)
292
288
 
293
289
  try:
294
290
  response = requests.post(
@@ -330,7 +326,7 @@ class TavilyClient:
330
326
  include_images: bool = None,
331
327
  extract_depth: Literal["basic", "advanced"] = None,
332
328
  format: Literal["markdown", "text"] = None,
333
- timeout: int = 60,
329
+ timeout: float = 150,
334
330
  include_favicon: bool = None,
335
331
  **kwargs
336
332
  ) -> dict:
@@ -338,7 +334,6 @@ class TavilyClient:
338
334
  Combined crawl method.
339
335
  include_favicon: If True, include the favicon in the crawl results.
340
336
  """
341
- timeout = min(timeout, 120)
342
337
  response_dict = self._crawl(url,
343
338
  max_depth=max_depth,
344
339
  max_breadth=max_breadth,
@@ -370,7 +365,7 @@ class TavilyClient:
370
365
  exclude_domains: Sequence[str] = None,
371
366
  allow_external: bool = None,
372
367
  include_images: bool = None,
373
- timeout: int = 60,
368
+ timeout: float = 150,
374
369
  **kwargs
375
370
  ) -> dict:
376
371
  """
@@ -388,6 +383,7 @@ class TavilyClient:
388
383
  "exclude_domains": exclude_domains,
389
384
  "allow_external": allow_external,
390
385
  "include_images": include_images,
386
+ "timeout": timeout,
391
387
  }
392
388
 
393
389
  if kwargs:
@@ -395,8 +391,6 @@ class TavilyClient:
395
391
 
396
392
  data = {k: v for k, v in data.items() if v is not None}
397
393
 
398
- timeout = min(timeout, 120)
399
-
400
394
  try:
401
395
  response = requests.post(
402
396
  self.base_url + "/map", data=json.dumps(data), headers=self.headers, timeout=timeout, proxies=self.proxies)
@@ -435,14 +429,13 @@ class TavilyClient:
435
429
  exclude_domains: Sequence[str] = None,
436
430
  allow_external: bool = None,
437
431
  include_images: bool = None,
438
- timeout: int = 60,
432
+ timeout: float = 150,
439
433
  **kwargs
440
434
  ) -> dict:
441
435
  """
442
436
  Combined map method.
443
437
 
444
438
  """
445
- timeout = min(timeout, 120)
446
439
  response_dict = self._map(url,
447
440
  max_depth=max_depth,
448
441
  max_breadth=max_breadth,
@@ -468,7 +461,7 @@ class TavilyClient:
468
461
  include_domains: Sequence[str] = None,
469
462
  exclude_domains: Sequence[str] = None,
470
463
  max_tokens: int = 4000,
471
- timeout: int = 60,
464
+ timeout: float = 60,
472
465
  country: str = None,
473
466
  include_favicon: bool = None,
474
467
  **kwargs, # Accept custom arguments
@@ -481,7 +474,7 @@ class TavilyClient:
481
474
 
482
475
  Returns a string of JSON containing the search context up to context limit.
483
476
  """
484
- timeout = min(timeout, 120)
477
+
485
478
  response_dict = self._search(query,
486
479
  search_depth=search_depth,
487
480
  topic=topic,
@@ -510,7 +503,7 @@ class TavilyClient:
510
503
  max_results: int = 5,
511
504
  include_domains: Sequence[str] = None,
512
505
  exclude_domains: Sequence[str] = None,
513
- timeout: int = 60,
506
+ timeout: float = 60,
514
507
  country: str = None,
515
508
  include_favicon: bool = None,
516
509
  **kwargs, # Accept custom arguments
@@ -518,7 +511,6 @@ class TavilyClient:
518
511
  """
519
512
  Q&A search method. Search depth is advanced by default to get the best answer.
520
513
  """
521
- timeout = min(timeout, 120)
522
514
  response_dict = self._search(query,
523
515
  search_depth=search_depth,
524
516
  topic=topic,
@@ -541,11 +533,10 @@ class TavilyClient:
541
533
  search_depth: Literal["basic",
542
534
  "advanced"] = "advanced",
543
535
  max_results: int = 5,
544
- timeout: int = 60,
536
+ timeout: float = 60,
545
537
  country: str = None,
546
538
  ) -> Sequence[dict]:
547
539
  """ Company information search method. Search depth is advanced by default to get the best answer. """
548
- timeout = min(timeout, 120)
549
540
  def _perform_search(topic):
550
541
  return self._search(query,
551
542
  search_depth=search_depth,
@@ -574,6 +565,183 @@ class TavilyClient:
574
565
 
575
566
  return sorted_results
576
567
 
568
+ def _research(self,
569
+ input: str,
570
+ model: Literal["mini", "pro", "auto"] = None,
571
+ output_schema: dict = None,
572
+ stream: bool = False,
573
+ citation_format: Literal["numbered", "mla", "apa", "chicago"] = "numbered",
574
+ timeout: Optional[float] = None,
575
+ **kwargs
576
+ ) -> Union[dict, Generator[bytes, None, None]]:
577
+ """
578
+ Internal research method to send the request to the API.
579
+ """
580
+ data = {
581
+ "input": input,
582
+ "model": model,
583
+ "output_schema": output_schema,
584
+ "stream": stream,
585
+ "citation_format": citation_format,
586
+ }
587
+
588
+ data = {k: v for k, v in data.items() if v is not None}
589
+
590
+ if kwargs:
591
+ data.update(kwargs)
592
+
593
+ if stream:
594
+ try:
595
+ response = requests.post(
596
+ self.base_url + "/research",
597
+ data=json.dumps(data),
598
+ headers=self.headers,
599
+ timeout=timeout,
600
+ proxies=self.proxies,
601
+ stream=True
602
+ )
603
+ except requests.exceptions.Timeout:
604
+ raise TimeoutError(timeout)
605
+
606
+ if response.status_code != 200:
607
+ detail = ""
608
+ try:
609
+ detail = response.json().get("detail", {}).get("error", None)
610
+ except Exception:
611
+ pass
612
+
613
+ if response.status_code == 429:
614
+ raise UsageLimitExceededError(detail)
615
+ elif response.status_code in [403,432,433]:
616
+ raise ForbiddenError(detail)
617
+ elif response.status_code == 401:
618
+ raise InvalidAPIKeyError(detail)
619
+ elif response.status_code == 400:
620
+ raise BadRequestError(detail)
621
+ else:
622
+ raise response.raise_for_status()
623
+
624
+ def stream_generator() -> Generator[bytes, None, None]:
625
+ try:
626
+ for chunk in response.iter_content(chunk_size=None):
627
+ if chunk:
628
+ yield chunk
629
+ finally:
630
+ response.close()
631
+
632
+ return stream_generator()
633
+ else:
634
+ try:
635
+ response = requests.post(
636
+ self.base_url + "/research",
637
+ data=json.dumps(data),
638
+ headers=self.headers,
639
+ timeout=timeout,
640
+ proxies=self.proxies
641
+ )
642
+ except requests.exceptions.Timeout:
643
+ raise TimeoutError(timeout)
644
+
645
+ if response.status_code == 200:
646
+ return response.json()
647
+ else:
648
+ detail = ""
649
+ try:
650
+ detail = response.json().get("detail", {}).get("error", None)
651
+ except Exception:
652
+ pass
653
+
654
+ if response.status_code == 429:
655
+ raise UsageLimitExceededError(detail)
656
+ elif response.status_code in [403,432,433]:
657
+ raise ForbiddenError(detail)
658
+ elif response.status_code == 401:
659
+ raise InvalidAPIKeyError(detail)
660
+ elif response.status_code == 400:
661
+ raise BadRequestError(detail)
662
+ else:
663
+ raise response.raise_for_status()
664
+
665
+ def research(self,
666
+ input: str,
667
+ model: Literal["mini", "pro", "auto"] = None,
668
+ output_schema: dict = None,
669
+ stream: bool = False,
670
+ citation_format: Literal["numbered", "mla", "apa", "chicago"] = "numbered",
671
+ timeout: Optional[float] = None,
672
+ **kwargs
673
+ ) -> Union[dict, Generator[bytes, None, None]]:
674
+ """
675
+ Research method to create a research task.
676
+
677
+ Args:
678
+ input: The research task or question to investigate (required).
679
+ model: The model used by the research agent - must be either 'mini', 'pro', or 'auto'.
680
+ output_schema: Schema for the 'structured_output' response format (JSON Schema dict).
681
+ stream: Whether to stream the research task.
682
+ citation_format: Citation format - must be either 'numbered', 'mla', 'apa', or 'chicago'.
683
+ timeout: Optional HTTP request timeout in seconds.
684
+ **kwargs: Additional custom arguments.
685
+
686
+ Returns:
687
+ dict: Response containing request_id, created_at, status, input, and model.
688
+ """
689
+
690
+
691
+ response_dict = self._research(
692
+ input=input,
693
+ model=model,
694
+ output_schema=output_schema,
695
+ stream=stream,
696
+ citation_format=citation_format,
697
+ timeout=timeout,
698
+ **kwargs
699
+ )
700
+
701
+ return response_dict
702
+
703
+ def get_research(self,
704
+ request_id: str
705
+ ) -> dict:
706
+ """
707
+ Get research results by request_id.
708
+
709
+ Args:
710
+ request_id: The research request ID.
711
+
712
+ Returns:
713
+ dict: Research response containing request_id, created_at, completed_at, status, content, and sources.
714
+ """
715
+ try:
716
+ response = requests.get(
717
+ self.base_url + f"/research/{request_id}",
718
+ headers=self.headers,
719
+ proxies=self.proxies,
720
+ )
721
+ except Exception as e:
722
+ raise Exception(f"Error getting research: {e}")
723
+
724
+ if response.status_code in (200, 202):
725
+ data = response.json()
726
+ return data
727
+ else:
728
+ detail = ""
729
+ try:
730
+ detail = response.json().get("detail", {}).get("error", None)
731
+ except Exception:
732
+ pass
733
+
734
+ if response.status_code == 429:
735
+ raise UsageLimitExceededError(detail)
736
+ elif response.status_code in [403,432,433]:
737
+ raise ForbiddenError(detail)
738
+ elif response.status_code == 401:
739
+ raise InvalidAPIKeyError(detail)
740
+ elif response.status_code == 400:
741
+ raise BadRequestError(detail)
742
+ else:
743
+ raise response.raise_for_status()
744
+
577
745
 
578
746
  class Client(TavilyClient):
579
747
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tavily-python
3
- Version: 0.7.12
3
+ Version: 0.7.14
4
4
  Summary: Python wrapper for the Tavily API
5
5
  Home-page: https://github.com/tavily-ai/tavily-python
6
6
  Author: Tavily AI
@@ -8,7 +8,7 @@ Author-email: support@tavily.com
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.6
11
+ Requires-Python: >=3.8
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: requests
@@ -32,7 +32,7 @@ Dynamic: summary
32
32
  [![License](https://img.shields.io/github/license/tavily-ai/tavily-python)](https://github.com/tavily-ai/tavily-python/blob/main/LICENSE)
33
33
  [![CI](https://github.com/tavily-ai/tavily-python/actions/workflows/tests.yml/badge.svg)](https://github.com/tavily-ai/tavily-python/actions)
34
34
 
35
- The Tavily Python wrapper allows for easy interaction with the Tavily API, offering the full range of our search and extract functionalities directly from your Python programs. Easily integrate smart search and content extraction capabilities into your applications, harnessing Tavily's powerful search and extract features.
35
+ The Tavily Python wrapper allows for easy interaction with the Tavily API, offering the full range of our search, extract, crawl, map, and research functionalities directly from your Python programs. Easily integrate smart search, content extraction, and research capabilities into your applications, harnessing Tavily's powerful features.
36
36
 
37
37
  ## Installing
38
38
 
@@ -41,6 +41,7 @@ pip install tavily-python
41
41
  ```
42
42
 
43
43
  # Tavily Search
44
+
44
45
  Search lets you search the web for a given query.
45
46
 
46
47
  ## Usage
@@ -99,6 +100,7 @@ print(answer)
99
100
  This is how you get accurate and concise answers to questions, in one line of code. Perfect for usage by LLMs!
100
101
 
101
102
  # Tavily Extract
103
+
102
104
  Extract web page content from one or more specified URLs.
103
105
 
104
106
  ## Usage
@@ -203,6 +205,59 @@ for result in response["results"]:
203
205
 
204
206
  ```
205
207
 
208
+ # Tavily Research
209
+
210
+ Research lets you create comprehensive research reports on any topic, with automatic source gathering, analysis, and structured output.
211
+
212
+ ## Usage
213
+
214
+ Below are some code snippets that demonstrate how to interact with our Research API. Each step and component of this code is explained in greater detail in the API Methods section below.
215
+
216
+ ### Creating a research task and retrieving results
217
+
218
+ ```python
219
+ from tavily import TavilyClient
220
+
221
+ # Step 1. Instantiating your TavilyClient
222
+ tavily_client = TavilyClient(api_key="tvly-YOUR_API_KEY")
223
+
224
+ # Step 2. Creating a research task
225
+ response = tavily_client.research(
226
+ input="Research the latest developments in AI",
227
+ model="pro",
228
+ citation_format="apa"
229
+ )
230
+
231
+ # Step 3. Retrieving the research results
232
+ request_id = response["request_id"]
233
+ result = tavily_client.get_research(request_id)
234
+
235
+ # Step 4. Printing the research report
236
+ print(f"Status: {result['status']}")
237
+ print(f"Content: {result['content']}")
238
+ print(f"Sources: {len(result['sources'])} sources found")
239
+ ```
240
+
241
+ ### Streaming research results
242
+
243
+ ```python
244
+ from tavily import TavilyClient
245
+
246
+ # Step 1. Instantiating your TavilyClient
247
+ tavily_client = TavilyClient(api_key="tvly-YOUR_API_KEY")
248
+
249
+ # Step 2. Creating a streaming research task
250
+ stream = tavily_client.research(
251
+ input="Research the latest developments in AI",
252
+ model="pro",
253
+ stream=True
254
+ )
255
+
256
+ # Step 3. Processing the stream as it arrives
257
+ for chunk in stream:
258
+ print(chunk.decode('utf-8'))
259
+ ```
260
+
206
261
  ## Documentation
207
262
 
208
263
  For a complete guide on how to use the different endpoints and their parameters, please head to our [Python API Reference](https://docs.tavily.com/sdk/python/reference).
@@ -17,4 +17,5 @@ tavily_python.egg-info/top_level.txt
17
17
  tests/test_crawl.py
18
18
  tests/test_errors.py
19
19
  tests/test_map.py
20
+ tests/test_research.py
20
21
  tests/test_search.py
@@ -0,0 +1,149 @@
1
+ import asyncio
2
+
3
+ BASE_URL = "https://api.tavily.com"
4
+
5
+ dummy_queued_response = {
6
+ "request_id": "test-request-123",
7
+ "created_at": "2024-01-01T00:00:00Z",
8
+ "status": "pending",
9
+ "input": "Research the latest developments in AI",
10
+ "model": "mini"
11
+ }
12
+
13
+ dummy_research_response = {
14
+ "request_id": "test-request-123",
15
+ "created_at": "2024-01-01T00:00:00Z",
16
+ "completed_at": "2024-01-01T00:05:00Z",
17
+ "status": "completed",
18
+ "content": "This is a comprehensive research report on AI developments...",
19
+ "sources": [
20
+ {
21
+ "title": "AI Research Paper",
22
+ "url": "https://example.com/ai-paper",
23
+ }
24
+ ]
25
+ }
26
+
27
+ def validate_research_default(request, response):
28
+ assert request.method == "POST"
29
+ assert request.url == f"{BASE_URL}/research"
30
+ assert request.headers["Authorization"] == "Bearer tvly-test"
31
+ assert request.headers["X-Client-Source"] == "tavily-python"
32
+ assert request.json().get('input') == "Research the latest developments in AI"
33
+ assert response == dummy_queued_response
34
+
35
+ def validate_research_specific(request, response):
36
+ assert request.method == "POST"
37
+ assert request.url == f"{BASE_URL}/research"
38
+ assert request.headers["Authorization"] == "Bearer tvly-test"
39
+ assert request.headers["X-Client-Source"] == "tavily-python"
40
+
41
+ request_json = request.json()
42
+ for key, value in {
43
+ "input": "Research the latest developments in AI",
44
+ "model": "mini",
45
+ "citation_format": "apa",
46
+ "stream": True,
47
+ "output_schema": {
48
+ "title": "ResearchReport",
49
+ "description": "A structured research report",
50
+ "type": "object",
51
+ "properties": {
52
+ "summary": {"type": "string"},
53
+ "key_points": {"type": "array", "items": {"type": "string"}}
54
+ }
55
+ }
56
+ }.items():
57
+ assert request_json.get(key) == value
58
+
59
+ assert response == dummy_queued_response
60
+
61
+ def validate_get_research(request, response):
62
+ assert request.method == "GET"
63
+ assert request.url == f"{BASE_URL}/research/test-request-123"
64
+ assert request.headers["Authorization"] == "Bearer tvly-test"
65
+ assert request.headers["X-Client-Source"] == "tavily-python"
66
+ assert response == dummy_research_response
67
+
68
+ def test_sync_research_defaults(sync_interceptor, sync_client):
69
+ sync_interceptor.set_response(200, json=dummy_queued_response)
70
+ response = sync_client.research("Research the latest developments in AI")
71
+ request = sync_interceptor.get_request()
72
+ validate_research_default(request, response)
73
+
74
+ def test_sync_research_specific(sync_interceptor, sync_client):
75
+ sync_interceptor.set_response(200, json=dummy_queued_response)
76
+ response = sync_client.research(
77
+ input="Research the latest developments in AI",
78
+ model="mini",
79
+ citation_format="apa",
80
+ stream=True,
81
+ timeout=300,
82
+ output_schema={
83
+ "title": "ResearchReport",
84
+ "description": "A structured research report",
85
+ "type": "object",
86
+ "properties": {
87
+ "summary": {"type": "string"},
88
+ "key_points": {"type": "array", "items": {"type": "string"}}
89
+ }
90
+ }
91
+ )
92
+
93
+ request = sync_interceptor.get_request()
94
+ # When stream=True, response is a generator
95
+ assert hasattr(response, '__iter__') and not isinstance(response, (str, dict))
96
+ validate_research_specific(request, dummy_queued_response)
97
+
98
+ def test_sync_get_research(sync_interceptor, sync_client):
99
+ sync_interceptor.set_response(200, json=dummy_research_response)
100
+ response = sync_client.get_research("test-request-123")
101
+ request = sync_interceptor.get_request()
102
+ validate_get_research(request, response)
103
+
104
+ def test_async_research_defaults(async_interceptor, async_client):
105
+ async_interceptor.set_response(200, json=dummy_queued_response)
106
+ response = asyncio.run(async_client.research("Research the latest developments in AI"))
107
+ request = async_interceptor.get_request()
108
+ validate_research_default(request, response)
109
+
110
+ def test_async_research_specific(async_interceptor, async_client):
111
+ async_interceptor.set_response(200, json=dummy_queued_response)
112
+
113
+ async def run_test():
114
+ response = await async_client.research(
115
+ input="Research the latest developments in AI",
116
+ model="mini",
117
+ citation_format="apa",
118
+ stream=True,
119
+ timeout=300,
120
+ output_schema={
121
+ "title": "ResearchReport",
122
+ "description": "A structured research report",
123
+ "type": "object",
124
+ "properties": {
125
+ "summary": {"type": "string"},
126
+ "key_points": {"type": "array", "items": {"type": "string"}}
127
+ }
128
+ }
129
+ )
130
+ # When stream=True, response is an async generator
131
+ assert hasattr(response, '__aiter__')
132
+ try:
133
+ async for chunk in response:
134
+ # Just consume one chunk to trigger the request
135
+ break
136
+ except StopAsyncIteration:
137
+ pass
138
+ return response
139
+
140
+ response = asyncio.run(run_test())
141
+ request = async_interceptor.get_request()
142
+ validate_research_specific(request, dummy_queued_response)
143
+
144
+ def test_async_get_research(async_interceptor, async_client):
145
+ async_interceptor.set_response(200, json=dummy_research_response)
146
+ response = asyncio.run(async_client.get_research("test-request-123"))
147
+ request = async_interceptor.get_request()
148
+ validate_get_research(request, response)
149
+
File without changes
File without changes