langchain-hyperresearch 0.1.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.
@@ -0,0 +1,27 @@
1
+ """LangChain integration for Hyperresearch Answers."""
2
+
3
+ from langchain_hyperresearch._client import (
4
+ DEFAULT_BASE_URL,
5
+ HyperresearchClient,
6
+ HyperresearchError,
7
+ __version__,
8
+ )
9
+ from langchain_hyperresearch.retrievers import HyperresearchRetriever
10
+ from langchain_hyperresearch.tools import (
11
+ HyperresearchAnswerInput,
12
+ HyperresearchAnswerTool,
13
+ HyperresearchSearchInput,
14
+ HyperresearchSearchTool,
15
+ )
16
+
17
+ __all__ = [
18
+ "DEFAULT_BASE_URL",
19
+ "HyperresearchAnswerInput",
20
+ "HyperresearchAnswerTool",
21
+ "HyperresearchClient",
22
+ "HyperresearchError",
23
+ "HyperresearchRetriever",
24
+ "HyperresearchSearchInput",
25
+ "HyperresearchSearchTool",
26
+ "__version__",
27
+ ]
@@ -0,0 +1,301 @@
1
+ """A small client for the Hyperresearch Answers REST API.
2
+
3
+ POST /v1/answers ask: one short answer checked against quoted sources, or an honest not_found
4
+ POST /v1/answers/search search: ranked sources with word-for-word passages, no synthesis
5
+ GET /v1/answers/{id} a stored call (used to collect an ask that came back 202 pending)
6
+
7
+ Auth is ``Authorization: Bearer <hr_live_... key>`` with the ``answers:ask`` scope.
8
+ Every call is billed from the account's prepaid Answers balance.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ import time
16
+ import uuid
17
+ from typing import Any, Literal, Optional
18
+
19
+ import httpx
20
+
21
+ DEFAULT_BASE_URL = "https://api.hyperresearch.ai"
22
+ API_KEY_ENV = "HYPERRESEARCH_API_KEY"
23
+ BASE_URL_ENV = "HYPERRESEARCH_BASE_URL"
24
+ __version__ = "0.1.0"
25
+
26
+ AskMode = Literal["fast", "standard", "max"]
27
+ SearchMode = Literal["standard", "max"]
28
+
29
+ POLL_WAIT_SECONDS = 45
30
+ MAX_RETRY_DELAY_S = 30.0
31
+
32
+
33
+ class HyperresearchError(Exception):
34
+ """A non-2xx response, carrying the API's error envelope."""
35
+
36
+ def __init__(
37
+ self,
38
+ status: int,
39
+ message: str,
40
+ *,
41
+ type: Optional[str] = None,
42
+ code: Optional[str] = None,
43
+ param: Optional[str] = None,
44
+ request_id: Optional[str] = None,
45
+ retry_after: Optional[float] = None,
46
+ body: Any = None,
47
+ ) -> None:
48
+ super().__init__(message)
49
+ self.status = status
50
+ self.message = message
51
+ self.type = type
52
+ self.code = code
53
+ self.param = param
54
+ self.request_id = request_id
55
+ self.retry_after = retry_after
56
+ self.body = body
57
+
58
+ def __repr__(self) -> str:
59
+ return f"HyperresearchError(status={self.status}, code={self.code!r}, message={self.message!r})"
60
+
61
+
62
+ def _parse(response: httpx.Response) -> dict[str, Any]:
63
+ try:
64
+ data = response.json() if response.content else {}
65
+ except ValueError:
66
+ data = {"raw": response.text}
67
+ return data if isinstance(data, dict) else {"data": data}
68
+
69
+
70
+ def _err(body: dict[str, Any]) -> dict[str, Any]:
71
+ e = body.get("error")
72
+ return e if isinstance(e, dict) else {}
73
+
74
+
75
+ def _retry_after(response: httpx.Response, body: dict[str, Any]) -> Optional[float]:
76
+ err = _err(body)
77
+ if isinstance(err.get("retry_after"), (int, float)):
78
+ return float(err["retry_after"])
79
+ try:
80
+ return float(response.headers.get("Retry-After", ""))
81
+ except ValueError:
82
+ return None
83
+
84
+
85
+ def _error(response: httpx.Response, body: dict[str, Any]) -> HyperresearchError:
86
+ err = _err(body)
87
+ return HyperresearchError(
88
+ response.status_code,
89
+ err.get("message") or f"Hyperresearch API error {response.status_code}",
90
+ type=err.get("type"),
91
+ code=err.get("code"),
92
+ param=err.get("param"),
93
+ request_id=err.get("request_id"),
94
+ retry_after=_retry_after(response, body),
95
+ body=body,
96
+ )
97
+
98
+
99
+ class HyperresearchClient:
100
+ """Sync and async access to the Answers API.
101
+
102
+ Args:
103
+ api_key: Defaults to the ``HYPERRESEARCH_API_KEY`` environment variable.
104
+ base_url: Defaults to ``HYPERRESEARCH_BASE_URL``, then https://api.hyperresearch.ai.
105
+ ask_timeout: Seconds an ask may take end to end, including polling. Default 360 (max mode runs up to about 5 minutes).
106
+ search_timeout: Seconds a search may take. Default 90.
107
+ max_retries: Retries on 429/503 (capacity), honouring Retry-After. Default 2.
108
+ transport / async_transport: httpx transports (tests, proxies).
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ api_key: Optional[str] = None,
114
+ base_url: Optional[str] = None,
115
+ *,
116
+ ask_timeout: float = 360.0,
117
+ search_timeout: float = 90.0,
118
+ max_retries: int = 2,
119
+ headers: Optional[dict[str, str]] = None,
120
+ transport: Optional[httpx.BaseTransport] = None,
121
+ async_transport: Optional[httpx.AsyncBaseTransport] = None,
122
+ ) -> None:
123
+ self.api_key = api_key or os.environ.get(API_KEY_ENV)
124
+ self.base_url = (base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL).rstrip("/")
125
+ self.ask_timeout = ask_timeout
126
+ self.search_timeout = search_timeout
127
+ self.max_retries = max_retries
128
+ self.extra_headers = dict(headers or {})
129
+ self._transport = transport
130
+ self._async_transport = async_transport
131
+ # Test seams.
132
+ self._sleep = time.sleep
133
+ self._asleep = asyncio.sleep
134
+
135
+ # ---- shared ----
136
+
137
+ def _headers(self, extra: Optional[dict[str, str]] = None) -> dict[str, str]:
138
+ if not self.api_key:
139
+ raise HyperresearchError(
140
+ 401,
141
+ f"No Hyperresearch API key. Set {API_KEY_ENV} or pass api_key. "
142
+ "Create one with the answers:ask scope at https://console.hyperresearch.ai/#/keys",
143
+ code="missing_api_key",
144
+ )
145
+ return {
146
+ "Authorization": f"Bearer {self.api_key}",
147
+ "Accept": "application/json",
148
+ "User-Agent": f"langchain-hyperresearch/{__version__}",
149
+ **self.extra_headers,
150
+ **(extra or {}),
151
+ }
152
+
153
+ @staticmethod
154
+ def _ask_body(question: str, mode: str, as_of: Optional[str], max_cost_cents: Optional[float]) -> dict[str, Any]:
155
+ body: dict[str, Any] = {"question": question, "mode": mode}
156
+ if as_of is not None:
157
+ body["as_of"] = as_of
158
+ if max_cost_cents is not None:
159
+ body["max_cost_cents"] = max_cost_cents
160
+ return body
161
+
162
+ def _delay(self, attempt: int, retry_after: Optional[float]) -> float:
163
+ return min(retry_after if retry_after is not None else 2.0**attempt, MAX_RETRY_DELAY_S)
164
+
165
+ # ---- sync ----
166
+
167
+ def _request(
168
+ self, client: httpx.Client, method: str, path: str, *, json: Any = None, headers: Optional[dict[str, str]] = None
169
+ ) -> tuple[int, dict[str, Any]]:
170
+ attempt = 0
171
+ while True:
172
+ response = client.request(method, f"{self.base_url}{path}", json=json, headers=self._headers(headers))
173
+ body = _parse(response)
174
+ if response.is_success:
175
+ return response.status_code, body
176
+ if response.status_code in (429, 503) and attempt < self.max_retries:
177
+ self._sleep(self._delay(attempt, _retry_after(response, body)))
178
+ attempt += 1
179
+ continue
180
+ raise _error(response, body)
181
+
182
+ def ask(self, question: str, mode: AskMode = "standard", *, as_of: Optional[str] = None, max_cost_cents: Optional[float] = None) -> dict[str, Any]:
183
+ """Ask one question. A 202 ``pending`` is collected from GET /v1/answers/{id} (charged once)."""
184
+ deadline = time.monotonic() + self.ask_timeout
185
+ with httpx.Client(transport=self._transport, timeout=httpx.Timeout(self.ask_timeout)) as client:
186
+ status, body = self._request(
187
+ client,
188
+ "POST",
189
+ "/v1/answers",
190
+ json=self._ask_body(question, mode, as_of, max_cost_cents),
191
+ headers={"Idempotency-Key": str(uuid.uuid4()), "Prefer": f"respond-async; wait={POLL_WAIT_SECONDS + 5}"},
192
+ )
193
+ while status == 202 or body.get("status") == "pending":
194
+ call_id = body.get("id")
195
+ if not call_id:
196
+ raise HyperresearchError(status, "The API answered pending without a call id.", body=body)
197
+ if time.monotonic() > deadline:
198
+ raise HyperresearchError(
199
+ 408, f"Ask {call_id} did not finish within {self.ask_timeout:.0f}s; fetch it later with get_call().", code="client_timeout", body=body
200
+ )
201
+ status, body = self._request(client, "GET", f"/v1/answers/{call_id}?wait={POLL_WAIT_SECONDS}")
202
+ return body
203
+
204
+ def search(self, query: str, max_results: int = 10, mode: SearchMode = "standard") -> dict[str, Any]:
205
+ """Ranked raw sources with verbatim passages."""
206
+ with httpx.Client(transport=self._transport, timeout=httpx.Timeout(self.search_timeout)) as client:
207
+ _, body = self._request(
208
+ client,
209
+ "POST",
210
+ "/v1/answers/search",
211
+ json={"query": query, "max_results": max_results, "mode": mode},
212
+ headers={"Idempotency-Key": str(uuid.uuid4())},
213
+ )
214
+ return body
215
+
216
+ def get_call(self, call_id: str, wait: Optional[int] = None) -> dict[str, Any]:
217
+ """A stored call. Free."""
218
+ path = f"/v1/answers/{call_id}" + (f"?wait={wait}" if wait is not None else "")
219
+ with httpx.Client(transport=self._transport, timeout=httpx.Timeout(60.0)) as client:
220
+ _, body = self._request(client, "GET", path)
221
+ return body
222
+
223
+ # ---- async ----
224
+
225
+ async def _arequest(
226
+ self, client: httpx.AsyncClient, method: str, path: str, *, json: Any = None, headers: Optional[dict[str, str]] = None
227
+ ) -> tuple[int, dict[str, Any]]:
228
+ attempt = 0
229
+ while True:
230
+ response = await client.request(method, f"{self.base_url}{path}", json=json, headers=self._headers(headers))
231
+ body = _parse(response)
232
+ if response.is_success:
233
+ return response.status_code, body
234
+ if response.status_code in (429, 503) and attempt < self.max_retries:
235
+ await self._asleep(self._delay(attempt, _retry_after(response, body)))
236
+ attempt += 1
237
+ continue
238
+ raise _error(response, body)
239
+
240
+ async def aask(self, question: str, mode: AskMode = "standard", *, as_of: Optional[str] = None, max_cost_cents: Optional[float] = None) -> dict[str, Any]:
241
+ deadline = time.monotonic() + self.ask_timeout
242
+ async with httpx.AsyncClient(transport=self._async_transport, timeout=httpx.Timeout(self.ask_timeout)) as client:
243
+ status, body = await self._arequest(
244
+ client,
245
+ "POST",
246
+ "/v1/answers",
247
+ json=self._ask_body(question, mode, as_of, max_cost_cents),
248
+ headers={"Idempotency-Key": str(uuid.uuid4()), "Prefer": f"respond-async; wait={POLL_WAIT_SECONDS + 5}"},
249
+ )
250
+ while status == 202 or body.get("status") == "pending":
251
+ call_id = body.get("id")
252
+ if not call_id:
253
+ raise HyperresearchError(status, "The API answered pending without a call id.", body=body)
254
+ if time.monotonic() > deadline:
255
+ raise HyperresearchError(
256
+ 408, f"Ask {call_id} did not finish within {self.ask_timeout:.0f}s; fetch it later with get_call().", code="client_timeout", body=body
257
+ )
258
+ status, body = await self._arequest(client, "GET", f"/v1/answers/{call_id}?wait={POLL_WAIT_SECONDS}")
259
+ return body
260
+
261
+ async def asearch(self, query: str, max_results: int = 10, mode: SearchMode = "standard") -> dict[str, Any]:
262
+ async with httpx.AsyncClient(transport=self._async_transport, timeout=httpx.Timeout(self.search_timeout)) as client:
263
+ _, body = await self._arequest(
264
+ client,
265
+ "POST",
266
+ "/v1/answers/search",
267
+ json={"query": query, "max_results": max_results, "mode": mode},
268
+ headers={"Idempotency-Key": str(uuid.uuid4())},
269
+ )
270
+ return body
271
+
272
+
273
+ def compact_answer(r: dict[str, Any]) -> dict[str, Any]:
274
+ """The form a model reads: the answer, its quotes, and what it cost."""
275
+ out: dict[str, Any] = {
276
+ "id": r.get("id"),
277
+ "status": r.get("status"),
278
+ "answer": r.get("answer", ""),
279
+ "evidence": [
280
+ {k: e.get(k) for k in ("url", "title", "quote", "check") if e.get(k) is not None} for e in (r.get("evidence") or []) if isinstance(e, dict)
281
+ ],
282
+ }
283
+ if r.get("caveat"):
284
+ out["caveat"] = r["caveat"]
285
+ if r.get("escalated"):
286
+ out["escalated"] = True
287
+ if r.get("error"):
288
+ out["error"] = r["error"]
289
+ out["price_cents"] = r.get("price_cents")
290
+ return out
291
+
292
+
293
+ def compact_search(r: dict[str, Any]) -> dict[str, Any]:
294
+ results = []
295
+ for x in r.get("results") or []:
296
+ if not isinstance(x, dict):
297
+ continue
298
+ row = {k: x.get(k) for k in ("rank", "url", "title", "published", "source") if x.get(k) is not None}
299
+ row["passages"] = [{"text": p.get("text"), "verified": p.get("verified")} for p in (x.get("passages") or []) if isinstance(p, dict)]
300
+ results.append(row)
301
+ return {"id": r.get("id"), "status": r.get("status"), "query": r.get("query"), "results": results, "price_cents": r.get("price_cents")}
File without changes
@@ -0,0 +1,78 @@
1
+ """A LangChain retriever over Hyperresearch Answers ``search``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from langchain_core.callbacks import AsyncCallbackManagerForRetrieverRun, CallbackManagerForRetrieverRun
8
+ from langchain_core.documents import Document
9
+ from langchain_core.retrievers import BaseRetriever
10
+ from pydantic import ConfigDict, Field, SecretStr, model_validator
11
+
12
+ from langchain_hyperresearch._client import HyperresearchClient, SearchMode
13
+
14
+
15
+ def _to_documents(res: dict[str, Any]) -> list[Document]:
16
+ docs = []
17
+ for r in res.get("results") or []:
18
+ if not isinstance(r, dict):
19
+ continue
20
+ passages = [p for p in (r.get("passages") or []) if isinstance(p, dict)]
21
+ texts = [p["text"] for p in passages if p.get("text")]
22
+ docs.append(
23
+ Document(
24
+ page_content="\n\n".join(texts) if texts else (r.get("snippet") or ""),
25
+ metadata={
26
+ "url": r.get("url"),
27
+ "title": r.get("title"),
28
+ "rank": r.get("rank"),
29
+ "source": r.get("source"),
30
+ "published": r.get("published"),
31
+ "verified": all(p.get("verified") is not False for p in passages),
32
+ "call_id": res.get("id"),
33
+ },
34
+ )
35
+ )
36
+ return docs
37
+
38
+
39
+ class HyperresearchRetriever(BaseRetriever):
40
+ """Ranked sources from Hyperresearch Answers ``search``, one Document per source.
41
+
42
+ ``page_content`` is the matched passages, verbatim (or the snippet when there are none).
43
+ ``metadata`` carries url, title, rank, source, published, verified and the call id.
44
+ Each retrieval is one billed ``search`` call (standard 5¢, max 15¢).
45
+
46
+ Example:
47
+ .. code-block:: python
48
+
49
+ from langchain_hyperresearch import HyperresearchRetriever
50
+
51
+ retriever = HyperresearchRetriever(max_results=5)
52
+ docs = retriever.invoke("minimum age to serve as a US senator")
53
+ """
54
+
55
+ model_config = ConfigDict(arbitrary_types_allowed=True)
56
+
57
+ max_results: int = Field(default=10, ge=1, le=25)
58
+ mode: SearchMode = "standard"
59
+ api_key: Optional[SecretStr] = Field(default=None, exclude=True)
60
+ base_url: Optional[str] = None
61
+ client: Optional[HyperresearchClient] = Field(default=None, exclude=True)
62
+
63
+ @model_validator(mode="after")
64
+ def _build_client(self) -> HyperresearchRetriever:
65
+ if self.client is None:
66
+ self.client = HyperresearchClient(
67
+ api_key=self.api_key.get_secret_value() if self.api_key else None,
68
+ base_url=self.base_url,
69
+ )
70
+ return self
71
+
72
+ def _get_relevant_documents(self, query: str, *, run_manager: CallbackManagerForRetrieverRun) -> list[Document]:
73
+ assert self.client is not None
74
+ return _to_documents(self.client.search(query, self.max_results, self.mode))
75
+
76
+ async def _aget_relevant_documents(self, query: str, *, run_manager: AsyncCallbackManagerForRetrieverRun) -> list[Document]:
77
+ assert self.client is not None
78
+ return _to_documents(await self.client.asearch(query, self.max_results, self.mode))
@@ -0,0 +1,202 @@
1
+ """Hyperresearch Answers tools for LangChain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Literal, Optional
7
+
8
+ from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
9
+ from langchain_core.tools import BaseTool
10
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
11
+
12
+ from langchain_hyperresearch._client import (
13
+ AskMode,
14
+ HyperresearchClient,
15
+ SearchMode,
16
+ compact_answer,
17
+ compact_search,
18
+ )
19
+
20
+ ANSWER_DESCRIPTION = (
21
+ "Answer one factual question with Hyperresearch Answers. Researches the open web and about 45 structured sources "
22
+ "(filings, statistics, registers, scholarly indexes, books) on the server and returns one short answer with the quotes it rests on, "
23
+ "each checked word for word against its page, or status not_found when nothing supports an answer. "
24
+ "Prefer this over a loop of searches for any factual lookup. Modes: fast (5¢), standard (10¢, 20¢ if it digs deeper; default), "
25
+ "max (50¢, numbers confirmed on two sites). Each call is charged to the user's prepaid Hyperresearch Answers balance."
26
+ )
27
+
28
+ SEARCH_DESCRIPTION = (
29
+ "Search the web and Hyperresearch's ~45 structured sources (filings, statistics, registers, scholarly, books). "
30
+ "Returns ranked raw sources with verbatim passages. No synthesis. Use when you need to read the sources yourself. "
31
+ "Modes: standard (5¢, default), max (15¢: every provider plus structured data rows and PDF tables, for hard lookups). "
32
+ "Each call is charged to the user's prepaid Hyperresearch Answers balance."
33
+ )
34
+
35
+
36
+ class HyperresearchAnswerInput(BaseModel):
37
+ """Input for HyperresearchAnswerTool."""
38
+
39
+ question: str = Field(min_length=1, max_length=2000, description="One factual question, asked the way you would ask a researcher.")
40
+ mode: Optional[Literal["fast", "standard", "max"]] = Field(
41
+ default=None,
42
+ description="How hard to look: fast (5¢, about 45 s), standard (10¢, 20¢ if it digs deeper; default, up to about 3 min) "
43
+ "or max (50¢, up to about 5 min).",
44
+ )
45
+ as_of: Optional[str] = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$", description="Answer as of this date (YYYY-MM-DD). Omit for today.")
46
+
47
+
48
+ class HyperresearchSearchInput(BaseModel):
49
+ """Input for HyperresearchSearchTool."""
50
+
51
+ query: str = Field(min_length=1, max_length=1000, description="What to find, as a question or a precise phrase.")
52
+ max_results: Optional[int] = Field(
53
+ default=None, ge=1, le=25, description="How many ranked sources to return (1-25, default 10). The price does not change."
54
+ )
55
+ mode: Optional[Literal["standard", "max"]] = Field(
56
+ default=None,
57
+ description="standard (5¢, default) or max (15¢: every provider, plus structured data rows and PDF tables, for hard lookups).",
58
+ )
59
+
60
+
61
+ class _HyperresearchBase(BaseTool):
62
+ model_config = ConfigDict(arbitrary_types_allowed=True)
63
+
64
+ api_key: Optional[SecretStr] = Field(default=None, exclude=True)
65
+ """Defaults to the HYPERRESEARCH_API_KEY environment variable."""
66
+ base_url: Optional[str] = None
67
+ """Defaults to HYPERRESEARCH_BASE_URL, then https://api.hyperresearch.ai."""
68
+ client: Optional[HyperresearchClient] = Field(default=None, exclude=True)
69
+ """Share one client between tools and retrievers."""
70
+
71
+ response_format: Literal["content_and_artifact"] = "content_and_artifact"
72
+
73
+ @model_validator(mode="after")
74
+ def _build_client(self) -> _HyperresearchBase:
75
+ if self.client is None:
76
+ self.client = HyperresearchClient(
77
+ api_key=self.api_key.get_secret_value() if self.api_key else None,
78
+ base_url=self.base_url,
79
+ )
80
+ return self
81
+
82
+
83
+ class HyperresearchAnswerTool(_HyperresearchBase):
84
+ """Hyperresearch Answers ``ask``: one checked answer with its quotes, or ``not_found``.
85
+
86
+ Setup:
87
+ .. code-block:: bash
88
+
89
+ pip install -U langchain-hyperresearch
90
+ export HYPERRESEARCH_API_KEY="hr_live_..."
91
+
92
+ Instantiate:
93
+ .. code-block:: python
94
+
95
+ from langchain_hyperresearch import HyperresearchAnswerTool
96
+
97
+ tool = HyperresearchAnswerTool() # the model picks the mode
98
+ tool = HyperresearchAnswerTool(mode="fast", max_cost_cents=5)
99
+
100
+ Invoke directly:
101
+ .. code-block:: python
102
+
103
+ tool.invoke({"question": "How old must a US senator be?", "mode": "fast"})
104
+
105
+ Invoke with a ToolCall (returns a ToolMessage; ``artifact`` is the full API response):
106
+ .. code-block:: python
107
+
108
+ tool.invoke({"args": {"question": "How old must a US senator be?"}, "id": "1", "name": tool.name, "type": "tool_call"})
109
+ """
110
+
111
+ name: str = "hyperresearch_answer"
112
+ description: str = ANSWER_DESCRIPTION
113
+ args_schema: type[BaseModel] = HyperresearchAnswerInput
114
+
115
+ mode: Optional[AskMode] = None
116
+ """Fix the mode; the model then cannot choose it."""
117
+ max_cost_cents: Optional[float] = None
118
+ """Cap every call, in cents. A standard ask under 20 never escalates."""
119
+
120
+ def _mode(self, mode: Optional[str]) -> str:
121
+ return self.mode or mode or "standard"
122
+
123
+ def _run(
124
+ self,
125
+ question: str,
126
+ mode: Optional[str] = None,
127
+ as_of: Optional[str] = None,
128
+ run_manager: Optional[CallbackManagerForToolRun] = None,
129
+ ) -> tuple[str, dict[str, Any]]:
130
+ assert self.client is not None
131
+ raw = self.client.ask(question, self._mode(mode), as_of=as_of, max_cost_cents=self.max_cost_cents) # type: ignore[arg-type]
132
+ return json.dumps(compact_answer(raw), ensure_ascii=False), raw
133
+
134
+ async def _arun(
135
+ self,
136
+ question: str,
137
+ mode: Optional[str] = None,
138
+ as_of: Optional[str] = None,
139
+ run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
140
+ ) -> tuple[str, dict[str, Any]]:
141
+ assert self.client is not None
142
+ raw = await self.client.aask(question, self._mode(mode), as_of=as_of, max_cost_cents=self.max_cost_cents) # type: ignore[arg-type]
143
+ return json.dumps(compact_answer(raw), ensure_ascii=False), raw
144
+
145
+
146
+ class HyperresearchSearchTool(_HyperresearchBase):
147
+ """Hyperresearch Answers ``search``: ranked raw sources with verbatim passages.
148
+
149
+ Setup:
150
+ .. code-block:: bash
151
+
152
+ pip install -U langchain-hyperresearch
153
+ export HYPERRESEARCH_API_KEY="hr_live_..."
154
+
155
+ Instantiate:
156
+ .. code-block:: python
157
+
158
+ from langchain_hyperresearch import HyperresearchSearchTool
159
+
160
+ tool = HyperresearchSearchTool(max_results=5)
161
+
162
+ Invoke:
163
+ .. code-block:: python
164
+
165
+ tool.invoke({"query": "minimum age to serve as a US senator"})
166
+ """
167
+
168
+ name: str = "hyperresearch_search"
169
+ description: str = SEARCH_DESCRIPTION
170
+ args_schema: type[BaseModel] = HyperresearchSearchInput
171
+
172
+ mode: Optional[SearchMode] = None
173
+ """Fix the mode; the model then cannot choose it."""
174
+ max_results: Optional[int] = Field(default=None, ge=1, le=25)
175
+ """Fix the number of results (1-25)."""
176
+
177
+ def _args(self, max_results: Optional[int], mode: Optional[str]) -> tuple[int, str]:
178
+ return self.max_results or max_results or 10, self.mode or mode or "standard"
179
+
180
+ def _run(
181
+ self,
182
+ query: str,
183
+ max_results: Optional[int] = None,
184
+ mode: Optional[str] = None,
185
+ run_manager: Optional[CallbackManagerForToolRun] = None,
186
+ ) -> tuple[str, dict[str, Any]]:
187
+ assert self.client is not None
188
+ n, m = self._args(max_results, mode)
189
+ raw = self.client.search(query, n, m) # type: ignore[arg-type]
190
+ return json.dumps(compact_search(raw), ensure_ascii=False), raw
191
+
192
+ async def _arun(
193
+ self,
194
+ query: str,
195
+ max_results: Optional[int] = None,
196
+ mode: Optional[str] = None,
197
+ run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
198
+ ) -> tuple[str, dict[str, Any]]:
199
+ assert self.client is not None
200
+ n, m = self._args(max_results, mode)
201
+ raw = await self.client.asearch(query, n, m) # type: ignore[arg-type]
202
+ return json.dumps(compact_search(raw), ensure_ascii=False), raw
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-hyperresearch
3
+ Version: 0.1.0
4
+ Summary: LangChain integration for Hyperresearch Answers: a cited, quote-checked answer tool, a raw-source search tool and a retriever.
5
+ Project-URL: Homepage, https://hyperresearch.ai/answers
6
+ Project-URL: Documentation, https://hyperresearch.ai/docs/answers/langchain
7
+ Project-URL: Repository, https://github.com/jordan-gibbs/hyperresearch-answers
8
+ Project-URL: Source, https://github.com/jordan-gibbs/hyperresearch-answers/tree/main/packages/langchain-python
9
+ Project-URL: Issues, https://github.com/jordan-gibbs/hyperresearch-answers/issues
10
+ Author: Hyperresearch
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: agents,answers,citations,hyperresearch,langchain,retriever,search,tools,web-search
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: <4.0,>=3.10
19
+ Requires-Dist: httpx<1.0,>=0.27
20
+ Requires-Dist: langchain-core<2.0.0,>=1.0.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # langchain-hyperresearch
24
+
25
+ This package contains the LangChain integration for [Hyperresearch Answers](https://hyperresearch.ai/answers): cited, quote-checked answers and ranked raw sources for agents.
26
+
27
+ ## Overview
28
+
29
+ ### Details
30
+
31
+ | Class | Package | Serializable | JS support |
32
+ | :--- | :--- | :---: | :---: |
33
+ | `HyperresearchAnswerTool` | `langchain-hyperresearch` | ❌ | ✅ (`@hyperresearch/langchain`) |
34
+ | `HyperresearchSearchTool` | `langchain-hyperresearch` | ❌ | ✅ |
35
+ | `HyperresearchRetriever` | `langchain-hyperresearch` | ❌ | ✅ |
36
+
37
+ ### Features
38
+
39
+ - **`HyperresearchAnswerTool`** (`hyperresearch_answer`): one factual question in, one short answer out, with the quotes it rests on, each checked word for word against its page. When nothing supports an answer it returns `not_found` instead of a guess. The research runs on the Hyperresearch side: one tool call instead of a loop of searches, and no pages in the agent's context.
40
+ - **`HyperresearchSearchTool`** (`hyperresearch_search`): ranked raw sources from the open web and about 45 structured sources (company filings, statistics agencies, scholarly indexes, books, government records), with word-for-word passages. No summary.
41
+ - **`HyperresearchRetriever`**: `search` as a retriever, one `Document` per ranked source.
42
+ - Sync and async (`invoke` / `ainvoke`). Tools use `response_format="content_and_artifact"`: the model reads a compact JSON string, and `ToolMessage.artifact` holds the full API response.
43
+
44
+ ## Setup
45
+
46
+ ### Credentials
47
+
48
+ Create an API key with the `answers:ask` scope at <https://console.hyperresearch.ai/#/keys>. Every account gets $5 of free credit, no card needed.
49
+
50
+ ```python
51
+ import getpass
52
+ import os
53
+
54
+ if "HYPERRESEARCH_API_KEY" not in os.environ:
55
+ os.environ["HYPERRESEARCH_API_KEY"] = getpass.getpass("Enter your Hyperresearch API key: ")
56
+ ```
57
+
58
+ ### Installation
59
+
60
+ ```bash
61
+ pip install -U langchain-hyperresearch
62
+ # or
63
+ uv add langchain-hyperresearch
64
+ ```
65
+
66
+ ## Instantiation
67
+
68
+ ```python
69
+ from langchain_hyperresearch import HyperresearchAnswerTool, HyperresearchSearchTool
70
+
71
+ answer = HyperresearchAnswerTool() # the model picks the mode
72
+ cheap = HyperresearchAnswerTool(mode="fast", max_cost_cents=5)
73
+ search = HyperresearchSearchTool(max_results=5)
74
+ ```
75
+
76
+ | Parameter | Default | |
77
+ |---|---|---|
78
+ | `api_key` | `HYPERRESEARCH_API_KEY` | |
79
+ | `mode` | the model's choice, else `standard` | Fix the mode. Ask: `fast` 5¢ · `standard` 10¢ (20¢ when it digs deeper) · `max` 50¢. Search: `standard` 5¢ · `max` 15¢. |
80
+ | `max_cost_cents` | none | Answer tool. Caps every call; a standard ask under 20 never escalates. |
81
+ | `max_results` | the model's choice, else 10 | Search tool and retriever. 1-25. |
82
+ | `client` | new client | Share one `HyperresearchClient` (it also takes `base_url`, timeouts, `max_retries`). |
83
+
84
+ ## Invocation
85
+
86
+ ### Directly
87
+
88
+ ```python
89
+ answer.invoke({"question": "How old must a US senator be?", "mode": "fast"})
90
+ ```
91
+
92
+ ```json
93
+ {"id": "ans_01J...", "status": "answered", "answer": "At least 30 years old (US Constitution, Article I, Section 3).", "evidence": [{"url": "https://www.archives.gov/founding-docs/constitution-transcript", "title": "The Constitution of the United States: A Transcription", "quote": "No Person shall be a Senator who shall not have attained to the Age of thirty Years", "check": "verified"}], "price_cents": 5}
94
+ ```
95
+
96
+ `status` is one of `answered`, `false_premise`, `unconfirmed`, `not_found` or `error`.
97
+
98
+ ```python
99
+ search.invoke({"query": "minimum age to serve as a US senator"})
100
+ ```
101
+
102
+ ### As a ToolCall
103
+
104
+ ```python
105
+ model_generated_tool_call = {
106
+ "args": {"question": "How old must a US senator be?"},
107
+ "id": "1",
108
+ "name": answer.name,
109
+ "type": "tool_call",
110
+ }
111
+ msg = answer.invoke(model_generated_tool_call)
112
+ msg.content # the compact JSON the model reads
113
+ msg.artifact # the full API response, including balance_cents
114
+ ```
115
+
116
+ ### Within an agent
117
+
118
+ ```python
119
+ from langchain.agents import create_agent
120
+
121
+ agent = create_agent(
122
+ model="claude-sonnet-4-6",
123
+ tools=[HyperresearchAnswerTool(), HyperresearchSearchTool()],
124
+ )
125
+
126
+ agent.invoke(
127
+ {"messages": [{"role": "user", "content": "What was the US federal deficit in fiscal 2025, with a source?"}]}
128
+ )
129
+ ```
130
+
131
+ ## Retriever
132
+
133
+ ```python
134
+ from langchain_hyperresearch import HyperresearchRetriever
135
+
136
+ retriever = HyperresearchRetriever(max_results=5)
137
+ docs = retriever.invoke("minimum age to serve as a US senator")
138
+ docs[0].page_content # the matched passages, verbatim
139
+ docs[0].metadata # url, title, rank, source, published, verified, call_id
140
+ ```
141
+
142
+ Each retrieval is one billed `search` call.
143
+
144
+ ## Pricing
145
+
146
+ | Call | Price | If not found |
147
+ |---|---|---|
148
+ | ask · fast | 5¢ | 1¢–5¢ |
149
+ | ask · standard (default) | 10¢, 20¢ when it digs deeper | 2¢–20¢ |
150
+ | ask · max | 50¢ | 10¢–50¢ |
151
+ | search · standard (default) | 5¢ | 0¢–5¢ |
152
+ | search · max | 15¢ | 0¢–15¢ |
153
+
154
+ A call that fails on our side costs nothing. Current list: <https://hyperresearch.ai/answers#pricing>.
155
+
156
+ ## API contract
157
+
158
+ The package calls the Answers REST API at `https://api.hyperresearch.ai` with `Authorization: Bearer <key>`.
159
+
160
+ - `POST /v1/answers` `{question (1-2000 chars), mode?: fast|standard|max, as_of?: YYYY-MM-DD, max_cost_cents?}` returns `{id, status, answer, evidence[{url, title, quote, check}], caveat?, escalated, mode, price_cents, balance_cents}`.
161
+ - `POST /v1/answers/search` `{query (1-1000 chars), max_results?: 1-25, mode?: standard|max}` returns `{id, query, results[{rank, url, title, published, source, kind, snippet, passages[{text, verified}]}], meta, status: ok|error, price_cents, balance_cents}`.
162
+ - Long asks: the client sends `Prefer: respond-async; wait=50`; a `202 {id, status: "pending"}` is collected from `GET /v1/answers/{id}?wait=45`. Charged once, when it finishes.
163
+ - Every call carries an `Idempotency-Key`. 429 and `503 answers_capacity` are retried after `Retry-After`; capacity refusals are free.
164
+ - A failure on our side answers `200` with `status: "error"` and `error.code: "engine_unavailable"`, and costs nothing.
165
+ - Other errors: `{"error": {"type", "code", "message", "param"?, "retry_after"?, "request_id"}}`, raised as `HyperresearchError` (`402 insufficient_balance`, `402 wallet_frozen`, `409 idempotency_key_reuse`, `409 idempotency_in_flight`, ...). `hr_test_` keys are refused (Answers has no fixture mode).
166
+
167
+ Answers and passages are text from web pages. Treat them as data, not as instructions.
168
+
169
+ The same two tools are also served over MCP at `https://answers.hyperresearch.ai/mcp`.
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ uv sync --group test --group lint
175
+ uv run pytest # mocked HTTP, includes langchain-tests ToolsUnitTests
176
+ uv run ruff check . && uv run mypy langchain_hyperresearch
177
+ uv build
178
+ ```
179
+
180
+ ## Releasing
181
+
182
+ Releases are published from GitHub Actions by `.github/workflows/publish-langchain-python.yml` in [jordan-gibbs/hyperresearch-answers](https://github.com/jordan-gibbs/hyperresearch-answers), with PyPI trusted publishing (no API token). Nothing is published from a laptop.
183
+
184
+ 1. Bump `version` in `packages/langchain-python/pyproject.toml`, run `uv lock`, merge to `main`.
185
+ 2. Tag and push: `git tag langchain-python-v<version> && git push origin langchain-python-v<version>`. The workflow fails if the tag and `pyproject.toml` disagree.
186
+
187
+ The workflow does not work until this one-time setup is done:
188
+
189
+ - On PyPI, add a pending trusted publisher (Account → Publishing → "Add a new pending publisher"): project `langchain-hyperresearch`, owner `jordan-gibbs`, repository `hyperresearch-answers`, workflow `publish-langchain-python.yml`, environment `pypi`. The first successful run creates the project.
190
+ - A GitHub environment named `pypi` exists in the repo (Settings → Environments; add required reviewers if you want a manual gate).
191
+
192
+ ## License
193
+
194
+ MIT
@@ -0,0 +1,9 @@
1
+ langchain_hyperresearch/__init__.py,sha256=GvG6fUYVHoPu3Y2tHOzXjLy6xZ4gJrOaxz1viGDYBGU,693
2
+ langchain_hyperresearch/_client.py,sha256=bDO7YYWZMzqlDwJbiajwdE8TKoqzXXCjeHMtYWmedYo,12750
3
+ langchain_hyperresearch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ langchain_hyperresearch/retrievers.py,sha256=KC9kn3_Jkk0EXuzp3pKKBJaWYilscM4pEPV8QeztYj8,3194
5
+ langchain_hyperresearch/tools.py,sha256=rkAjIrmZNOyFOC8qrqmeSaijnCXSSO_yfiBuPpCS1Yg,8192
6
+ langchain_hyperresearch-0.1.0.dist-info/METADATA,sha256=cWvy1rxy_w6wWEuqVFqwTvheTViim_iRgO3dkx-igPk,8920
7
+ langchain_hyperresearch-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ langchain_hyperresearch-0.1.0.dist-info/licenses/LICENSE,sha256=3PGUUvvm_rsvAXFoBqw3b0n2bBXZEdisn3g_1ijvIOQ,1075
9
+ langchain_hyperresearch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thalient Labs Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.