tavily-python 0.7.23__tar.gz → 0.7.25__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 (27) hide show
  1. {tavily_python-0.7.23 → tavily_python-0.7.25}/PKG-INFO +47 -1
  2. {tavily_python-0.7.23 → tavily_python-0.7.25}/README.md +46 -0
  3. {tavily_python-0.7.23 → tavily_python-0.7.25}/setup.py +1 -1
  4. tavily_python-0.7.25/tavily/__init__.py +11 -0
  5. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily/async_tavily.py +143 -118
  6. tavily_python-0.7.25/tavily/errors.py +71 -0
  7. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily/tavily.py +139 -129
  8. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily_python.egg-info/PKG-INFO +47 -1
  9. {tavily_python-0.7.23 → tavily_python-0.7.25}/tests/test_custom_session.py +17 -10
  10. {tavily_python-0.7.23 → tavily_python-0.7.25}/tests/test_errors.py +12 -13
  11. tavily_python-0.7.23/tavily/__init__.py +0 -4
  12. tavily_python-0.7.23/tavily/errors.py +0 -30
  13. {tavily_python-0.7.23 → tavily_python-0.7.25}/LICENSE +0 -0
  14. {tavily_python-0.7.23 → tavily_python-0.7.25}/setup.cfg +0 -0
  15. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily/config.py +0 -0
  16. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily/hybrid_rag/__init__.py +0 -0
  17. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily/hybrid_rag/hybrid_rag.py +0 -0
  18. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily/utils.py +0 -0
  19. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily_python.egg-info/SOURCES.txt +0 -0
  20. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily_python.egg-info/dependency_links.txt +0 -0
  21. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily_python.egg-info/requires.txt +0 -0
  22. {tavily_python-0.7.23 → tavily_python-0.7.25}/tavily_python.egg-info/top_level.txt +0 -0
  23. {tavily_python-0.7.23 → tavily_python-0.7.25}/tests/test_crawl.py +0 -0
  24. {tavily_python-0.7.23 → tavily_python-0.7.25}/tests/test_map.py +0 -0
  25. {tavily_python-0.7.23 → tavily_python-0.7.25}/tests/test_research.py +0 -0
  26. {tavily_python-0.7.23 → tavily_python-0.7.25}/tests/test_search.py +0 -0
  27. {tavily_python-0.7.23 → tavily_python-0.7.25}/tests/test_session_pooling.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tavily-python
3
- Version: 0.7.23
3
+ Version: 0.7.25
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
@@ -40,6 +40,29 @@ The Tavily Python wrapper allows for easy interaction with the Tavily API, offer
40
40
  pip install tavily-python
41
41
  ```
42
42
 
43
+ ## Keyless mode
44
+
45
+ You can try Tavily without an API key. Instantiate `TavilyClient()` with no arguments and the SDK runs in keyless mode against the public Tavily API. Keyless mode supports `search()` and `extract()` only; other methods raise an error explaining that an API key is required.
46
+
47
+ ```python
48
+ from tavily import TavilyClient, TavilyKeylessLimitError
49
+
50
+ # No API key needed
51
+ client = TavilyClient()
52
+
53
+ try:
54
+ response = client.search("Who is Leo Messi?")
55
+ print(response)
56
+ except TavilyKeylessLimitError as e:
57
+ # Rate-limit cap reached. The exception carries the human-readable
58
+ # message plus structured fields (code, window, retry_after_seconds,
59
+ # next_actions) returned by the Tavily API.
60
+ print(e)
61
+ print("retry after:", e.retry_after_seconds, "seconds")
62
+ ```
63
+
64
+ Keyless usage is rate-limited. For higher limits and the full set of endpoints (including `crawl`, `map`, and `research`), [sign up for a Tavily API key](https://tavily.com) and pass it as `TavilyClient(api_key="tvly-...")`.
65
+
43
66
  # Tavily Search
44
67
 
45
68
  Search lets you search the web for a given query.
@@ -320,6 +343,29 @@ response = await client.search("latest AI research")
320
343
  - Custom session proxies take precedence over SDK proxy settings
321
344
  - The SDK will **not** close externally-provided sessions — you manage the lifecycle
322
345
 
346
+ ## Session & User Tracking
347
+
348
+ `session_id`, `human_id`, and `client_name` are optional identifiers that help attribute requests to a logical session, an end user, and a named client. All three are sent as HTTP headers (`X-Session-Id`, `X-Human-Id`, `X-Client-Name`) and are never persisted in raw form — `human_id` is hashed server-side.
349
+
350
+ Set them once at client init, or per-call (per-call wins):
351
+
352
+ ```python
353
+ from tavily import TavilyClient
354
+
355
+ # Client-level — applied to every request
356
+ client = TavilyClient(
357
+ api_key="tvly-YOUR_API_KEY",
358
+ session_id="my-session-123",
359
+ human_id="internal-user-id-42",
360
+ client_name="my-app",
361
+ )
362
+
363
+ # Per-call override
364
+ client.search("hello", session_id="ad-hoc-session")
365
+ ```
366
+
367
+ All three are opt-in. Leave them unset and the SDK sends nothing — behavior is identical to earlier versions.
368
+
323
369
  ## Documentation
324
370
 
325
371
  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).
@@ -13,6 +13,29 @@ The Tavily Python wrapper allows for easy interaction with the Tavily API, offer
13
13
  pip install tavily-python
14
14
  ```
