exa-py 1.14.19__py3-none-any.whl → 1.15.0__py3-none-any.whl
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.
- exa_py/api.py +78 -31
- exa_py/research/__init__.py +34 -5
- exa_py/research/async_client.py +310 -0
- exa_py/research/base.py +165 -0
- exa_py/research/models.py +314 -113
- exa_py/research/sync_client.py +308 -0
- exa_py/research/utils.py +222 -0
- exa_py/utils.py +1 -4
- {exa_py-1.14.19.dist-info → exa_py-1.15.0.dist-info}/METADATA +1 -1
- {exa_py-1.14.19.dist-info → exa_py-1.15.0.dist-info}/RECORD +11 -8
- exa_py/research/client.py +0 -358
- {exa_py-1.14.19.dist-info → exa_py-1.15.0.dist-info}/WHEEL +0 -0
exa_py/research/client.py
DELETED
|
@@ -1,358 +0,0 @@
|
|
|
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, Literal
|
|
13
|
-
|
|
14
|
-
from exa_py.utils import JSONSchemaInput
|
|
15
|
-
from ..api import _convert_schema_input
|
|
16
|
-
|
|
17
|
-
if TYPE_CHECKING: # pragma: no cover – only for static analysers
|
|
18
|
-
# Import with full type info when static type-checking. `_Result` still
|
|
19
|
-
# lives in ``exa_py.api`` but the response model moved to
|
|
20
|
-
# ``exa_py.research.models``.
|
|
21
|
-
from .models import (
|
|
22
|
-
ResearchTask,
|
|
23
|
-
ResearchTaskId,
|
|
24
|
-
ListResearchTasksResponse,
|
|
25
|
-
) # noqa: F401
|
|
26
|
-
|
|
27
|
-
# ---------------------------------------------------------------------------
|
|
28
|
-
# Public, user-facing clients
|
|
29
|
-
# ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class ResearchClient:
|
|
33
|
-
"""Synchronous helper namespace accessed via :pyattr:`Exa.research`."""
|
|
34
|
-
|
|
35
|
-
def __init__(self, parent_client):
|
|
36
|
-
# A reference to the *already-constructed* ``Exa`` instance so that we
|
|
37
|
-
# can piggy-back on its HTTP plumbing (headers, base URL, retries, …).
|
|
38
|
-
self._client = parent_client
|
|
39
|
-
|
|
40
|
-
def create_task(
|
|
41
|
-
self,
|
|
42
|
-
*,
|
|
43
|
-
instructions: str,
|
|
44
|
-
model: Literal["exa-research", "exa-research-pro"] = "exa-research",
|
|
45
|
-
output_infer_schema: bool = None,
|
|
46
|
-
output_schema: "Optional[JSONSchemaInput]" = None,
|
|
47
|
-
) -> "ResearchTaskId":
|
|
48
|
-
"""Submit a research request and return the *task identifier*."""
|
|
49
|
-
payload = {"instructions": instructions}
|
|
50
|
-
if model is not None:
|
|
51
|
-
payload["model"] = model
|
|
52
|
-
if output_schema is not None or output_infer_schema is not None:
|
|
53
|
-
payload["output"] = {}
|
|
54
|
-
if output_schema is not None:
|
|
55
|
-
payload["output"]["schema"] = _convert_schema_input(output_schema)
|
|
56
|
-
if output_infer_schema is not None:
|
|
57
|
-
payload["output"]["inferSchema"] = output_infer_schema
|
|
58
|
-
|
|
59
|
-
raw_response: Dict[str, Any] = self._client.request(
|
|
60
|
-
"/research/v0/tasks", payload
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
# Defensive checks so that we fail loudly if the contract changes.
|
|
64
|
-
if not isinstance(raw_response, dict) or "id" not in raw_response:
|
|
65
|
-
raise RuntimeError(
|
|
66
|
-
f"Unexpected response while creating research task: {raw_response}"
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
# Lazily import to avoid circular deps at runtime.
|
|
70
|
-
from .models import ResearchTaskId # noqa: WPS433 – runtime import
|
|
71
|
-
|
|
72
|
-
return ResearchTaskId(id=raw_response["id"])
|
|
73
|
-
|
|
74
|
-
def get_task(self, id: str) -> "ResearchTask": # noqa: D401 – imperative mood is fine
|
|
75
|
-
"""Fetch the current status / result for a research task."""
|
|
76
|
-
endpoint = f"/research/v0/tasks/{id}"
|
|
77
|
-
|
|
78
|
-
# The new endpoint is a simple GET.
|
|
79
|
-
raw_response: Dict[str, Any] = self._client.request(endpoint, method="GET")
|
|
80
|
-
|
|
81
|
-
return _build_research_task(raw_response)
|
|
82
|
-
|
|
83
|
-
# ------------------------------------------------------------------
|
|
84
|
-
# Convenience helpers
|
|
85
|
-
# ------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
def poll_task(
|
|
88
|
-
self,
|
|
89
|
-
id: str,
|
|
90
|
-
*,
|
|
91
|
-
poll_interval: float = 1.0,
|
|
92
|
-
timeout_seconds: int = 15 * 60,
|
|
93
|
-
) -> "ResearchTask":
|
|
94
|
-
"""Blocking helper that polls until task completes or fails.
|
|
95
|
-
|
|
96
|
-
Parameters
|
|
97
|
-
----------
|
|
98
|
-
id:
|
|
99
|
-
The ID of the research task to poll.
|
|
100
|
-
poll_interval:
|
|
101
|
-
Seconds to wait between successive polls (default 1s).
|
|
102
|
-
timeout_seconds:
|
|
103
|
-
Maximum time to wait before raising :class:`TimeoutError` (default 15 min).
|
|
104
|
-
"""
|
|
105
|
-
|
|
106
|
-
import time
|
|
107
|
-
|
|
108
|
-
deadline = time.monotonic() + timeout_seconds
|
|
109
|
-
|
|
110
|
-
while True:
|
|
111
|
-
task = self.get_task(id)
|
|
112
|
-
status = task.status.lower() if isinstance(task.status, str) else ""
|
|
113
|
-
|
|
114
|
-
if status in {"completed", "failed", "complete", "finished", "done"}:
|
|
115
|
-
return task
|
|
116
|
-
|
|
117
|
-
if time.monotonic() > deadline:
|
|
118
|
-
raise TimeoutError(
|
|
119
|
-
f"Research task {id} did not finish within {timeout_seconds} seconds"
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
time.sleep(poll_interval)
|
|
123
|
-
|
|
124
|
-
# ------------------------------------------------------------------
|
|
125
|
-
# Listing helpers
|
|
126
|
-
# ------------------------------------------------------------------
|
|
127
|
-
|
|
128
|
-
def list(
|
|
129
|
-
self,
|
|
130
|
-
*,
|
|
131
|
-
cursor: Optional[str] = None,
|
|
132
|
-
limit: Optional[int] = None,
|
|
133
|
-
) -> "ListResearchTasksResponse":
|
|
134
|
-
"""List research tasks with optional pagination.
|
|
135
|
-
|
|
136
|
-
Parameters
|
|
137
|
-
----------
|
|
138
|
-
cursor:
|
|
139
|
-
Pagination cursor returned by a previous call (optional).
|
|
140
|
-
limit:
|
|
141
|
-
Maximum number of tasks to return (optional).
|
|
142
|
-
"""
|
|
143
|
-
|
|
144
|
-
params = {
|
|
145
|
-
k: v for k, v in {"cursor": cursor, "limit": limit}.items() if v is not None
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
raw_response: Dict[str, Any] = self._client.request(
|
|
149
|
-
"/research/v0/tasks",
|
|
150
|
-
data=None,
|
|
151
|
-
method="GET",
|
|
152
|
-
params=params or None,
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
# Defensive checks so that we fail loudly if the contract changes.
|
|
156
|
-
if not isinstance(raw_response, dict) or "data" not in raw_response:
|
|
157
|
-
raise RuntimeError(
|
|
158
|
-
f"Unexpected response while listing research tasks: {raw_response}"
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
tasks = [_build_research_task(t) for t in raw_response.get("data", [])]
|
|
162
|
-
|
|
163
|
-
# Lazy import to avoid cycles.
|
|
164
|
-
from .models import ListResearchTasksResponse # noqa: WPS433 – runtime import
|
|
165
|
-
|
|
166
|
-
return ListResearchTasksResponse(
|
|
167
|
-
data=tasks,
|
|
168
|
-
has_more=raw_response.get("hasMore", False),
|
|
169
|
-
next_cursor=raw_response.get("nextCursor"),
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
class AsyncResearchClient:
|
|
174
|
-
"""Async counterpart used via :pyattr:`AsyncExa.research`."""
|
|
175
|
-
|
|
176
|
-
def __init__(self, parent_client):
|
|
177
|
-
self._client = parent_client
|
|
178
|
-
|
|
179
|
-
async def create_task(
|
|
180
|
-
self,
|
|
181
|
-
*,
|
|
182
|
-
instructions: str,
|
|
183
|
-
model: Literal["exa-research", "exa-research-pro"] = "exa-research",
|
|
184
|
-
output_schema: "JSONSchemaInput",
|
|
185
|
-
) -> "ResearchTaskId":
|
|
186
|
-
"""Submit a research request and return the *task identifier* (async)."""
|
|
187
|
-
|
|
188
|
-
# Convert schema using the same conversion logic as main API
|
|
189
|
-
from ..api import _convert_schema_input # noqa: WPS433 – runtime import
|
|
190
|
-
|
|
191
|
-
payload = {
|
|
192
|
-
"instructions": instructions,
|
|
193
|
-
"model": model,
|
|
194
|
-
"output": {"schema": _convert_schema_input(output_schema)},
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
raw_response: Dict[str, Any] = await self._client.async_request(
|
|
198
|
-
"/research/v0/tasks", payload
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
# Defensive checks so that we fail loudly if the contract changes.
|
|
202
|
-
if not isinstance(raw_response, dict) or "id" not in raw_response:
|
|
203
|
-
raise RuntimeError(
|
|
204
|
-
f"Unexpected response while creating research task: {raw_response}"
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
# Lazily import to avoid circular deps at runtime.
|
|
208
|
-
from .models import ResearchTaskId # noqa: WPS433 – runtime import
|
|
209
|
-
|
|
210
|
-
return ResearchTaskId(id=raw_response["id"])
|
|
211
|
-
|
|
212
|
-
async def get_task(self, id: str) -> "ResearchTask": # noqa: D401
|
|
213
|
-
"""Fetch the current status / result for a research task (async)."""
|
|
214
|
-
|
|
215
|
-
endpoint = f"/research/v0/tasks/{id}"
|
|
216
|
-
|
|
217
|
-
# Perform GET using the underlying HTTP client because `async_request`
|
|
218
|
-
# only supports POST semantics.
|
|
219
|
-
resp = await self._client.client.get(
|
|
220
|
-
self._client.base_url + endpoint, headers=self._client.headers
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
if resp.status_code >= 400:
|
|
224
|
-
raise RuntimeError(
|
|
225
|
-
f"Request failed with status code {resp.status_code}: {resp.text}"
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
raw_response: Dict[str, Any] = resp.json()
|
|
229
|
-
|
|
230
|
-
return _build_research_task(raw_response)
|
|
231
|
-
|
|
232
|
-
# ------------------------------------------------------------------
|
|
233
|
-
# Convenience helpers
|
|
234
|
-
# ------------------------------------------------------------------
|
|
235
|
-
|
|
236
|
-
async def poll_task(
|
|
237
|
-
self,
|
|
238
|
-
id: str,
|
|
239
|
-
*,
|
|
240
|
-
poll_interval: float = 1.0,
|
|
241
|
-
timeout_seconds: int = 15 * 60,
|
|
242
|
-
) -> "ResearchTask":
|
|
243
|
-
"""Async helper that polls until task completes or fails.
|
|
244
|
-
|
|
245
|
-
Mirrors :py:meth:`ResearchClient.poll_task` but uses ``await`` and
|
|
246
|
-
:pyfunc:`asyncio.sleep`. Raises :class:`TimeoutError` on timeout.
|
|
247
|
-
"""
|
|
248
|
-
|
|
249
|
-
import asyncio
|
|
250
|
-
import time
|
|
251
|
-
|
|
252
|
-
deadline = time.monotonic() + timeout_seconds
|
|
253
|
-
|
|
254
|
-
while True:
|
|
255
|
-
task = await self.get_task(id)
|
|
256
|
-
status = task.status.lower() if isinstance(task.status, str) else ""
|
|
257
|
-
|
|
258
|
-
if status in {"completed", "failed", "complete", "finished", "done"}:
|
|
259
|
-
return task
|
|
260
|
-
|
|
261
|
-
if time.monotonic() > deadline:
|
|
262
|
-
raise TimeoutError(
|
|
263
|
-
f"Research task {id} did not finish within {timeout_seconds} seconds"
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
await asyncio.sleep(poll_interval)
|
|
267
|
-
|
|
268
|
-
# ------------------------------------------------------------------
|
|
269
|
-
# Listing helpers
|
|
270
|
-
# ------------------------------------------------------------------
|
|
271
|
-
|
|
272
|
-
async def list(
|
|
273
|
-
self,
|
|
274
|
-
*,
|
|
275
|
-
cursor: Optional[str] = None,
|
|
276
|
-
limit: Optional[int] = None,
|
|
277
|
-
) -> "ListResearchTasksResponse":
|
|
278
|
-
"""Async list of research tasks with optional pagination."""
|
|
279
|
-
|
|
280
|
-
params = {
|
|
281
|
-
k: v for k, v in {"cursor": cursor, "limit": limit}.items() if v is not None
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
resp = await self._client.client.get(
|
|
285
|
-
self._client.base_url + "/research/v0/tasks",
|
|
286
|
-
headers=self._client.headers,
|
|
287
|
-
params=params or None,
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
if resp.status_code >= 400:
|
|
291
|
-
raise RuntimeError(
|
|
292
|
-
f"Request failed with status code {resp.status_code}: {resp.text}"
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
raw_response: Dict[str, Any] = resp.json()
|
|
296
|
-
|
|
297
|
-
if not isinstance(raw_response, dict) or "data" not in raw_response:
|
|
298
|
-
raise RuntimeError(
|
|
299
|
-
f"Unexpected response while listing research tasks: {raw_response}"
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
tasks = [_build_research_task(t) for t in raw_response.get("data", [])]
|
|
303
|
-
|
|
304
|
-
from .models import ListResearchTasksResponse # noqa: WPS433 – runtime import
|
|
305
|
-
|
|
306
|
-
return ListResearchTasksResponse(
|
|
307
|
-
data=tasks,
|
|
308
|
-
has_more=raw_response.get("hasMore", False),
|
|
309
|
-
next_cursor=raw_response.get("nextCursor"),
|
|
310
|
-
)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
# ---------------------------------------------------------------------------
|
|
314
|
-
# Internal helpers (lazy imports to avoid cycles)
|
|
315
|
-
# ---------------------------------------------------------------------------
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def _build_research_task(raw: Dict[str, Any]):
|
|
319
|
-
"""Convert raw API response into a :class:`ResearchTask` instance."""
|
|
320
|
-
|
|
321
|
-
# Defensive check – fail loudly if the API contract changes.
|
|
322
|
-
if not isinstance(raw, dict) or "id" not in raw:
|
|
323
|
-
raise RuntimeError(f"Unexpected response while fetching research task: {raw}")
|
|
324
|
-
|
|
325
|
-
# Lazily import heavy deps to avoid cycles and unnecessary startup cost.
|
|
326
|
-
from .models import ResearchTask # noqa: WPS433 – runtime import
|
|
327
|
-
from ..api import _Result, to_snake_case # noqa: WPS433 – runtime import
|
|
328
|
-
|
|
329
|
-
citations_raw = raw.get("citations", {}) or {}
|
|
330
|
-
citations_parsed = {}
|
|
331
|
-
for key, cites in citations_raw.items():
|
|
332
|
-
results = []
|
|
333
|
-
for c in cites:
|
|
334
|
-
snake_c = to_snake_case(c)
|
|
335
|
-
results.append(
|
|
336
|
-
_Result(
|
|
337
|
-
url=snake_c.get("url"),
|
|
338
|
-
id=snake_c.get("id"),
|
|
339
|
-
title=snake_c.get("title"),
|
|
340
|
-
score=snake_c.get("score"),
|
|
341
|
-
published_date=snake_c.get("published_date"),
|
|
342
|
-
author=snake_c.get("author"),
|
|
343
|
-
image=snake_c.get("image"),
|
|
344
|
-
favicon=snake_c.get("favicon"),
|
|
345
|
-
subpages=snake_c.get("subpages"),
|
|
346
|
-
extras=snake_c.get("extras"),
|
|
347
|
-
)
|
|
348
|
-
)
|
|
349
|
-
citations_parsed[key] = results
|
|
350
|
-
|
|
351
|
-
return ResearchTask(
|
|
352
|
-
id=raw["id"],
|
|
353
|
-
status=raw["status"],
|
|
354
|
-
instructions=raw.get("instructions", ""),
|
|
355
|
-
schema=raw.get("schema", {}),
|
|
356
|
-
data=raw.get("data"),
|
|
357
|
-
citations=citations_parsed,
|
|
358
|
-
)
|
|
File without changes
|