exa-py 1.13.1__tar.gz → 1.14.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.

Files changed (28) hide show
  1. {exa_py-1.13.1 → exa_py-1.14.0}/PKG-INFO +39 -5
  2. {exa_py-1.13.1 → exa_py-1.14.0}/README.md +38 -4
  3. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/api.py +7 -96
  4. exa_py-1.14.0/exa_py/research/__init__.py +10 -0
  5. exa_py-1.14.0/exa_py/research/client.py +334 -0
  6. exa_py-1.14.0/exa_py/research/models.py +120 -0
  7. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/client.py +2 -1
  8. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/core/base.py +6 -2
  9. exa_py-1.14.0/exa_py/websets/streams/__init__.py +4 -0
  10. exa_py-1.14.0/exa_py/websets/streams/client.py +96 -0
  11. exa_py-1.14.0/exa_py/websets/streams/runs/__init__.py +3 -0
  12. exa_py-1.14.0/exa_py/websets/streams/runs/client.py +38 -0
  13. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/types.py +302 -49
  14. {exa_py-1.13.1 → exa_py-1.14.0}/pyproject.toml +4 -6
  15. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/__init__.py +0 -0
  16. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/py.typed +0 -0
  17. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/utils.py +0 -0
  18. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/__init__.py +0 -0
  19. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/_generator/pydantic/BaseModel.jinja2 +0 -0
  20. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/core/__init__.py +0 -0
  21. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/enrichments/__init__.py +0 -0
  22. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/enrichments/client.py +0 -0
  23. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/items/__init__.py +0 -0
  24. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/items/client.py +0 -0
  25. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/searches/__init__.py +0 -0
  26. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/searches/client.py +0 -0
  27. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/webhooks/__init__.py +0 -0
  28. {exa_py-1.13.1 → exa_py-1.14.0}/exa_py/websets/webhooks/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: exa-py
3
- Version: 1.13.1
3
+ Version: 1.14.0
4
4
  Summary: Python SDK for Exa API.
5
5
  License: MIT
6
6
  Author: Exa AI
@@ -45,6 +45,7 @@ exa = Exa(api_key="your-api-key")
45
45
  ```
46
46
 
47
47
  ## Common requests
48
+
48
49
  ```python
49
50
 
50
51
  # basic search
@@ -63,9 +64,9 @@ exa = Exa(api_key="your-api-key")
63
64
  results = exa.search_and_contents("This is a Exa query:")
64
65
 
65
66
  # search and get contents with contents options