15
15
 
16
+ ## Keyless mode
17
+
18
+ You can try Tavily without an API key. Instantiate `TavilyClient()` with no arguments and the SDK runs in keyless mode against the public Tavily API. Keyless mode supports `search()` and `extract()` only; other methods raise an error explaining that an API key is required.
19
+
20
+ ```python
21
+ from tavily import TavilyClient, TavilyKeylessLimitError
22
+
23
+ # No API key needed
24
+ client = TavilyClient()
25
+
26
+ try:
27
+ response = client.search("Who is Leo Messi?")
28
+ print(response)
29
+ except TavilyKeylessLimitError as e:
30
+ # Rate-limit cap reached. The exception carries the human-readable
31
+ # message plus structured fields (code, window, retry_after_seconds,
32
+ # next_actions) returned by the Tavily API.
33
+ print(e)
34
+ print("retry after:", e.retry_after_seconds, "seconds")
35
+ ```
36
+
37
+ Keyless usage is rate-limited. For higher limits and the full set of endpoints (including `crawl`, `map`, and `research`), [sign up for a Tavily API key](https://tavily.com) and pass it as `TavilyClient(api_key="tvly-...")`.
38
+
16
39
  # Tavily Search
17
40
 
18
41
  Search lets you search the web for a given query.
@@ -293,6 +316,29 @@ response = await client.search("latest AI research")
293
316
  - Custom session proxies take precedence over SDK proxy settings
294
317
  - The SDK will **not** close externally-provided sessions — you manage the lifecycle
295
318
 
319
+ ## Session & User Tracking
320
+
321
+ `session_id`, `human_id`, and `client_name` are optional identifiers that help attribute requests to a logical session, an end user, and a named client. All three are sent as HTTP headers (`X-Session-Id`, `X-Human-Id`, `X-Client-Name`) and are never persisted in raw form — `human_id` is hashed server-side.
322
+
323
+ Set them once at client init, or per-call (per-call wins):
324
+
325
+ ```python
326
+ from tavily import TavilyClient
327
+
328
+ # Client-level — applied to every request
329
+ client = TavilyClient(
330
+ api_key="tvly-YOUR_API_KEY",
331
+ session_id="my-session-123",
332
+ human_id="internal-user-id-42",
333
+ client_name="my-app",
334
+ )
335
+
336
+ # Per-call override
337
+ client.search("hello", session_id="ad-hoc-session")
338
+ ```
339
+
340
+ All three are opt-in. Leave them unset and the SDK sends nothing — behavior is identical to earlier versions.
341
+
296
342
  ## Documentation
297
343
 
298
344
  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.23',
8
+ version='0.7.25',
9
9
  url='https://github.com/tavily-ai/tavily-python',
10
10
  author='Tavily AI',
11
11
  author_email='support@tavily.com',
@@ -0,0 +1,11 @@
1
+ from .async_tavily import AsyncTavilyClient
2
+ from .tavily import Client, TavilyClient
3
+ from .errors import (
4
+ InvalidAPIKeyError,
5
+ UsageLimitExceededError,
6
+ MissingAPIKeyError,
7
+ BadRequestError,
8
+ TavilyKeylessLimitError,
9
+ KeylessUnsupportedEndpointError,
10
+ )
11
+ from .hybrid_rag import TavilyHybridClient
@@ -6,7 +6,37 @@ from typing import Literal, Sequence, Optional, List, Union, AsyncGenerator, Awa
6
6
  import httpx
7
7
 
8
8
  from .utils import get_max_items_from_list
9
- from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError
9
+ from .errors import (
10
+ UsageLimitExceededError,
11
+ InvalidAPIKeyError,
12
+ MissingAPIKeyError,
13
+ BadRequestError,
14
+ ForbiddenError,
15
+ TimeoutError,
16
+ TavilyKeylessLimitError,
17
+ KeylessUnsupportedEndpointError,
18
+ )
19
+
20
+
21
+ def _is_keyless_envelope(body) -> bool:
22
+ """Return True when the response body matches the Tavily recoverable-error envelope shape."""
23
+ return (
24
+ isinstance(body, dict)
25
+ and isinstance(body.get("error"), dict)
26
+ and isinstance(body["error"].get("code"), str)
27
+ )
28
+
29
+
30
+ def _raise_keyless_envelope(body) -> None:
31
+ """Raise ``TavilyKeylessLimitError`` from an envelope-shaped response body."""
32
+ err = body["error"]
33
+ raise TavilyKeylessLimitError(
34
+ message=err.get("message") or "",
35
+ code=err.get("code"),
36
+ window=err.get("window"),
37
+ retry_after_seconds=err.get("retry_after_seconds"),
38
+ next_actions=err.get("next_actions") or [],
39
+ )
10
40
 
11
41
 
12
42
  class AsyncTavilyClient:
@@ -20,23 +50,39 @@ class AsyncTavilyClient:
20
50
  api_base_url: Optional[str] = None,
21
51
  client_source: Optional[str] = None,
22
52
  project_id: Optional[str] = None,
53
+ session_id: Optional[str] = None,
54
+ human_id: Optional[str] = None,
55
+ client_name: Optional[str] = None,
23
56
  client: Optional[httpx.AsyncClient] = None):
