exa-py 1.9.1__tar.gz → 1.11.0__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.

Potentially problematic release.


This version of exa-py might be problematic. Click here for more details.

@@ -1,21 +1,24 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: exa-py
3
- Version: 1.9.1
3
+ Version: 1.11.0
4
4
  Summary: Python SDK for Exa API.
5
+ License: MIT
5
6
  Author: Exa AI
6
7
  Author-email: hello@exa.ai
7
- Requires-Python: >=3.9,<4.0
8
+ Requires-Python: >=3.9
9
+ Classifier: License :: OSI Approved :: MIT License
8
10
  Classifier: Programming Language :: Python :: 3
9
11
  Classifier: Programming Language :: Python :: 3.9
10
12
  Classifier: Programming Language :: Python :: 3.10
11
13
  Classifier: Programming Language :: Python :: 3.11
12
14
  Classifier: Programming Language :: Python :: 3.12
13
15
  Classifier: Programming Language :: Python :: 3.13
14
- Requires-Dist: openai (>=1.48,<2.0)
15
- Requires-Dist: pydantic (>=2.10.6,<3.0.0)
16
- Requires-Dist: pytest-mock (>=3.14.0,<4.0.0)
17
- Requires-Dist: requests (>=2.32.3,<3.0.0)
18
- Requires-Dist: typing-extensions (>=4.12.2,<5.0.0)
16
+ Requires-Dist: httpx (>=0.28.1)
17
+ Requires-Dist: openai (>=1.48)
18
+ Requires-Dist: pydantic (>=2.10.6)
19
+ Requires-Dist: pytest-mock (>=3.14.0)
20
+ Requires-Dist: requests (>=2.32.3)
21
+ Requires-Dist: typing-extensions (>=4.12.2)
19
22
  Description-Content-Type: text/markdown
20
23
 
21
24
  # Exa
@@ -0,0 +1,2 @@
1
+ from .api import Exa as Exa
2
+ from .api import AsyncExa as AsyncExa
@@ -22,6 +22,7 @@ from typing import (
22
22
  overload,
23
23
  )
24
24
 
25
+ import httpx
25
26
  import requests
26
27
  from openai import OpenAI
27
28
  from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam
@@ -730,6 +731,56 @@ class StreamAnswerResponse:
730
731
  self._raw_response.close()
731
732
 
732
733
 
734
+ class AsyncStreamAnswerResponse:
735
+ """A class representing a streaming answer response."""
736
+
737
+ def __init__(self, raw_response: httpx.Response):
738
+ self._raw_response = raw_response
739
+ self._ensure_ok_status()
740
+
741
+ def _ensure_ok_status(self):
742
+ if self._raw_response.status_code != 200:
743
+ raise ValueError(
744
+ f"Request failed with status code {self._raw_response.status_code}: {self._raw_response.text}"
745
+ )
746
+
747
+ def __aiter__(self):
748
+ async def generator():
749
+ async for line in self._raw_response.aiter_lines():
750
+ if not line:
751
+ continue
752
+ decoded_line = line.removeprefix("data: ")
753
+ try:
754
+ chunk = json.loads(decoded_line)
755
+ except json.JSONDecodeError:
756
+ continue
757
+
758
+ content = None
759
+ citations = None
760
+
761
+ if "choices" in chunk and chunk["choices"]:
762
+ if "delta" in chunk["choices"][0]:
763
+ content = chunk["choices"][0]["delta"].get("content")
764
+
765
+ if (
766
+ "citations" in chunk
767
+ and chunk["citations"]
768
+ and chunk["citations"] != "null"
769
+ ):
770
+ citations = [
771
+ AnswerResult(**to_snake_case(s)) for s in chunk["citations"]
772
+ ]
773
+
774
+ stream_chunk = StreamChunk(content=content, citations=citations)
775
+ if stream_chunk.has_data():
776
+ yield stream_chunk
777
+ return generator()
778
+
779
+ def close(self) -> None:
780
+ """Close the underlying raw response to release the network socket."""
781
+ self._raw_response.close()
782
+
783
+
733
784
  T = TypeVar("T")
734
785
 
735
786
 
@@ -789,7 +840,7 @@ class Exa:
789
840
  self,
790
841
  api_key: Optional[str],
791
842
  base_url: str = "https://api.exa.ai",
792
- user_agent: str = "exa-py 1.9.1",
843
+ user_agent: str = "exa-py 1.11.0",
793
844
  ):