66
- results = exa.search_and_contents("This is a Exa query:",
67
+ results = exa.search_and_contents("This is a Exa query:",
67
68
  text={"include_html_tags": True, "max_characters": 1000})
68
-
69
+
69
70
  # find similar documents
70
71
  results = exa.find_similar("https://example.com")
71
72
 
@@ -79,7 +80,7 @@ exa = Exa(api_key="your-api-key")
79
80
  results = exa.get_contents(["tesla.com"])
80
81
 
81
82
  # get contents with contents options
82
- results = exa.get_contents(["urls"],
83
+ results = exa.get_contents(["urls"],
83
84
  text={"include_html_tags": True, "max_characters": 1000})
84
85
 
85
86
  # basic answer
@@ -95,6 +96,39 @@ exa = Exa(api_key="your-api-key")
95
96
  for chunk in response:
96
97
  print(chunk, end='', flush=True)
97
98
 
99
+ # research task example – answer a question with citations
100
+ # Example prompt & schema inspired by the TypeScript example.
101
+ QUESTION = (
102
+ "Summarize the history of San Francisco highlighting one or two major events "
103
+ "for each decade from 1850 to 1950"
104
+ )
105
+ OUTPUT_SCHEMA: Dict[str, Any] = {
106
+ "type": "object",
107
+ "required": ["timeline"],
108
+ "properties": {
109
+ "timeline": {
110
+ "type": "array",
111
+ "items": {
112
+ "type": "object",
113
+ "required": ["decade", "notableEvents"],
114
+ "properties": {
115
+ "decade": {
116
+ "type": "string",
117
+ "description": 'Decade label e.g. "1850s"',
118
+ },
119
+ "notableEvents": {
120
+ "type": "string",
121
+ "description": "A summary of notable events.",
122
+ },
123
+ },
124
+ },
125
+ },
126
+ },
127
+ }
128
+ resp = exa.research.create_task(
129
+ instructions=QUESTION,
130
+ model="exa-research",
131
+ output_schema=OUTPUT_SCHEMA,
132
+ )
98
133
  ```
99
134
 
100
-
@@ -22,6 +22,7 @@ exa = Exa(api_key="your-api-key")
22
22
  ```
23
23
 
24
24
  ## Common requests
25
+
25
26
  ```python
26
27
 
27
28
  # basic search
@@ -40,9 +41,9 @@ exa = Exa(api_key="your-api-key")
40
41
  results = exa.search_and_contents("This is a Exa query:")
41
42
 
42
43
  # search and get contents with contents options
43
- results = exa.search_and_contents("This is a Exa query:",
44
+ results = exa.search_and_contents("This is a Exa query:",
44
45
  text={"include_html_tags": True, "max_characters": 1000})
45
-
46
+
46
47
  # find similar documents
47
48
  results = exa.find_similar("https://example.com")
48
49
 
@@ -56,7 +57,7 @@ exa = Exa(api_key="your-api-key")
56
57
  results = exa.get_contents(["tesla.com"])
57
58
 
58
59
  # get contents with contents options
59
- results = exa.get_contents(["urls"],
60
+ results = exa.get_contents(["urls"],
60
61
  text={"include_html_tags": True, "max_characters": 1000})
61
62
 
62
63
  # basic answer
@@ -72,5 +73,38 @@ exa = Exa(api_key="your-api-key")
72
73
  for chunk in response:
73
74
  print(chunk, end='', flush=True)
74
75
 
76
+ # research task example – answer a question with citations
77
+ # Example prompt & schema inspired by the TypeScript example.
78
+ QUESTION = (
79
+ "Summarize the history of San Francisco highlighting one or two major events "
80
+ "for each decade from 1850 to 1950"
81
+ )
82
+ OUTPUT_SCHEMA: Dict[str, Any] = {
83
+ "type": "object",
84
+ "required": ["timeline"],
85
+ "properties": {
86
+ "timeline": {
87
+ "type": "array",
88
+ "items": {
89
+ "type": "object",
90
+ "required": ["decade", "notableEvents"],
91
+ "properties": {
92
+ "decade": {
93
+ "type": "string",
94
+ "description": 'Decade label e.g. "1850s"',
95
+ },
96
+ "notableEvents": {
97
+ "type": "string",
98
+ "description": "A summary of notable events.",
99
+ },
100
+ },
101
+ },
102
+ },
103
+ },
104
+ }
105
+ resp = exa.research.create_task(
106
+ instructions=QUESTION,
107
+ model="exa-research",
108
+ output_schema=OUTPUT_SCHEMA,
109
+ )
75
110
  ```
76
-
@@ -38,6 +38,7 @@ from exa_py.utils import (
38
38
  )
39
39
  from .websets import WebsetsClient
40
40
  from .websets.core.base import ExaJSONEncoder
41
+ from .research.client import ResearchClient, AsyncResearchClient
41
42
 
42
43
  is_beta = os.getenv("IS_BETA") == "True"
43
44
 
@@ -837,37 +838,6 @@ def nest_fields(original_dict: Dict, fields_to_nest: List[str], new_key: str):
837
838
  return original_dict
838
839
 
839
840
 
840
- @dataclass
841
- class ResearchTaskResponse:
842
- """A class representing the response for a research task.
843
-
844
- Attributes:
845
- id (str): The unique identifier for the research request.
846
- status (str): Status of the research request.
847
- output (Optional[Dict[str, Any]]): The answer structured as JSON, if available.
848
- citations (Optional[Dict[str, List[_Result]]]): List of citations used to generate the answer, grouped by root field in the output schema.
849
- """
850
-
851
- id: str
852
- status: str
853
- output: Optional[Dict[str, Any]]
854
- citations: Dict[str, List[_Result]]
855
-
856
- def __str__(self):
857
- output_repr = (
858
- json.dumps(self.output, indent=2, ensure_ascii=False)
859
- if self.output is not None
860
- else "None"
861
- )
862
- citations_str = "\n\n".join(str(src) for src in self.citations)
863
- return (
864
- f"ID: {self.id}\n"
865
- f"Status: {self.status}\n"
866
- f"Output: {output_repr}\n\n"
867
- f"Citations:\n{citations_str}"
868
- )
869
-
870
-
871
841
  class Exa:
872
842
  """A client for interacting with Exa API."""
873
843
 
@@ -875,7 +845,7 @@ class Exa:
875
845
  self,
876
846
  api_key: Optional[str],
877
847
  base_url: str = "https://api.exa.ai",
878
- user_agent: str = "exa-py 1.12.1",
848
+ user_agent: str = "exa-py 1.12.4",
879
849
  ):
880
850
  """Initialize the Exa client with the provided API key and optional base URL and user agent.
881
851
 
@@ -898,6 +868,8 @@ class Exa:
898
868
  "Content-Type": "application/json",
899
869
  }
900
870
  self.websets = WebsetsClient(self)
871
+ # Research tasks client (new, mirrors Websets design)
872
+ self.research = ResearchClient(self)
901
873
 
902
874
  def request(
903
875
  self,
@@ -1952,40 +1924,12 @@ class Exa:
1952
1924
  raw_response = self.request("/answer", options)
1953
1925
  return StreamAnswerResponse(raw_response)
1954
1926
 
1955
- def researchTask(
1956
- self,
1957
- *,
1958
- input_instructions: str,
1959
- output_schema: Dict[str, Any],
1960
- ) -> ResearchTaskResponse:
1961
- """Submit a research request to Exa.
1962
-
1963
- Args:
1964
- input_instructions (str): The instructions for the research task.
1965
- output_schema (Dict[str, Any]): JSON schema describing the desired answer structure.
1966
- """
1967
- # Build the request payload expected by the Exa API
1968
- options = {
1969
- "input": {"instructions": input_instructions},
1970
- "output": {"schema": output_schema},
1971
- }
1972
-
1973
- response = self.request("/research/tasks", options)
1974
-
1975
- return ResearchTaskResponse(
1976
- id=response["id"],
1977
- status=response["status"],
1978
- output=response.get("output"),
1979
- citations={
1980
- key: [_Result(**to_snake_case(citation)) for citation in citations_list]
1981
- for key, citations_list in response.get("citations", {}).items()
1982
- },
1983
- )
1984
-
1985
1927
 
1986
1928
  class AsyncExa(Exa):
1987
1929
  def __init__(self, api_key: str, api_base: str = "https://api.exa.ai"):
1988
1930
  super().__init__(api_key, api_base)
1931
+ # Override the synchronous ResearchClient with its async counterpart.
1932
+ self.research = AsyncResearchClient(self)
1989
1933
  self._client = None
1990
1934
 
1991
1935
  @property
@@ -2021,7 +1965,7 @@ class AsyncExa(Exa):
2021
1965
  res = await self.client.post(
2022
1966
  self.base_url + endpoint, json=data, headers=self.headers
2023
1967
  )
2024
- if res.status_code != 200:
1968
+ if res.status_code != 200 and res.status_code != 201:
2025
1969
  raise ValueError(
2026
1970
  f"Request failed with status code {res.status_code}: {res.text}"
2027
1971
  )
@@ -2316,36 +2260,3 @@ class AsyncExa(Exa):
2316
2260
  options["stream"] = True
2317
2261
  raw_response = await self.async_request("/answer", options)
2318
2262
  return AsyncStreamAnswerResponse(raw_response)
2319
-
2320
- async def researchTask(
2321
- self,
2322
- *,
2323
- input_instructions: str,
2324
- output_schema: Dict[str, Any],
2325
- ) -> ResearchTaskResponse:
2326
- """Asynchronously submit a research request to Exa.
2327
-
2328
- Args:
2329
- input_instructions (str): The instructions for the research task.
2330
- output_schema (Dict[str, Any]): JSON schema describing the desired answer structure.
2331
-
2332
- Returns:
2333
- ResearchTaskResponse: The parsed response from the Exa API.
2334
- """
2335
- # Build the request payload expected by the Exa API
2336
- options = {
2337
- "input": {"instructions": input_instructions},
2338
- "output": {"schema": output_schema},
2339
- }
2340
-
2341
- response = await self.async_request("/research/tasks", options)
2342
-
2343
- return ResearchTaskResponse(
2344
- id=response["id"],
2345
- status=response["status"],
2346
- output=response.get("output"),
2347
- citations={
2348
- key: [_Result(**to_snake_case(citation)) for citation in citations_list]
2349
- for key, citations_list in response.get("citations", {}).items()
2350
- },
2351
- )
@@ -0,0 +1,10 @@
1
+ from .client import ResearchClient, AsyncResearchClient
2
+ from .models import ResearchTask, ListResearchTasksResponse, ResearchTaskId
3
+
4
+ __all__ = [
5
+ "ResearchClient",
6
+ "AsyncResearchClient",
7
+ "ResearchTaskId",
8
+ "ResearchTask",
9
+ "ListResearchTasksResponse",
10
+ ]
@@ -0,0 +1,334 @@
1
+ """Lightweight research client wrappers for the Exa REST API.
2
+
3
+ This module purposefully keeps its import surface minimal to avoid circular
4
+ import problems with :pymod:`exa_py.api`. Any heavy dependencies (including
5
+ `exa_py.api` itself) are imported lazily **inside** functions. This means
6
+ that type-checkers still see the full, precise types via the ``TYPE_CHECKING``
7
+ block, but at runtime we only pay the cost if/when a helper is actually used.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any, Dict, Optional
13
+
14
+ if TYPE_CHECKING: # pragma: no cover – only for static analysers
15
+ # Import with full type info when static type-checking. `_Result` still
16
+ # lives in ``exa_py.api`` but the response model moved to
17
+ # ``exa_py.research.models``.
18
+ from ..api import _Result # noqa: F401
19
+ from .models import (
20
+ ResearchTask,
21
+ ResearchTaskId,
22
+ ListResearchTasksResponse,
23
+ ) # noqa: F401
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Public, user-facing clients
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ class ResearchClient:
31
+ """Synchronous helper namespace accessed via :pyattr:`Exa.research`."""
32
+
33
+ def __init__(self, parent_client):
34
+ # A reference to the *already-constructed* ``Exa`` instance so that we
35
+ # can piggy-back on its HTTP plumbing (headers, base URL, retries, …).
36
+ self._client = parent_client
37
+
38
+ def create_task(
39
+ self,
40
+ *,
41
+ instructions: str,
42
+ model: str = "exa-research",
43
+ output_schema: Dict[str, Any],
44
+ ) -> "ResearchTaskId":
45
+ """Submit a research request and return the *task identifier*."""
46
+ payload = {
47
+ "instructions": instructions,
48
+ "model": model,
49
+ "output": {"schema": output_schema},
50
+ }
51
+
52
+ raw_response: Dict[str, Any] = self._client.request(
53
+ "/research/v0/tasks", payload
54
+ )
55
+
56
+ # Defensive checks so that we fail loudly if the contract changes.
57
+ if not isinstance(raw_response, dict) or "id" not in raw_response:
58
+ raise RuntimeError(
59
+ f"Unexpected response while creating research task: {raw_response}"
60
+ )
61
+
62
+ # Lazily import to avoid circular deps at runtime.
63
+ from .models import ResearchTaskId # noqa: WPS433 – runtime import
64
+
65
+ return ResearchTaskId(id=raw_response["id"])
66
+
67
+ def get_task(
68
+ self, id: str
69
+ ) -> "ResearchTask": # noqa: D401 – imperative mood is fine
70
+ """Fetch the current status / result for a research task."""
71
+ endpoint = f"/research/v0/tasks/{id}"
72
+
73
+ # The new endpoint is a simple GET.
74
+ raw_response: Dict[str, Any] = self._client.request(endpoint, method="GET")
75
+
76
+ return _build_research_task(raw_response)
77
+
78
+ # ------------------------------------------------------------------
79
+ # Convenience helpers
80
+ # ------------------------------------------------------------------
81
+
82
+ def poll_task(
83
+ self,
84
+ id: str,
85
+ *,
86
+ poll_interval: float = 1.0,
87
+ timeout_seconds: int = 15 * 60,
88
+ ) -> "ResearchTask":
89
+ """Blocking helper that polls until task completes or fails.
90
+
91
+ Parameters
92
+ ----------
93
+ id:
94
+ The ID of the research task to poll.
95
+ poll_interval:
96
+ Seconds to wait between successive polls (default 1s).
97
+ timeout_seconds:
98
+ Maximum time to wait before raising :class:`TimeoutError` (default 15 min).
99
+ """
100
+
101
+ import time
102
+
103
+ deadline = time.monotonic() + timeout_seconds
104
+
105
+ while True:
106
+ task = self.get_task(id)
107
+ status = task.status.lower() if isinstance(task.status, str) else ""
108
+
109
+ if status in {"completed", "failed", "complete", "finished", "done"}:
110
+ return task
111
+
112
+ if time.monotonic() > deadline:
113
+ raise TimeoutError(
114
+ f"Research task {id} did not finish within {timeout_seconds} seconds"
115
+ )
116
+
117
+ time.sleep(poll_interval)
118
+
119
+ # ------------------------------------------------------------------
120
+ # Listing helpers
121
+ # ------------------------------------------------------------------
122
+
123
+ def list(
124
+ self,
125
+ *,
126
+ cursor: Optional[str] = None,
127
+ limit: Optional[int] = None,
128
+ ) -> "ListResearchTasksResponse":
129
+ """List research tasks with optional pagination.
130
+
131
+ Parameters
132
+ ----------
133
+ cursor:
134
+ Pagination cursor returned by a previous call (optional).
135
+ limit:
136
+ Maximum number of tasks to return (optional).
137
+ """
138
+
139
+ params = {
140
+ k: v for k, v in {"cursor": cursor, "limit": limit}.items() if v is not None
141
+ }
142
+
143
+ raw_response: Dict[str, Any] = self._client.request(
144
+ "/research/v0/tasks",
145
+ data=None,
146
+ method="GET",
147
+ params=params or None,
148
+ )
149
+
150
+ # Defensive checks so that we fail loudly if the contract changes.
151
+ if not isinstance(raw_response, dict) or "data" not in raw_response:
152
+ raise RuntimeError(
153
+ f"Unexpected response while listing research tasks: {raw_response}"
154
+ )
155
+
156
+ tasks = [_build_research_task(t) for t in raw_response.get("data", [])]
157
+
158
+ # Lazy import to avoid cycles.
159
+ from .models import ListResearchTasksResponse # noqa: WPS433 – runtime import
160
+
161
+ return ListResearchTasksResponse(
162
+ data=tasks,
163
+ has_more=raw_response.get("hasMore", False),
164
+ next_cursor=raw_response.get("nextCursor"),
165
+ )
166
+
167
+
168
+ class AsyncResearchClient:
169
+ """Async counterpart used via :pyattr:`AsyncExa.research`."""
170
+
171
+ def __init__(self, parent_client):
172
+ self._client = parent_client
173
+
174
+ async def create_task(
175
+ self,
176
+ *,
177
+ instructions: str,
178
+ model: str = "exa-research",
179
+ output_schema: Dict[str, Any],
180
+ ) -> "ResearchTaskId":
181
+ """Submit a research request and return the *task identifier* (async)."""
182
+
183
+ payload = {
184
+ "instructions": instructions,
185
+ "model": model,
186
+ "output": {"schema": output_schema},
187
+ }
188
+
189
+ raw_response: Dict[str, Any] = await self._client.async_request(
190
+ "/research/v0/tasks", payload
191
+ )
192
+
193
+ # Defensive checks so that we fail loudly if the contract changes.
194
+ if not isinstance(raw_response, dict) or "id" not in raw_response:
195
+ raise RuntimeError(
196
+ f"Unexpected response while creating research task: {raw_response}"
197
+ )
198
+
199
+ # Lazily import to avoid circular deps at runtime.
200
+ from .models import ResearchTaskId # noqa: WPS433 – runtime import
201
+
202
+ return ResearchTaskId(id=raw_response["id"])
203
+
204
+ async def get_task(self, id: str) -> "ResearchTask": # noqa: D401
205
+ """Fetch the current status / result for a research task (async)."""
206
+
207
+ endpoint = f"/research/v0/tasks/{id}"
208
+
209
+ # Perform GET using the underlying HTTP client because `async_request`
210
+ # only supports POST semantics.
211
+ resp = await self._client.client.get(
212
+ self._client.base_url + endpoint, headers=self._client.headers
213
+ )
214
+
215
+ if resp.status_code >= 400:
216
+ raise RuntimeError(
217
+ f"Request failed with status code {resp.status_code}: {resp.text}"
218
+ )
219
+
220
+ raw_response: Dict[str, Any] = resp.json()
221
+
222
+ return _build_research_task(raw_response)
223
+
224
+ # ------------------------------------------------------------------
225
+ # Convenience helpers
226
+ # ------------------------------------------------------------------
227
+
228
+ async def poll_task(
229
+ self,
230
+ id: str,
231
+ *,
232
+ poll_interval: float = 1.0,
233
+ timeout_seconds: int = 15 * 60,
234
+ ) -> "ResearchTask":
235
+ """Async helper that polls until task completes or fails.
236
+
237
+ Mirrors :py:meth:`ResearchClient.poll_task` but uses ``await`` and
238
+ :pyfunc:`asyncio.sleep`. Raises :class:`TimeoutError` on timeout.
239
+ """
240
+
241
+ import asyncio
242
+ import time
243
+
244
+ deadline = time.monotonic() + timeout_seconds
245
+
246
+ while True:
247
+ task = await self.get_task(id)
248
+ status = task.status.lower() if isinstance(task.status, str) else ""
249
+
250
+ if status in {"completed", "failed", "complete", "finished", "done"}:
251
+ return task
252
+
253
+ if time.monotonic() > deadline:
254
+ raise TimeoutError(
255
+ f"Research task {id} did not finish within {timeout_seconds} seconds"
256
+ )
257
+
258
+ await asyncio.sleep(poll_interval)
259
+
260
+ # ------------------------------------------------------------------
261
+ # Listing helpers
262
+ # ------------------------------------------------------------------
263
+
264
+ async def list(
265
+ self,
266
+ *,
267
+ cursor: Optional[str] = None,
268
+ limit: Optional[int] = None,
269
+ ) -> "ListResearchTasksResponse":
270
+ """Async list of research tasks with optional pagination."""
271
+
272
+ params = {
273
+ k: v for k, v in {"cursor": cursor, "limit": limit}.items() if v is not None
274
+ }
275
+
276
+ resp = await self._client.client.get(
277
+ self._client.base_url + "/research/v0/tasks",
278
+ headers=self._client.headers,
279
+ params=params or None,
280
+ )
281
+
282
+ if resp.status_code >= 400:
283
+ raise RuntimeError(
284
+ f"Request failed with status code {resp.status_code}: {resp.text}"
285
+ )
286
+
287
+ raw_response: Dict[str, Any] = resp.json()
288
+
289
+ if not isinstance(raw_response, dict) or "data" not in raw_response:
290
+ raise RuntimeError(
291
+ f"Unexpected response while listing research tasks: {raw_response}"
292
+ )
293
+
294
+ tasks = [_build_research_task(t) for t in raw_response.get("data", [])]
295
+
296
+ from .models import ListResearchTasksResponse # noqa: WPS433 – runtime import
297
+
298
+ return ListResearchTasksResponse(
299
+ data=tasks,
300
+ has_more=raw_response.get("hasMore", False),
301
+ next_cursor=raw_response.get("nextCursor"),
302
+ )
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Internal helpers (lazy imports to avoid cycles)
307
+ # ---------------------------------------------------------------------------
308
+
309
+
310
+ def _build_research_task(raw: Dict[str, Any]):
311
+ """Convert raw API response into a :class:`ResearchTask` instance."""
312
+
313
+ # Defensive check – fail loudly if the API contract changes.
314
+ if not isinstance(raw, dict) or "id" not in raw:
315
+ raise RuntimeError(f"Unexpected response while fetching research task: {raw}")
316
+
317
+ # Lazily import heavy deps to avoid cycles and unnecessary startup cost.
318
+ from .models import ResearchTask # noqa: WPS433 – runtime import
319
+ from ..api import _Result, to_snake_case # noqa: WPS433 – runtime import
320
+
321
+ citations_raw = raw.get("citations", {}) or {}
322
+ citations_parsed = {
323
+ key: [_Result(**to_snake_case(c)) for c in cites]
324
+ for key, cites in citations_raw.items()
325
+ }
326
+
327
+ return ResearchTask(
328
+ id=raw["id"],
329
+ status=raw["status"],
330
+ instructions=raw.get("instructions", ""),
331
+ schema=raw.get("schema", {}),
332
+ data=raw.get("data"),
333
+ citations=citations_parsed,
334
+ )