24
57
  if api_key is None:
25
58
  api_key = os.getenv("TAVILY_API_KEY")
26
59
 
27
- if not api_key and client is None:
28
- raise MissingAPIKeyError()
60
+ # api_key is optional: when absent, the client runs in keyless mode.
61
+ # Only `search` and `extract` are available in keyless mode.
62
+ api_key = api_key or None
29
63
 
30
64
  tavily_project = project_id or os.getenv("TAVILY_PROJECT")
31
65
 
32
66
  self._api_base_url = api_base_url or "https://api.tavily.com"
33
67
  self._company_info_tags = company_info_tags
68
+ self._keyless = api_key is None and client is None
69
+
70
+ if self._keyless:
71
+ # Honor an explicit client_source so non-SDK keyless surfaces
72
+ # (e.g. tavily-cli-keyless) are attributed correctly by the server.
73
+ client_source_header = client_source or "tavily-python-keyless"
74
+ else:
75
+ client_source_header = client_source or "tavily-python"
34
76
 
35
77
  default_headers = {
36
78
  "Content-Type": "application/json",
37
79
  **({"Authorization": f"Bearer {api_key}"} if api_key else {}),
38
- "X-Client-Source": client_source or "tavily-python",
39
- **({"X-Project-ID": tavily_project} if tavily_project else {})
80
+ **({"X-Tavily-Access-Mode": "keyless"} if self._keyless else {}),
81
+ "X-Client-Source": client_source_header,
82
+ **({"X-Project-ID": tavily_project} if tavily_project else {}),
83
+ **({"X-Session-Id": session_id} if session_id else {}),
84
+ **({"X-Human-Id": human_id} if human_id else {}),
85
+ **({"X-Client-Name": client_name} if client_name else {}),
40
86
  }
41
87
 
42
88
  self._external_client = client is not None
@@ -83,6 +129,66 @@ class AsyncTavilyClient:
83
129
  async def __aexit__(self, exc_type, exc_val, exc_tb):
84
130
  await self.close()