794
845
  """Initialize the Exa client with the provided API key and optional base URL and user agent.
795
846
 
@@ -1775,6 +1826,7 @@ class Exa:
1775
1826
  *,
1776
1827
  stream: Optional[bool] = False,
1777
1828
  text: Optional[bool] = False,
1829
+ system_prompt: Optional[str] = None,
1778
1830
  model: Optional[Literal["exa", "exa-pro"]] = None,
1779
1831
  ) -> Union[AnswerResponse, StreamAnswerResponse]: ...
1780
1832
 
@@ -1784,6 +1836,7 @@ class Exa:
1784
1836
  *,
1785
1837
  stream: Optional[bool] = False,
1786
1838
  text: Optional[bool] = False,
1839
+ system_prompt: Optional[str] = None,
1787
1840
  model: Optional[Literal["exa", "exa-pro"]] = None,
1788
1841
  ) -> Union[AnswerResponse, StreamAnswerResponse]:
1789
1842
  """Generate an answer to a query using Exa's search and LLM capabilities.
@@ -1791,6 +1844,7 @@ class Exa:
1791
1844
  Args:
1792
1845
  query (str): The query to answer.
1793
1846
  text (bool, optional): Whether to include full text in the results. Defaults to False.
1847
+ system_prompt (str, optional): A system prompt to guide the LLM's behavior when generating the answer.
1794
1848
  model (str, optional): The model to use for answering. Either "exa" or "exa-pro". Defaults to None.
1795
1849
 
1796
1850
  Returns:
@@ -1819,6 +1873,7 @@ class Exa:
1819
1873
  query: str,
1820
1874
  *,
1821
1875
  text: bool = False,
1876
+ system_prompt: Optional[str] = None,
1822
1877
  model: Optional[Literal["exa", "exa-pro"]] = None,
1823
1878
  ) -> StreamAnswerResponse:
1824
1879
  """Generate a streaming answer response.
@@ -1826,6 +1881,7 @@ class Exa:
1826
1881
  Args:
1827
1882
  query (str): The query to answer.
1828
1883
  text (bool): Whether to include full text in the results. Defaults to False.
1884
+ system_prompt (str, optional): A system prompt to guide the LLM's behavior when generating the answer.
1829
1885
  model (str, optional): The model to use for answering. Either "exa" or "exa-pro". Defaults to None.
1830
1886
 
1831
1887
  Returns:
@@ -1837,3 +1893,337 @@ class Exa:
1837
1893
  options["stream"] = True
1838
1894
  raw_response = self.request("/answer", options)
1839
1895
  return StreamAnswerResponse(raw_response)
1896
+
1897
+ class AsyncExa(Exa):
1898
+ def __init__(self, api_key: str, api_base: str = "https://api.exa.ai"):
1899
+ super().__init__(api_key, api_base)
1900
+ self._client = None
1901
+
1902
+ @property
1903
+ def client(self) -> httpx.AsyncClient:
1904
+ # this may only be a
1905
+ if self._client is None:
1906
+ self._client = httpx.AsyncClient(
1907
+ base_url=self.base_url,
1908
+ headers=self.headers,
1909
+ timeout=60
1910
+ )
1911
+ return self._client
1912
+
1913
+ async def async_request(self, endpoint: str, data):
1914
+ """Send a POST request to the Exa API, optionally streaming if data['stream'] is True.
1915
+
1916
+ Args:
1917
+ endpoint (str): The API endpoint (path).
1918
+ data (dict): The JSON payload to send.
1919
+
1920
+ Returns:
1921
+ Union[dict, requests.Response]: If streaming, returns the Response object.
1922
+ Otherwise, returns the JSON-decoded response as a dict.
1923
+
1924
+ Raises:
1925
+ ValueError: If the request fails (non-200 status code).
1926
+ """
1927
+ if data.get("stream"):
1928
+ request = httpx.Request(
1929
+ 'POST',
1930
+ self.base_url + endpoint,
1931
+ json=data,
1932
+ headers=self.headers
1933
+ )
1934
+ res = await self.client.send(request, stream=True)
1935
+ return res
1936
+
1937
+ res = await self.client.post(self.base_url + endpoint, json=data, headers=self.headers)
1938
+ if res.status_code != 200:
1939
+ raise ValueError(
1940
+ f"Request failed with status code {res.status_code}: {res.text}"
1941
+ )
1942
+ return res.json()
1943
+
1944
+ async def search(
1945
+ self,
1946
+ query: str,
1947
+ *,
1948
+ num_results: Optional[int] = None,
1949
+ include_domains: Optional[List[str]] = None,
1950
+ exclude_domains: Optional[List[str]] = None,
1951
+ start_crawl_date: Optional[str] = None,
1952
+ end_crawl_date: Optional[str] = None,
1953
+ start_published_date: Optional[str] = None,
1954
+ end_published_date: Optional[str] = None,
1955
+ include_text: Optional[List[str]] = None,
1956
+ exclude_text: Optional[List[str]] = None,
1957
+ use_autoprompt: Optional[bool] = None,
1958
+ type: Optional[str] = None,
1959
+ category: Optional[str] = None,
1960
+ flags: Optional[List[str]] = None,
1961
+ moderation: Optional[bool] = None,
1962
+ ) -> SearchResponse[_Result]:
1963
+ """Perform a search with a prompt-engineered query to retrieve relevant results.
1964
+
1965
+ Args:
1966
+ query (str): The query string.
1967
+ num_results (int, optional): Number of search results to return (default 10).
1968
+ include_domains (List[str], optional): Domains to include in the search.
1969
+ exclude_domains (List[str], optional): Domains to exclude from the search.
1970
+ start_crawl_date (str, optional): Only links crawled after this date.
1971
+ end_crawl_date (str, optional): Only links crawled before this date.
1972
+ start_published_date (str, optional): Only links published after this date.
1973
+ end_published_date (str, optional): Only links published before this date.
1974
+ include_text (List[str], optional): Strings that must appear in the page text.
1975
+ exclude_text (List[str], optional): Strings that must not appear in the page text.
1976
+ use_autoprompt (bool, optional): Convert query to Exa (default False).
1977
+ type (str, optional): 'keyword' or 'neural' (default 'neural').
1978
+ category (str, optional): e.g. 'company'
1979
+ flags (List[str], optional): Experimental flags for Exa usage.
1980
+ moderation (bool, optional): If True, the search results will be moderated for safety.
1981
+
1982
+ Returns:
1983
+ SearchResponse: The response containing search results, etc.
1984
+ """
1985
+ options = {k: v for k, v in locals().items() if k != "self" and v is not None}
1986
+ validate_search_options(options, SEARCH_OPTIONS_TYPES)
1987
+ options = to_camel_case(options)
1988
+ data = await self.async_request("/search", options)
1989
+ cost_dollars = parse_cost_dollars(data.get("costDollars"))
1990
+ return SearchResponse(
1991
+ [Result(**to_snake_case(result)) for result in data["results"]],
1992
+ data["autopromptString"] if "autopromptString" in data else None,
1993
+ data["resolvedSearchType"] if "resolvedSearchType" in data else None,
1994
+ data["autoDate"] if "autoDate" in data else None,
1995
+ cost_dollars=cost_dollars,
1996
+ )
1997
+
1998
+ async def search_and_contents(self, query: str, **kwargs):
1999
+ options = {k: v for k, v in {"query": query, **kwargs}.items() if v is not None}
2000
+ # If user didn't ask for any particular content, default to text
2001
+ if (
2002
+ "text" not in options
2003
+ and "highlights" not in options
2004
+ and "summary" not in options
2005
+ and "extras" not in options
2006
+ ):
2007
+ options["text"] = True
2008
+
2009
+ validate_search_options(
2010
+ options,
2011
+ {
2012
+ **SEARCH_OPTIONS_TYPES,
2013
+ **CONTENTS_OPTIONS_TYPES,
2014
+ **CONTENTS_ENDPOINT_OPTIONS_TYPES,
2015
+ },
2016
+ )
2017
+
2018
+ # Nest the appropriate fields under "contents"
2019
+ options = nest_fields(
2020
+ options,
2021
+ [
2022
+ "text",
2023
+ "highlights",
2024
+ "summary",
2025
+ "subpages",
2026
+ "subpage_target",
2027
+ "livecrawl",
2028
+ "livecrawl_timeout",
2029
+ "extras",
2030
+ ],
2031
+ "contents",
2032
+ )
2033
+ options = to_camel_case(options)
2034
+ data = await self.async_request("/search", options)
2035
+ cost_dollars = parse_cost_dollars(data.get("costDollars"))
2036
+ return SearchResponse(
2037
+ [Result(**to_snake_case(result)) for result in data["results"]],
2038
+ data["autopromptString"] if "autopromptString" in data else None,
2039
+ data["resolvedSearchType"] if "resolvedSearchType" in data else None,
2040
+ data["autoDate"] if "autoDate" in data else None,
2041
+ cost_dollars=cost_dollars,
2042
+ )
2043
+
2044
+ async def get_contents(self, urls: Union[str, List[str], List[_Result]], **kwargs):
2045
+ options = {
2046
+ k: v
2047
+ for k, v in {"urls": urls, **kwargs}.items()
2048
+ if k != "self" and v is not None
2049
+ }
2050
+ if (
2051
+ "text" not in options
2052
+ and "highlights" not in options
2053
+ and "summary" not in options
2054
+ and "extras" not in options
2055
+ ):
2056
+ options["text"] = True
2057
+
2058
+ validate_search_options(
2059
+ options,
2060
+ {**CONTENTS_OPTIONS_TYPES, **CONTENTS_ENDPOINT_OPTIONS_TYPES},
2061
+ )
2062
+ options = to_camel_case(options)
2063
+ data = await self.async_request("/contents", options)
2064
+ cost_dollars = parse_cost_dollars(data.get("costDollars"))
2065
+ return SearchResponse(
2066
+ [Result(**to_snake_case(result)) for result in data["results"]],
2067
+ data.get("autopromptString"),
2068
+ data.get("resolvedSearchType"),
2069
+ data.get("autoDate"),
2070
+ cost_dollars=cost_dollars,
2071
+ )
2072
+
2073
+ async def find_similar(
2074
+ self,
2075
+ url: str,
2076
+ *,
2077
+ num_results: Optional[int] = None,
2078
+ include_domains: Optional[List[str]] = None,
2079
+ exclude_domains: Optional[List[str]] = None,
2080
+ start_crawl_date: Optional[str] = None,
2081
+ end_crawl_date: Optional[str] = None,
2082
+ start_published_date: Optional[str] = None,
2083
+ end_published_date: Optional[str] = None,
2084
+ include_text: Optional[List[str]] = None,
2085
+ exclude_text: Optional[List[str]] = None,
2086
+ exclude_source_domain: Optional[bool] = None,
2087
+ category: Optional[str] = None,
2088
+ flags: Optional[List[str]] = None,
2089
+ ) -> SearchResponse[_Result]:
2090
+ """Finds similar pages to a given URL, potentially with domain filters and date filters.
2091
+
2092
+ Args:
2093
+ url (str): The URL to find similar pages for.
2094
+ num_results (int, optional): Number of results to return. Default is None (server default).
2095
+ include_domains (List[str], optional): Domains to include in the search.
2096
+ exclude_domains (List[str], optional): Domains to exclude from the search.
2097
+ start_crawl_date (str, optional): Only links crawled after this date.
2098
+ end_crawl_date (str, optional): Only links crawled before this date.
2099
+ start_published_date (str, optional): Only links published after this date.
2100
+ end_published_date (str, optional): Only links published before this date.
2101
+ include_text (List[str], optional): Strings that must appear in the page text.
2102
+ exclude_text (List[str], optional): Strings that must not appear in the page text.
2103
+ exclude_source_domain (bool, optional): Whether to exclude the source domain.
2104
+ category (str, optional): A data category to focus on.
2105
+ flags (List[str], optional): Experimental flags.
2106
+
2107
+ Returns:
2108
+ SearchResponse[_Result]
2109
+ """
2110
+ options = {k: v for k, v in locals().items() if k != "self" and v is not None}
2111
+ validate_search_options(options, FIND_SIMILAR_OPTIONS_TYPES)
2112
+ options = to_camel_case(options)
2113
+ data = await self.async_request("/findSimilar", options)
2114
+ cost_dollars = parse_cost_dollars(data.get("costDollars"))
2115
+ return SearchResponse(
2116
+ [Result(**to_snake_case(result)) for result in data["results"]],
2117
+ data.get("autopromptString"),
2118
+ data.get("resolvedSearchType"),
2119
+ data.get("autoDate"),
2120
+ cost_dollars=cost_dollars,
2121
+ )
2122
+
2123
+ async def find_similar_and_contents(self, url: str, **kwargs):
2124
+ options = {k: v for k, v in {"url": url, **kwargs}.items() if v is not None}
2125
+ # Default to text if none specified
2126
+ if (
2127
+ "text" not in options
2128
+ and "highlights" not in options
2129
+ and "summary" not in options
2130
+ ):
2131
+ options["text"] = True
2132
+
2133
+ validate_search_options(
2134
+ options,
2135
+ {
2136
+ **FIND_SIMILAR_OPTIONS_TYPES,
2137
+ **CONTENTS_OPTIONS_TYPES,
2138
+ **CONTENTS_ENDPOINT_OPTIONS_TYPES,
2139
+ },
2140
+ )
2141
+ # We nest the content fields
2142
+ options = nest_fields(
2143
+ options,
2144
+ [
2145
+ "text",
2146
+ "highlights",
2147
+ "summary",
2148
+ "subpages",
2149
+ "subpage_target",
2150
+ "livecrawl",
2151
+ "livecrawl_timeout",
2152
+ "extras",
2153
+ ],
2154
+ "contents",
2155
+ )
2156
+ options = to_camel_case(options)
2157
+ data = await self.async_request("/findSimilar", options)
2158
+ cost_dollars = parse_cost_dollars(data.get("costDollars"))
2159
+ return SearchResponse(
2160
+ [Result(**to_snake_case(result)) for result in data["results"]],
2161
+ data.get("autopromptString"),
2162
+ data.get("resolvedSearchType"),
2163
+ data.get("autoDate"),
2164
+ cost_dollars=cost_dollars,
2165
+ )
2166
+
2167
+ async def answer(
2168
+ self,
2169
+ query: str,
2170
+ *,
2171
+ stream: Optional[bool] = False,
2172
+ text: Optional[bool] = False,
2173
+ system_prompt: Optional[str] = None,
2174
+ model: Optional[Literal["exa", "exa-pro"]] = None,
2175
+ ) -> Union[AnswerResponse, StreamAnswerResponse]:
2176
+ """Generate an answer to a query using Exa's search and LLM capabilities.
2177
+
2178
+ Args:
2179
+ query (str): The query to answer.
2180
+ text (bool, optional): Whether to include full text in the results. Defaults to False.
2181
+ system_prompt (str, optional): A system prompt to guide the LLM's behavior when generating the answer.
2182
+ model (str, optional): The model to use for answering. Either "exa" or "exa-pro". Defaults to None.
2183
+
2184
+ Returns:
2185
+ AnswerResponse: An object containing the answer and citations.
2186
+
2187
+ Raises:
2188
+ ValueError: If stream=True is provided. Use stream_answer() instead for streaming responses.
2189
+ """
2190
+ if stream:
2191
+ raise ValueError(
2192
+ "stream=True is not supported in `answer()`. "
2193
+ "Please use `stream_answer(...)` for streaming."
2194
+ )
2195
+
2196
+ options = {k: v for k, v in locals().items() if k != "self" and v is not None}
2197
+ options = to_camel_case(options)
2198
+ response = await self.async_request("/answer", options)
2199
+
2200
+ return AnswerResponse(
2201
+ response["answer"],
2202
+ [AnswerResult(**to_snake_case(result)) for result in response["citations"]],
2203
+ )
2204
+
2205
+ async def stream_answer(
2206
+ self,
2207
+ query: str,
2208
+ *,
2209
+ text: bool = False,
2210
+ system_prompt: Optional[str] = None,
2211
+ model: Optional[Literal["exa", "exa-pro"]] = None,
2212
+ ) -> AsyncStreamAnswerResponse:
2213
+ """Generate a streaming answer response.
2214
+
2215
+ Args:
2216
+ query (str): The query to answer.
2217
+ text (bool): Whether to include full text in the results. Defaults to False.
2218
+ system_prompt (str, optional): A system prompt to guide the LLM's behavior when generating the answer.
2219
+ model (str, optional): The model to use for answering. Either "exa" or "exa-pro". Defaults to None.
2220
+
2221
+ Returns:
2222
+ AsyncStreamAnswerResponse: An object that can be iterated over to retrieve (partial text, partial citations).
2223
+ Each iteration yields a tuple of (Optional[str], Optional[List[AnswerResult]]).
2224
+ """
2225
+ options = {k: v for k, v in locals().items() if k != "self" and v is not None}
2226
+ options = to_camel_case(options)
2227
+ options["stream"] = True
2228
+ raw_response = await self.async_request("/answer", options)
2229
+ return AsyncStreamAnswerResponse(raw_response)