tavily-python 0.7.24__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.24 → tavily_python-0.7.25}/PKG-INFO +24 -1
  2. {tavily_python-0.7.24 → tavily_python-0.7.25}/README.md +23 -0
  3. {tavily_python-0.7.24 → 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.24 → tavily_python-0.7.25}/tavily/async_tavily.py +107 -111
  6. tavily_python-0.7.25/tavily/errors.py +71 -0
  7. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily/tavily.py +89 -117
  8. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily_python.egg-info/PKG-INFO +24 -1
  9. {tavily_python-0.7.24 → tavily_python-0.7.25}/tests/test_custom_session.py +17 -10
  10. {tavily_python-0.7.24 → tavily_python-0.7.25}/tests/test_errors.py +12 -13
  11. tavily_python-0.7.24/tavily/__init__.py +0 -4
  12. tavily_python-0.7.24/tavily/errors.py +0 -30
  13. {tavily_python-0.7.24 → tavily_python-0.7.25}/LICENSE +0 -0
  14. {tavily_python-0.7.24 → tavily_python-0.7.25}/setup.cfg +0 -0
  15. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily/config.py +0 -0
  16. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily/hybrid_rag/__init__.py +0 -0
  17. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily/hybrid_rag/hybrid_rag.py +0 -0
  18. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily/utils.py +0 -0
  19. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily_python.egg-info/SOURCES.txt +0 -0
  20. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily_python.egg-info/dependency_links.txt +0 -0
  21. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily_python.egg-info/requires.txt +0 -0
  22. {tavily_python-0.7.24 → tavily_python-0.7.25}/tavily_python.egg-info/top_level.txt +0 -0
  23. {tavily_python-0.7.24 → tavily_python-0.7.25}/tests/test_crawl.py +0 -0
  24. {tavily_python-0.7.24 → tavily_python-0.7.25}/tests/test_map.py +0 -0
  25. {tavily_python-0.7.24 → tavily_python-0.7.25}/tests/test_research.py +0 -0
  26. {tavily_python-0.7.24 → tavily_python-0.7.25}/tests/test_search.py +0 -0
  27. {tavily_python-0.7.24 → 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.24
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.
@@ -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.
@@ -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.24',
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:
@@ -27,18 +57,28 @@ class AsyncTavilyClient:
27
57
  if api_key is None:
28
58
  api_key = os.getenv("TAVILY_API_KEY")
29
59
 
30
- if not api_key and client is None:
31
- 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
32
63
 
33
64
  tavily_project = project_id or os.getenv("TAVILY_PROJECT")
34
65
 
35
66
  self._api_base_url = api_base_url or "https://api.tavily.com"
36
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"
37
76
 
38
77
  default_headers = {
39
78
  "Content-Type": "application/json",
40
79
  **({"Authorization": f"Bearer {api_key}"} if api_key else {}),
41
- "X-Client-Source": client_source or "tavily-python",
80
+ **({"X-Tavily-Access-Mode": "keyless"} if self._keyless else {}),
81
+ "X-Client-Source": client_source_header,
42
82
  **({"X-Project-ID": tavily_project} if tavily_project else {}),
43
83
  **({"X-Session-Id": session_id} if session_id else {}),
44
84
  **({"X-Human-Id": human_id} if human_id else {}),
@@ -89,6 +129,49 @@ class AsyncTavilyClient:
89
129
  async def __aexit__(self, exc_type, exc_val, exc_tb):
90
130
  await self.close()
91
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
+
92
175
  @staticmethod
93
176
  def _pop_request_headers(kwargs: dict) -> Optional[dict]:
94
177
  """Pop session_id, human_id, and client_name from kwargs and return them as headers.
@@ -169,22 +252,7 @@ class AsyncTavilyClient:
169
252
  if response.status_code == 200:
170
253
  return response.json()
171
254
  else:
172
- detail = ""
173
- try:
174
- detail = response.json().get("detail", {}).get("error", None)
175
- except Exception:
176
- pass
177
-
178
- if response.status_code == 429:
179
- raise UsageLimitExceededError(detail)
180
- elif response.status_code in [403,432,433]:
181
- raise ForbiddenError(detail)
182
- elif response.status_code == 401:
183
- raise InvalidAPIKeyError(detail)
184
- elif response.status_code == 400:
185
- raise BadRequestError(detail)
186
- else:
187
- raise response.raise_for_status()
255
+ self._handle_error_response(response)
188
256
 
189
257
  async def search(self,
190
258
  query: str,
@@ -283,23 +351,7 @@ class AsyncTavilyClient:
283
351
  if response.status_code == 200:
284
352
  return response.json()
285
353
  else:
286
- detail = ""
287
- try:
288
- detail = response.json().get("detail", {}).get("error", None)
289
- except Exception:
290
- pass
291
-
292
-
293
- if response.status_code == 429:
294
- raise UsageLimitExceededError(detail)
295
- elif response.status_code in [403,432,433]:
296
- raise ForbiddenError(detail)
297
- elif response.status_code == 401:
298
- raise InvalidAPIKeyError(detail)
299
- elif response.status_code == 400:
300
- raise BadRequestError(detail)
301
- else:
302
- raise response.raise_for_status()
354
+ self._handle_error_response(response)
303
355
 
304
356
  async def extract(self,
305
357
  urls: Union[List[str], str], # Accept a list of URLs or a single URL
@@ -394,22 +446,7 @@ class AsyncTavilyClient:
394
446
  if response.status_code == 200:
395
447
  return response.json()
396
448
  else:
397
- detail = ""
398
- try:
399
- detail = response.json().get("detail", {}).get("error", None)
400
- except Exception:
401
- pass
402
-
403
- if response.status_code == 429:
404
- raise UsageLimitExceededError(detail)
405
- elif response.status_code in [403,432,433]:
406
- raise ForbiddenError(detail)
407
- elif response.status_code == 401:
408
- raise InvalidAPIKeyError(detail)
409
- elif response.status_code == 400:
410
- raise BadRequestError(detail)
411
- else:
412
- raise response.raise_for_status()
449
+ self._handle_error_response(response)
413
450
 
414
451
  async def crawl(self,
415
452
  url: str,
@@ -435,6 +472,7 @@ class AsyncTavilyClient:
435
472
  Combined crawl method.
436
473
 
437
474
  """
475
+ self._check_keyless_supported("crawl")
438
476
  response_dict = await self._crawl(url,
439
477
  max_depth=max_depth,
440
478
  max_breadth=max_breadth,
@@ -505,22 +543,7 @@ class AsyncTavilyClient:
505
543
  if response.status_code == 200:
506
544
  return response.json()
507
545
  else:
508
- detail = ""
509
- try:
510
- detail = response.json().get("detail", {}).get("error", None)
511
- except Exception:
512
- pass
513
-
514
- if response.status_code == 429:
515
- raise UsageLimitExceededError(detail)
516
- elif response.status_code in [403,432,433]:
517
- raise ForbiddenError(detail)
518
- elif response.status_code == 401:
519
- raise InvalidAPIKeyError(detail)
520
- elif response.status_code == 400:
521
- raise BadRequestError(detail)
522
- else:
523
- raise response.raise_for_status()
546
+ self._handle_error_response(response)
524
547
 
525
548
  async def map(self,
526
549
  url: str,
@@ -542,6 +565,7 @@ class AsyncTavilyClient:
542
565
  Combined map method.
543
566
 
544
567
  """
568
+ self._check_keyless_supported("map")
545
569
  response_dict = await self._map(url,
546
570
  max_depth=max_depth,
547
571
  max_breadth=max_breadth,
@@ -581,6 +605,7 @@ class AsyncTavilyClient:
581
605
 
582
606
  Returns a string of JSON containing the search context up to context limit.
583
607
  """
608
+ self._check_keyless_supported("get_search_context")
584
609
  timeout = min(timeout, 120)
585
610
  response_dict = await self._search(query,
586
611
  search_depth=search_depth,
@@ -617,6 +642,7 @@ class AsyncTavilyClient:
617
642
  """
618
643
  Q&A search method. Search depth is advanced by default to get the best answer.
619
644
  """
645
+ self._check_keyless_supported("qna_search")
620
646
  timeout = min(timeout, 120)
621
647
  response_dict = await self._search(query,
622
648
  search_depth=search_depth,
@@ -643,6 +669,7 @@ class AsyncTavilyClient:
643
669
  country: str = None,
644
670
  ) -> Sequence[dict]:
645
671
  """ Company information search method. Search depth is advanced by default to get the best answer. """
672
+ self._check_keyless_supported("get_company_info")
646
673
  timeout = min(timeout, 120)
647
674
 
648
675
  async def _perform_search(topic: str):
@@ -707,16 +734,13 @@ class AsyncTavilyClient:
707
734
  except Exception:
708
735
  error_text = "Unknown error"
709
736
 
710
- if response.status_code == 429:
711
- raise UsageLimitExceededError(error_text)
712
- elif response.status_code in [403,432,433]:
713
- raise ForbiddenError(error_text)
714
- elif response.status_code == 401:
715
- raise InvalidAPIKeyError(error_text)
716
- elif response.status_code == 400:
717
- raise BadRequestError(error_text)
718
- else:
719
- 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)
720
744
 
721
745
  async for chunk in response.aiter_bytes():
722
746
  if chunk:
@@ -737,22 +761,7 @@ class AsyncTavilyClient:
737
761
  if response.status_code == 200:
738
762
  return response.json()
739
763
  else:
740
- detail = ""
741
- try:
742
- detail = response.json().get("detail", {}).get("error", None)
743
- except Exception:
744
- pass
745
-
746
- if response.status_code == 429:
747
- raise UsageLimitExceededError(detail)
748
- elif response.status_code in [403,432,433]:
749
- raise ForbiddenError(detail)
750
- elif response.status_code == 401:
751
- raise InvalidAPIKeyError(detail)
752
- elif response.status_code == 400:
753
- raise BadRequestError(detail)
754
- else:
755
- raise response.raise_for_status()
764
+ self._handle_error_response(response)
756
765
 
757
766
  return _make_request()
758
767
 
@@ -781,6 +790,7 @@ class AsyncTavilyClient:
781
790
  When stream=False: dict - the response dictionary.
782
791
  When stream=True: AsyncGenerator[bytes, None] - iterate over this to get streaming chunks.
783
792
  """
793
+ self._check_keyless_supported("research")
784
794
  result = self._research(
785
795
  input=input,
786
796
  model=model,
@@ -807,6 +817,7 @@ class AsyncTavilyClient:
807
817
  Returns:
808
818
  dict: Research response containing request_id, created_at, completed_at, status, content, and sources.
809
819
  """
820
+ self._check_keyless_supported("get_research")
810
821
  try:
811
822
  response = await self._client.get(f"/research/{request_id}")
812
823
  except Exception as e:
@@ -816,19 +827,4 @@ class AsyncTavilyClient:
816
827
  data = response.json()
817
828
  return data
818
829
  else:
819
- detail = ""
820
- try:
821
- detail = response.json().get("detail", {}).get("error", None)
822
- except Exception:
823
- pass
824
-
825
- if response.status_code == 429:
826
- raise UsageLimitExceededError(detail)
827
- elif response.status_code in [403,432,433]:
828
- raise ForbiddenError(detail)
829
- elif response.status_code == 401:
830
- raise InvalidAPIKeyError(detail)
831
- elif response.status_code == 400:
832
- raise BadRequestError(detail)
833
- else:
834
- 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
@@ -4,7 +4,38 @@ import os
4
4
  import warnings
5
5
  from typing import Literal, Sequence, Optional, List, Union, Generator
6
6
  from .utils import get_max_items_from_list
7
- from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError
7
+ from .errors import (
8
+ UsageLimitExceededError,
9
+ InvalidAPIKeyError,
10
+ MissingAPIKeyError,
11
+ BadRequestError,
12
+ ForbiddenError,
13
+ TimeoutError,
14
+ TavilyKeylessLimitError,
15
+ KeylessUnsupportedEndpointError,
16
+ )
17
+
18
+
19
+ def _is_keyless_envelope(body) -> bool:
20
+ """Return True when the response body matches the Tavily recoverable-error envelope shape."""
21
+ return (
22
+ isinstance(body, dict)
23
+ and isinstance(body.get("error"), dict)
24
+ and isinstance(body["error"].get("code"), str)
25
+ )
26
+
27
+
28
+ def _raise_keyless_envelope(body) -> None:
29
+ """Raise ``TavilyKeylessLimitError`` from an envelope-shaped response body."""
30
+ err = body["error"]
31
+ raise TavilyKeylessLimitError(
32
+ message=err.get("message") or "",
33
+ code=err.get("code"),
34
+ window=err.get("window"),
35
+ retry_after_seconds=err.get("retry_after_seconds"),
36
+ next_actions=err.get("next_actions") or [],
37
+ )
38
+
8
39
 
9
40
  class TavilyClient:
10
41
  """
@@ -26,8 +57,7 @@ class TavilyClient:
26
57
  if api_key is None:
27
58
  api_key = os.getenv("TAVILY_API_KEY")
28
59
 
29
- if not api_key and session is None:
30
- raise MissingAPIKeyError()
60
+ api_key = api_key or None
31
61
 
32
62
  resolved_proxies = {
33
63
  "http": proxies.get("http") if proxies else os.getenv("TAVILY_HTTP_PROXY"),
@@ -39,12 +69,19 @@ class TavilyClient:
39
69
 
40
70
  self.base_url = api_base_url or "https://api.tavily.com"
41
71
  self.api_key = api_key
72
+ self._keyless = api_key is None and session is None
42
73
  self.proxies = resolved_proxies
43
74
 
75
+ if self._keyless:
76
+ client_source_header = client_source or "tavily-python-keyless"
77
+ else:
78
+ client_source_header = client_source or "tavily-python"
79
+
44
80
  self.headers = {
45
81
  "Content-Type": "application/json",
46
82
  **({"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}),
47
- "X-Client-Source": client_source or "tavily-python",
83
+ **({"X-Tavily-Access-Mode": "keyless"} if self._keyless else {}),
84
+ "X-Client-Source": client_source_header,
48
85
  **({"X-Project-ID": tavily_project} if tavily_project else {}),
49
86
  **({"X-Session-Id": session_id} if session_id else {}),
50
87
  **({"X-Human-Id": human_id} if human_id else {}),
@@ -73,6 +110,40 @@ class TavilyClient:
73
110
  def __exit__(self, exc_type, exc_val, exc_tb):
74
111
  self.close()
75
112
 
113
+ def _handle_error_response(self, response) -> None:
114
+ """Raise an appropriate exception for a non-2xx response."""
115
+ body = None
116
+ try:
117
+ body = response.json()
118
+ except Exception:
119
+ body = None
120
+
121
+ if self._keyless and _is_keyless_envelope(body):
122
+ _raise_keyless_envelope(body)
123
+
124
+ detail = ""
125
+ if isinstance(body, dict):
126
+ try:
127
+ detail = body.get("detail", {}).get("error", None) or ""
128
+ except Exception:
129
+ detail = ""
130
+
131
+ if response.status_code == 429:
132
+ raise UsageLimitExceededError(detail)
133
+ elif response.status_code in [403, 432, 433]:
134
+ raise ForbiddenError(detail)
135
+ elif response.status_code == 401:
136
+ raise InvalidAPIKeyError(detail)
137
+ elif response.status_code == 400:
138
+ raise BadRequestError(detail)
139
+ else:
140
+ raise response.raise_for_status()
141
+
142
+ def _check_keyless_supported(self, method: str) -> None:
143
+ """Raise ``KeylessUnsupportedEndpointError`` when running in keyless mode."""
144
+ if self._keyless:
145
+ raise KeylessUnsupportedEndpointError(method)
146
+
76
147
  @staticmethod
77
148
  def _pop_request_headers(kwargs: dict) -> Optional[dict]:
78
149
  """Pop session_id, human_id, and client_name from kwargs and return them as headers.
@@ -155,22 +226,7 @@ class TavilyClient:
155
226
  if response.status_code == 200:
156
227
  return response.json()
157
228
  else:
158
- detail = ""
159
- try:
160
- detail = response.json().get("detail", {}).get("error", None)
161
- except Exception:
162
- pass
163
-
164
- if response.status_code == 429:
165
- raise UsageLimitExceededError(detail)
166
- elif response.status_code in [403, 432, 433]:
167
- raise ForbiddenError(detail)
168
- elif response.status_code == 401:
169
- raise InvalidAPIKeyError(detail)
170
- elif response.status_code == 400:
171
- raise BadRequestError(detail)
172
- else:
173
- raise response.raise_for_status()
229
+ self._handle_error_response(response)
174
230
 
175
231
 
176
232
  def search(self,
@@ -263,22 +319,7 @@ class TavilyClient:
263
319
  if response.status_code == 200:
264
320
  return response.json()
265
321
  else:
266
- detail = ""
267
- try:
268
- detail = response.json().get("detail", {}).get("error", None)
269
- except Exception:
270
- pass
271
-
272
- if response.status_code == 429:
273
- raise UsageLimitExceededError(detail)
274
- elif response.status_code in [403, 432, 433]:
275
- raise ForbiddenError(detail)
276
- elif response.status_code == 401:
277
- raise InvalidAPIKeyError(detail)
278
- elif response.status_code == 400:
279
- raise BadRequestError(detail)
280
- else:
281
- raise response.raise_for_status()
322
+ self._handle_error_response(response)
282
323
 
283
324
  def extract(self,
284
325
  urls: Union[List[str], str], # Accept a list of URLs or a single URL
@@ -367,22 +408,7 @@ class TavilyClient:
367
408
  if response.status_code == 200:
368
409
  return response.json()
369
410
  else:
370
- detail = ""
371
- try:
372
- detail = response.json().get("detail", {}).get("error", None)
373
- except Exception:
374
- pass
375
-
376
- if response.status_code == 429:
377
- raise UsageLimitExceededError(detail)
378
- elif response.status_code in [403, 432, 433]:
379
- raise ForbiddenError(detail)
380
- elif response.status_code == 401:
381
- raise InvalidAPIKeyError(detail)
382
- elif response.status_code == 400:
383
- raise BadRequestError(detail)
384
- else:
385
- raise response.raise_for_status()
411
+ self._handle_error_response(response)
386
412
 
387
413
  def crawl(self,
388
414
  url: str,
@@ -408,6 +434,7 @@ class TavilyClient:
408
434
  Combined crawl method.
409
435
  include_favicon: If True, include the favicon in the crawl results.
410
436
  """
437
+ self._check_keyless_supported("crawl")
411
438
  return self._crawl(url,
412
439
  max_depth=max_depth,
413
440
  max_breadth=max_breadth,
@@ -476,22 +503,7 @@ class TavilyClient:
476
503
  if response.status_code == 200:
477
504
  return response.json()
478
505
  else:
479
- detail = ""
480
- try:
481
- detail = response.json().get("detail", {}).get("error", None)
482
- except Exception:
483
- pass
484
-
485
- if response.status_code == 429:
486
- raise UsageLimitExceededError(detail)
487
- elif response.status_code in [403, 432, 433]:
488
- raise ForbiddenError(detail)
489
- elif response.status_code == 401:
490
- raise InvalidAPIKeyError(detail)
491
- elif response.status_code == 400:
492
- raise BadRequestError(detail)
493
- else:
494
- raise response.raise_for_status()
506
+ self._handle_error_response(response)
495
507
 
496
508
  def map(self,
497
509
  url: str,
@@ -511,8 +523,9 @@ class TavilyClient:
511
523
  ) -> dict:
512
524
  """
513
525
  Combined map method.
514
-
526
+
515
527
  """
528
+ self._check_keyless_supported("map")
516
529
  return self._map(url,
517
530
  max_depth=max_depth,
518
531
  max_breadth=max_breadth,
@@ -550,6 +563,7 @@ class TavilyClient:
550
563
 
551
564
  Returns a string of JSON containing the search context up to context limit.
552
565
  """
566
+ self._check_keyless_supported("get_search_context")
553
567
  warnings.warn("get_search_context is deprecated and will be removed in future versions.",
554
568
  DeprecationWarning, stacklevel=2)
555
569
 
@@ -589,6 +603,7 @@ class TavilyClient:
589
603
  """
590
604
  Q&A search method. Search depth is advanced by default to get the best answer.
591
605
  """
606
+ self._check_keyless_supported("qna_search")
592
607
  warnings.warn("qna_search is deprecated and will be removed in future versions.",
593
608
  DeprecationWarning, stacklevel=2)
594
609
  response_dict = self._search(query,
@@ -647,22 +662,7 @@ class TavilyClient:
647
662
  raise TimeoutError(timeout)
648
663
 
649
664
  if response.status_code != 200:
650
- detail = ""
651
- try:
652
- detail = response.json().get("detail", {}).get("error", None)
653
- except Exception:
654
- pass
655
-
656
- if response.status_code == 429:
657
- raise UsageLimitExceededError(detail)
658
- elif response.status_code in [403, 432, 433]:
659
- raise ForbiddenError(detail)
660
- elif response.status_code == 401:
661
- raise InvalidAPIKeyError(detail)
662
- elif response.status_code == 400:
663
- raise BadRequestError(detail)
664
- else:
665
- raise response.raise_for_status()
665
+ self._handle_error_response(response)
666
666
 
667
667
  def stream_generator() -> Generator[bytes, None, None]:
668
668
  try:
@@ -687,22 +687,7 @@ class TavilyClient:
687
687
  if response.status_code == 200:
688
688
  return response.json()
689
689
  else:
690
- detail = ""
691
- try:
692
- detail = response.json().get("detail", {}).get("error", None)
693
- except Exception:
694
- pass
695
-
696
- if response.status_code == 429:
697
- raise UsageLimitExceededError(detail)
698
- elif response.status_code in [403, 432, 433]:
699
- raise ForbiddenError(detail)
700
- elif response.status_code == 401:
701
- raise InvalidAPIKeyError(detail)
702
- elif response.status_code == 400:
703
- raise BadRequestError(detail)
704
- else:
705
- raise response.raise_for_status()
690
+ self._handle_error_response(response)
706
691
 
707
692
  def research(self,
708
693
  input: str,
@@ -728,6 +713,7 @@ class TavilyClient:
728
713
  Returns:
729
714
  dict: Response containing request_id, created_at, status, input, and model.
730
715
  """
716
+ self._check_keyless_supported("research")
731
717
 
732
718
  return self._research(
733
719
  input=input,
@@ -751,6 +737,7 @@ class TavilyClient:
751
737
  Returns:
752
738
  dict: Research response containing request_id, created_at, completed_at, status, content, and sources.
753
739
  """
740
+ self._check_keyless_supported("get_research")
754
741
  try:
755
742
  response = self.session.get(self.base_url + f"/research/{request_id}")
756
743
  except Exception as e:
@@ -759,22 +746,7 @@ class TavilyClient:
759
746
  if response.status_code in (200, 202):
760
747
  return response.json()
761
748
  else:
762
- detail = ""
763
- try:
764
- detail = response.json().get("detail", {}).get("error", None)
765
- except Exception:
766
- pass
767
-
768
- if response.status_code == 429:
769
- raise UsageLimitExceededError(detail)
770
- elif response.status_code in [403, 432, 433]:
771
- raise ForbiddenError(detail)
772
- elif response.status_code == 401:
773
- raise InvalidAPIKeyError(detail)
774
- elif response.status_code == 400:
775
- raise BadRequestError(detail)
776
- else:
777
- raise response.raise_for_status()
749
+ self._handle_error_response(response)
778
750
 
779
751
 
780
752
  class Client(TavilyClient):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tavily-python
3
- Version: 0.7.24
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.
@@ -4,7 +4,6 @@ from tests.request_intercept import intercept_requests, clear_interceptor, MockS
4
4
  import tavily.tavily as sync_tavily
5
5
  import tavily.async_tavily as async_tavily
6
6
  import pytest
7
- from tavily.errors import MissingAPIKeyError
8
7
 
9
8
 
10
9
  @pytest.fixture
@@ -104,10 +103,14 @@ class TestSyncCustomSession:
104
103
 
105
104
  # --- API key validation edge cases ---
106
105
 
107
- def test_no_api_key_no_session_raises(self, monkeypatch):
106
+ def test_no_api_key_no_session_creates_keyless_client(self, monkeypatch):
107
+ """No api_key + no custom session -> keyless mode (search/extract only)."""
108
108
  monkeypatch.delenv("TAVILY_API_KEY", raising=False)
109
- with pytest.raises(MissingAPIKeyError):
110
- sync_tavily.TavilyClient()
109
+ client = sync_tavily.TavilyClient()
110
+ assert client._keyless is True
111
+ assert "Authorization" not in client.headers
112
+ assert client.headers.get("X-Tavily-Access-Mode") == "keyless"
113
+ assert client.headers.get("X-Client-Source") == "tavily-python-keyless"
111
114
 
112
115
  def test_no_api_key_with_session_allowed(self, sync_interceptor, monkeypatch):
113
116
  monkeypatch.delenv("TAVILY_API_KEY", raising=False)
@@ -140,10 +143,13 @@ class TestSyncCustomSession:
140
143
  assert req is not None
141
144
  assert req.headers["Authorization"] == "Bearer apim-token"
142
145
 
143
- def test_empty_string_api_key_no_session_raises(self, monkeypatch):
146
+ def test_empty_string_api_key_no_session_creates_keyless_client(self, monkeypatch):
147
+ """Empty-string api_key + no custom session -> keyless mode."""
144
148
  monkeypatch.delenv("TAVILY_API_KEY", raising=False)
145
- with pytest.raises(MissingAPIKeyError):
146
- sync_tavily.TavilyClient(api_key="")
149
+ client = sync_tavily.TavilyClient(api_key="")
150
+ assert client._keyless is True
151
+ assert "Authorization" not in client.headers
152
+ assert client.headers.get("X-Tavily-Access-Mode") == "keyless"
147
153
 
148
154
  def test_empty_string_api_key_with_session_allowed(self, sync_interceptor):
149
155
  custom_session = MockSession(sync_interceptor)
@@ -336,10 +342,11 @@ class TestAsyncCustomClient:
336
342
 
337
343
  # --- API key validation edge cases ---
338
344
 
339
- def test_no_api_key_no_client_raises(self, monkeypatch):
345
+ def test_no_api_key_no_client_creates_keyless_client(self, monkeypatch):
346
+ """Async: no api_key + no custom client -> keyless mode."""
340
347
  monkeypatch.delenv("TAVILY_API_KEY", raising=False)
341
- with pytest.raises(MissingAPIKeyError):
342
- async_tavily.AsyncTavilyClient()
348
+ client = async_tavily.AsyncTavilyClient()
349
+ assert client._keyless is True
343
350
 
344
351
  def test_no_api_key_with_client_allowed(self, monkeypatch):
345
352
  monkeypatch.delenv("TAVILY_API_KEY", raising=False)
@@ -4,7 +4,7 @@ import asyncio
4
4
 
5
5
  import tavily.tavily as sync_tavily
6
6
  import tavily.async_tavily as async_tavily
7
- from tavily.errors import MissingAPIKeyError, InvalidAPIKeyError
7
+ from tavily.errors import InvalidAPIKeyError
8
8
 
9
9
  @pytest.fixture
10
10
  def set_api_key():
@@ -31,18 +31,17 @@ def test_load_key_from_env(set_api_key):
31
31
 
32
32
  # No error should be raised
33
33
 
34
- def test_missing_api_key(clear_api_key):
35
- with pytest.raises(MissingAPIKeyError):
36
- sync_tavily.TavilyClient(api_key='')
37
-
38
- with pytest.raises(MissingAPIKeyError):
39
- async_tavily.AsyncTavilyClient(api_key='')
40
-
41
- with pytest.raises(MissingAPIKeyError):
42
- sync_tavily.TavilyClient()
43
-
44
- with pytest.raises(MissingAPIKeyError):
45
- async_tavily.AsyncTavilyClient()
34
+ def test_no_api_key_creates_keyless_client(clear_api_key):
35
+ """With no API key (None or empty string) the client constructs in keyless mode."""
36
+ for ctor_args in [{"api_key": ""}, {}, {"api_key": None}]:
37
+ sync_client = sync_tavily.TavilyClient(**ctor_args)
38
+ assert sync_client._keyless is True
39
+ assert "Authorization" not in sync_client.headers
40
+ assert sync_client.headers.get("X-Tavily-Access-Mode") == "keyless"
41
+ assert sync_client.headers.get("X-Client-Source") == "tavily-python-keyless"
42
+
43
+ async_client = async_tavily.AsyncTavilyClient(**ctor_args)
44
+ assert async_client._keyless is True
46
45
 
47
46
  def test_invalid_api_key():
48
47
  with pytest.raises(InvalidAPIKeyError):
@@ -1,4 +0,0 @@
1
- from .async_tavily import AsyncTavilyClient
2
- from .tavily import Client, TavilyClient
3
- from .errors import InvalidAPIKeyError, UsageLimitExceededError, MissingAPIKeyError, BadRequestError
4
- from .hybrid_rag import TavilyHybridClient
@@ -1,30 +0,0 @@
1
- class UsageLimitExceededError(Exception):
2
- def __init__(self, message: str):
3
- super().__init__(message)
4
-
5
-
6
- class BadRequestError(Exception):
7
- def __init__(self, message: str):
8
- super().__init__(message)
9
-
10
-
11
- class ForbiddenError(Exception):
12
- def __init__(self, message: str):
13
- super().__init__(message)
14
-
15
-
16
- class InvalidAPIKeyError(Exception):
17
- def __init__(self, message: str):
18
- super().__init__(message)
19
-
20
-
21
- class TimeoutError(Exception):
22
- def __init__(self, timeout: float):
23
- super().__init__(f"Request timed out after {timeout} seconds.")
24
-
25
-
26
- class MissingAPIKeyError(Exception):
27
- def __init__(self):
28
- super().__init__(
29
- "No API key provided. Please provide the api_key attribute or set the TAVILY_API_KEY environment variable."
30
- )
File without changes
File without changes