85
131
 
132
+ def _handle_error_response(self, response, body_override=None) -> None:
133
+ """Raise an appropriate exception for a non-2xx httpx response.
134
+
135
+ On keyless calls, checks the response body for the recoverable-error
136
+ envelope shape and raises ``TavilyKeylessLimitError`` with the
137
+ envelope's structured fields when present.
138
+ """
139
+ if body_override is not None:
140
+ body = body_override
141
+ else:
142
+ try:
143
+ body = response.json()
144
+ except Exception:
145
+ body = None
146
+
147
+ if self._keyless and _is_keyless_envelope(body):
148
+ _raise_keyless_envelope(body)
149
+
150
+ detail = ""
151
+ if isinstance(body, dict):
152
+ try:
153
+ detail = body.get("detail", {}).get("error", None) or ""
154
+ except Exception:
155
+ detail = ""
156
+ elif isinstance(body, str):
157
+ detail = body
158
+
159
+ if response.status_code == 429:
160
+ raise UsageLimitExceededError(detail)
161
+ elif response.status_code in [403, 432, 433]:
162
+ raise ForbiddenError(detail)
163
+ elif response.status_code == 401:
164
+ raise InvalidAPIKeyError(detail)
165
+ elif response.status_code == 400:
166
+ raise BadRequestError(detail)
167
+ else:
168
+ raise response.raise_for_status()
169
+
170
+ def _check_keyless_supported(self, method: str) -> None:
171
+ """Raise ``KeylessUnsupportedEndpointError`` when running in keyless mode."""
172
+ if self._keyless:
173
+ raise KeylessUnsupportedEndpointError(method)
174
+
175
+ @staticmethod
176
+ def _pop_request_headers(kwargs: dict) -> Optional[dict]:
177
+ """Pop session_id, human_id, and client_name from kwargs and return them as headers.
178
+
179
+ Returns None when no overrides are provided so callers can omit the headers kwarg.
180
+ """
181
+ overrides = {}
182
+ for key, header_name in (
183
+ ("session_id", "X-Session-Id"),
184
+ ("human_id", "X-Human-Id"),
185
+ ("client_name", "X-Client-Name"),
186
+ ):
187
+ value = kwargs.pop(key, None)
188
+ if value is not None:
189
+ overrides[header_name] = str(value)
190
+ return overrides or None
191
+
86
192
  async def _search(
87
193
  self,
88
194
  query: str,
@@ -132,35 +238,21 @@ class AsyncTavilyClient:
132
238
 
133
239
  data = {k: v for k, v in data.items() if v is not None}
134
240
 
241
+ override_headers = self._pop_request_headers(kwargs)
135
242
  if kwargs:
136
243
  data.update(kwargs)
137
244
 
138
245
  timeout = min(timeout, 120)
139
246
 
140
247
  try:
141
- response = await self._client.post("/search", content=json.dumps(data), timeout=timeout)
248
+ response = await self._client.post("/search", content=json.dumps(data), timeout=timeout, **({"headers": override_headers} if override_headers else {}))
142
249
  except httpx.TimeoutException:
143
250
  raise TimeoutError(timeout)
144
251
 
145
252
  if response.status_code == 200:
146
253
  return response.json()
147
254
  else:
148
- detail = ""
149
- try:
150
- detail = response.json().get("detail", {}).get("error", None)
151
- except Exception:
152
- pass
153
-
154
- if response.status_code == 429:
155
- raise UsageLimitExceededError(detail)
156
- elif response.status_code in [403,432,433]:
157
- raise ForbiddenError(detail)
158
- elif response.status_code == 401:
159
- raise InvalidAPIKeyError(detail)
160
- elif response.status_code == 400:
161
- raise BadRequestError(detail)
162
- else:
163
- raise response.raise_for_status()
255
+ self._handle_error_response(response)
164
256
 
165
257
  async def search(self,
166
258
  query: str,
@@ -247,34 +339,19 @@ class AsyncTavilyClient:
247
339
 
248
340
  data = {k: v for k, v in data.items() if v is not None}
249
341
 
342
+ override_headers = self._pop_request_headers(kwargs)
250
343
  if kwargs:
251
344
  data.update(kwargs)
252
345
 
253
346
  try:
254
- response = await self._client.post("/extract", content=json.dumps(data), timeout=timeout)
347
+ response = await self._client.post("/extract", content=json.dumps(data), timeout=timeout, **({"headers": override_headers} if override_headers else {}))
255
348
  except httpx.TimeoutException:
256
349
  raise TimeoutError(timeout)
257
350
 
258
351
  if response.status_code == 200:
259
352
  return response.json()
260
353
  else:
261
- detail = ""
262
- try:
263
- detail = response.json().get("detail", {}).get("error", None)
264
- except Exception:
265
- pass
266
-
267
-
268
- if response.status_code == 429:
269
- raise UsageLimitExceededError(detail)
270
- elif response.status_code in [403,432,433]:
271
- raise ForbiddenError(detail)
272
- elif response.status_code == 401:
273
- raise InvalidAPIKeyError(detail)
274
- elif response.status_code == 400:
275
- raise BadRequestError(detail)
276
- else:
277
- raise response.raise_for_status()
354
+ self._handle_error_response(response)
278
355
 
279
356
  async def extract(self,
280
357
  urls: Union[List[str], str], # Accept a list of URLs or a single URL
@@ -355,35 +432,21 @@ class AsyncTavilyClient:
355
432
  "chunks_per_source": chunks_per_source,
356
433
  }
357
434
 
435
+ override_headers = self._pop_request_headers(kwargs)
358
436
  if kwargs:
359
437
  data.update(kwargs)
360
438
 
361
439
  data = {k: v for k, v in data.items() if v is not None}
362
440
 
363
441
  try:
364
- response = await self._client.post("/crawl", content=json.dumps(data), timeout=timeout)
442
+ response = await self._client.post("/crawl", content=json.dumps(data), timeout=timeout, **({"headers": override_headers} if override_headers else {}))
365
443
  except httpx.TimeoutException:
366
444
  raise TimeoutError(timeout)
367
445
 
368
446
  if response.status_code == 200:
369
447
  return response.json()
370
448
  else:
371
- detail = ""
372
- try:
373
- detail = response.json().get("detail", {}).get("error", None)
374
- except Exception:
375
- pass
376
-
377
- if response.status_code == 429:
378
- raise UsageLimitExceededError(detail)
379
- elif response.status_code in [403,432,433]:
380
- raise ForbiddenError(detail)
381
- elif response.status_code == 401:
382
- raise InvalidAPIKeyError(detail)
383
- elif response.status_code == 400:
384
- raise BadRequestError(detail)
385
- else:
386
- raise response.raise_for_status()
449
+ self._handle_error_response(response)
387
450
 
388
451
  async def crawl(self,
389
452
  url: str,
@@ -409,6 +472,7 @@ class AsyncTavilyClient:
409
472
  Combined crawl method.
410
473
 
411
474
  """
475
+ self._check_keyless_supported("crawl")
412
476
  response_dict = await self._crawl(url,
413
477
  max_depth=max_depth,
414
478
  max_breadth=max_breadth,
@@ -465,35 +529,21 @@ class AsyncTavilyClient:
465
529
  "include_usage": include_usage,
466
530
  }
467
531
 
532
+ override_headers = self._pop_request_headers(kwargs)
468
533
  if kwargs:
469
534
  data.update(kwargs)
470
535
 
471
536
  data = {k: v for k, v in data.items() if v is not None}
472
537
 
473
538
  try:
474
- response = await self._client.post("/map", content=json.dumps(data), timeout=timeout)
539
+ response = await self._client.post("/map", content=json.dumps(data), timeout=timeout, **({"headers": override_headers} if override_headers else {}))
475
540
  except httpx.TimeoutException:
476
541
  raise TimeoutError(timeout)
477
542
 
478
543
  if response.status_code == 200:
479
544
  return response.json()
480
545
  else:
481
- detail = ""
482
- try:
483
- detail = response.json().get("detail", {}).get("error", None)
484
- except Exception:
485
- pass
486
-
487
- if response.status_code == 429:
488
- raise UsageLimitExceededError(detail)
489
- elif response.status_code in [403,432,433]:
490
- raise ForbiddenError(detail)
491
- elif response.status_code == 401:
492
- raise InvalidAPIKeyError(detail)
493
- elif response.status_code == 400:
494
- raise BadRequestError(detail)
495
- else:
496
- raise response.raise_for_status()
546
+ self._handle_error_response(response)
497
547
 
498
548
  async def map(self,
499
549
  url: str,
@@ -515,6 +565,7 @@ class AsyncTavilyClient:
515
565
  Combined map method.
516
566
 
517
567
  """
568
+ self._check_keyless_supported("map")
518
569
  response_dict = await self._map(url,
519
570
  max_depth=max_depth,
520
571
  max_breadth=max_breadth,
@@ -554,6 +605,7 @@ class AsyncTavilyClient:
554
605
 
555
606
  Returns a string of JSON containing the search context up to context limit.
556
607
  """
608
+ self._check_keyless_supported("get_search_context")
557
609
  timeout = min(timeout, 120)
558
610
  response_dict = await self._search(query,
559
611
  search_depth=search_depth,
@@ -590,6 +642,7 @@ class AsyncTavilyClient:
590
642
  """
591
643
  Q&A search method. Search depth is advanced by default to get the best answer.
592
644
  """
645
+ self._check_keyless_supported("qna_search")
593
646
  timeout = min(timeout, 120)
594
647
  response_dict = await self._search(query,
595
648
  search_depth=search_depth,
@@ -616,6 +669,7 @@ class AsyncTavilyClient:
616
669
  country: str = None,
617
670
  ) -> Sequence[dict]:
618
671
  """ Company information search method. Search depth is advanced by default to get the best answer. """
672
+ self._check_keyless_supported("get_company_info")
619
673
  timeout = min(timeout, 120)
620
674
 
621
675
  async def _perform_search(topic: str):
@@ -659,6 +713,7 @@ class AsyncTavilyClient:
659
713
 
660
714
  data = {k: v for k, v in data.items() if v is not None}
661
715
 
716
+ override_headers = self._pop_request_headers(kwargs)
662
717
  if kwargs:
663
718
  data.update(kwargs)
664
719
 
@@ -669,7 +724,8 @@ class AsyncTavilyClient:
669
724
  "POST",
670
725
  "/research",
671
726
  content=json.dumps(data),
672
- timeout=timeout
727
+ timeout=timeout,
728
+ **({"headers": override_headers} if override_headers else {})
673
729
  ) as response:
674
730
  if response.status_code != 200:
675
731
  try:
@@ -678,16 +734,13 @@ class AsyncTavilyClient:
678
734
  except Exception:
679
735
  error_text = "Unknown error"
680
736
 
681
- if response.status_code == 429:
682
- raise UsageLimitExceededError(error_text)
683
- elif response.status_code in [403,432,433]:
684
- raise ForbiddenError(error_text)
685
- elif response.status_code == 401:
686
- raise InvalidAPIKeyError(error_text)
687
- elif response.status_code == 400:
688
- raise BadRequestError(error_text)
689
- else:
690
- raise Exception(f"Error {response.status_code}: {error_text}")
737
+ body_override = None
738
+ try:
739
+ body_override = json.loads(error_text)
740
+ except Exception:
741
+ body_override = error_text
742
+
743
+ self._handle_error_response(response, body_override=body_override)
691
744
 
692
745
  async for chunk in response.aiter_bytes():
693
746
  if chunk:
@@ -701,29 +754,14 @@ class AsyncTavilyClient:
701
754
  else:
702
755
  async def _make_request():
703
756
  try:
704
- response = await self._client.post("/research", content=json.dumps(data), timeout=timeout)
757
+ response = await self._client.post("/research", content=json.dumps(data), timeout=timeout, **({"headers": override_headers} if override_headers else {}))
705
758
  except httpx.TimeoutException:
706
759
  raise TimeoutError(timeout)
707
760
 
708
761
  if response.status_code == 200:
709
762
  return response.json()
710
763
  else:
711
- detail = ""
712
- try:
713
- detail = response.json().get("detail", {}).get("error", None)
714
- except Exception:
715
- pass
716
-
717
- if response.status_code == 429:
718
- raise UsageLimitExceededError(detail)
719
- elif response.status_code in [403,432,433]:
720
- raise ForbiddenError(detail)
721
- elif response.status_code == 401:
722
- raise InvalidAPIKeyError(detail)
723
- elif response.status_code == 400:
724
- raise BadRequestError(detail)
725
- else:
726
- raise response.raise_for_status()
764
+ self._handle_error_response(response)
727
765
 
728
766
  return _make_request()
729
767
 
@@ -752,6 +790,7 @@ class AsyncTavilyClient:
752
790
  When stream=False: dict - the response dictionary.
753
791
  When stream=True: AsyncGenerator[bytes, None] - iterate over this to get streaming chunks.
754
792
  """
793
+ self._check_keyless_supported("research")
755
794
  result = self._research(
756
795
  input=input,
757
796
  model=model,
@@ -778,6 +817,7 @@ class AsyncTavilyClient:
778
817
  Returns:
779
818
  dict: Research response containing request_id, created_at, completed_at, status, content, and sources.
780
819
  """
820
+ self._check_keyless_supported("get_research")
781
821
  try:
782
822
  response = await self._client.get(f"/research/{request_id}")
783
823
  except Exception as e:
@@ -787,19 +827,4 @@ class AsyncTavilyClient:
787
827
  data = response.json()
788
828
  return data
789
829
  else:
790
- detail = ""
791
- try:
792
- detail = response.json().get("detail", {}).get("error", None)
793
- except Exception:
794
- pass
795
-
796
- if response.status_code == 429:
797
- raise UsageLimitExceededError(detail)
798
- elif response.status_code in [403,432,433]:
799
- raise ForbiddenError(detail)
800
- elif response.status_code == 401:
801
- raise InvalidAPIKeyError(detail)
802
- elif response.status_code == 400:
803
- raise BadRequestError(detail)
804
- else:
805
- raise response.raise_for_status()
830
+ self._handle_error_response(response)
@@ -0,0 +1,71 @@
1
+ from typing import Any, List, Optional
2
+
3
+
4
+ class UsageLimitExceededError(Exception):
5
+ def __init__(self, message: str):
6
+ super().__init__(message)
7
+
8
+
9
+ class TavilyKeylessLimitError(UsageLimitExceededError):
10
+ """Raised when a keyless request is rejected by the Tavily API."""
11
+
12
+ def __init__(
13
+ self,
14
+ message: str,
15
+ code: Optional[str] = None,
16
+ window: Optional[str] = None,
17
+ retry_after_seconds: Optional[int] = None,
18
+ next_actions: Optional[List[Any]] = None,
19
+ ):
20
+ super().__init__(message)
21
+ self.message = message
22
+ self.code = code
23
+ self.window = window
24
+ self.retry_after_seconds = retry_after_seconds
25
+ self.next_actions = next_actions or []
26
+
27
+ def __str__(self) -> str:
28
+ return self.message or ""
29
+
30
+
31
+ class BadRequestError(Exception):
32
+ def __init__(self, message: str):
33
+ super().__init__(message)
34
+
35
+
36
+ class ForbiddenError(Exception):
37
+ def __init__(self, message: str):
38
+ super().__init__(message)
39
+
40
+
41
+ class InvalidAPIKeyError(Exception):
42
+ def __init__(self, message: str):
43
+ super().__init__(message)
44
+
45
+
46
+ class TimeoutError(Exception):
47
+ def __init__(self, timeout: float):
48
+ super().__init__(f"Request timed out after {timeout} seconds.")
49
+
50
+
51
+ class MissingAPIKeyError(Exception):
52
+ def __init__(self):
53
+ super().__init__(
54
+ "No API key provided. Please provide the api_key attribute or set the TAVILY_API_KEY environment variable."
55
+ )
56
+
57
+
58
+ class KeylessUnsupportedEndpointError(Exception):
59
+ """Raised when a method is called without an API key but does not support keyless mode.
60
+
61
+ Only ``search`` and ``extract`` support keyless mode. Calling other methods
62
+ keyless raises this error before any network request is sent.
63
+ """
64
+
65
+ def __init__(self, method: str):
66
+ super().__init__(
67
+ f"`{method}` is not available in keyless mode. "
68
+ "Only `search` and `extract` can be called without an API key. "
69
+ "Pass an `api_key` to TavilyClient (or set TAVILY_API_KEY) to use this method."
70
+ )
71
+ self.method = method