langchain-sofya 0.1.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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sofya, 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.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-sofya
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for Sofya: web search, page fetch, extraction and cited research for AI agents.
5
+ Project-URL: Homepage, https://sofya.co
6
+ Project-URL: Documentation, https://sofya.co/docs
7
+ Project-URL: Repository, https://github.com/sofya-co/langchain-sofya
8
+ Project-URL: Issues, https://github.com/sofya-co/langchain-sofya/issues
9
+ Author-email: "Sofya, Inc." <contact@sofya.co>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai agents,langchain,search api,sofya,tools,web scraping,web search
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx<1.0.0,>=0.27.0
25
+ Requires-Dist: langchain-core<2.0.0,>=0.3.15
26
+ Requires-Dist: pydantic<3.0.0,>=2.7.4
27
+ Description-Content-Type: text/markdown
28
+
29
+ # langchain-sofya
30
+
31
+ LangChain tools for [Sofya](https://sofya.co): live web search, any page or document as clean markdown, AI extraction from a page, and multi-source research with citations. One API key for all four.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install -U langchain-sofya
37
+ ```
38
+
39
+ Get an API key at [sofya.co/dashboard](https://sofya.co/dashboard) and set it:
40
+
41
+ ```bash
42
+ export SOFYA_API_KEY="sofya_live_..."
43
+ ```
44
+
45
+ Or pass `api_key="sofya_live_..."` to any tool.
46
+
47
+ ## Tools
48
+
49
+ | Tool | Name the model sees | What it does | Credits |
50
+ |---|---|---|---|
51
+ | `SofyaSearch` | `sofya_search` | Web search. Results with the text of each page, or snippets only, plus an optional short AI answer. Filter by domain, topic (general or news) and freshness. | 2 (basic), 1 (snippets), +2 for the answer |
52
+ | `SofyaFetch` | `sofya_fetch` | Up to 10 URLs as clean markdown. Web pages plus PDF, Word, Excel, PowerPoint, OpenDocument, EPUB and more. | 1 per URL, failed URLs free |
53
+ | `SofyaExtract` | `sofya_extract` | Reads one page and answers a prompt about it. | 10 |
54
+ | `SofyaResearch` | `sofya_research` | Splits a question into several searches, reads the sources and writes a report with citations. | 50 |
55
+
56
+ A credit is $0.0025. Pricing: [sofya.co/pricing](https://sofya.co/pricing).
57
+
58
+ ## Use
59
+
60
+ ```python
61
+ from langchain_sofya import SofyaSearch
62
+
63
+ search = SofyaSearch(max_results=5, include_answer=True)
64
+ result = search.invoke({"query": "latest stable Python release"})
65
+ print(result["answer"])
66
+ for r in result["results"]:
67
+ print(r["title"], r["url"])
68
+ ```
69
+
70
+ Arguments set on the tool are defaults; the model can override them per call (for example `max_results`, `include_domains` or `freshness`).
71
+
72
+ ```python
73
+ from langchain_sofya import SofyaExtract, SofyaFetch, SofyaResearch
74
+
75
+ SofyaFetch().invoke({"urls": ["https://example.com/report.pdf"]})
76
+
77
+ SofyaExtract().invoke({
78
+ "url": "https://en.wikipedia.org/wiki/Eiffel_Tower",
79
+ "prompt": "How tall is it and when did it open?",
80
+ })
81
+
82
+ SofyaResearch(max_sources=15).invoke({"query": "How do heat pumps perform in cold climates?"})
83
+ ```
84
+
85
+ Every tool also has an async version through `ainvoke`.
86
+
87
+ ### With an agent
88
+
89
+ ```python
90
+ from langchain.agents import create_agent
91
+ from langchain_sofya import SofyaExtract, SofyaFetch, SofyaSearch
92
+
93
+ agent = create_agent(
94
+ "anthropic:claude-sonnet-5-5",
95
+ tools=[SofyaSearch(), SofyaFetch(), SofyaExtract()],
96
+ )
97
+ agent.invoke({"messages": [{"role": "user", "content": "What changed in the latest Node.js LTS?"}]})
98
+ ```
99
+
100
+ Errors (invalid key, out of credits, rate limits) come back to the model as a tool message instead of raising, so an agent can tell the user what happened.
101
+
102
+ ## Settings
103
+
104
+ | Argument | Env var | Default |
105
+ |---|---|---|
106
+ | `api_key` | `SOFYA_API_KEY` | required |
107
+ | `base_url` | `SOFYA_BASE_URL` | `https://sofya.co` |
108
+ | `timeout` | - | 120 seconds (research can take up to a minute) |
109
+
110
+ ## Links
111
+
112
+ - API docs: [sofya.co/docs](https://sofya.co/docs)
113
+ - MCP server, for clients that speak MCP instead: `https://mcp.sofya.co/mcp`
114
+ - Support: support@sofya.co
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,90 @@
1
+ # langchain-sofya
2
+
3
+ LangChain tools for [Sofya](https://sofya.co): live web search, any page or document as clean markdown, AI extraction from a page, and multi-source research with citations. One API key for all four.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install -U langchain-sofya
9
+ ```
10
+
11
+ Get an API key at [sofya.co/dashboard](https://sofya.co/dashboard) and set it:
12
+
13
+ ```bash
14
+ export SOFYA_API_KEY="sofya_live_..."
15
+ ```
16
+
17
+ Or pass `api_key="sofya_live_..."` to any tool.
18
+
19
+ ## Tools
20
+
21
+ | Tool | Name the model sees | What it does | Credits |
22
+ |---|---|---|---|
23
+ | `SofyaSearch` | `sofya_search` | Web search. Results with the text of each page, or snippets only, plus an optional short AI answer. Filter by domain, topic (general or news) and freshness. | 2 (basic), 1 (snippets), +2 for the answer |
24
+ | `SofyaFetch` | `sofya_fetch` | Up to 10 URLs as clean markdown. Web pages plus PDF, Word, Excel, PowerPoint, OpenDocument, EPUB and more. | 1 per URL, failed URLs free |
25
+ | `SofyaExtract` | `sofya_extract` | Reads one page and answers a prompt about it. | 10 |
26
+ | `SofyaResearch` | `sofya_research` | Splits a question into several searches, reads the sources and writes a report with citations. | 50 |
27
+
28
+ A credit is $0.0025. Pricing: [sofya.co/pricing](https://sofya.co/pricing).
29
+
30
+ ## Use
31
+
32
+ ```python
33
+ from langchain_sofya import SofyaSearch
34
+
35
+ search = SofyaSearch(max_results=5, include_answer=True)
36
+ result = search.invoke({"query": "latest stable Python release"})
37
+ print(result["answer"])
38
+ for r in result["results"]:
39
+ print(r["title"], r["url"])
40
+ ```
41
+
42
+ Arguments set on the tool are defaults; the model can override them per call (for example `max_results`, `include_domains` or `freshness`).
43
+
44
+ ```python
45
+ from langchain_sofya import SofyaExtract, SofyaFetch, SofyaResearch
46
+
47
+ SofyaFetch().invoke({"urls": ["https://example.com/report.pdf"]})
48
+
49
+ SofyaExtract().invoke({
50
+ "url": "https://en.wikipedia.org/wiki/Eiffel_Tower",
51
+ "prompt": "How tall is it and when did it open?",
52
+ })
53
+
54
+ SofyaResearch(max_sources=15).invoke({"query": "How do heat pumps perform in cold climates?"})
55
+ ```
56
+
57
+ Every tool also has an async version through `ainvoke`.
58
+
59
+ ### With an agent
60
+
61
+ ```python
62
+ from langchain.agents import create_agent
63
+ from langchain_sofya import SofyaExtract, SofyaFetch, SofyaSearch
64
+
65
+ agent = create_agent(
66
+ "anthropic:claude-sonnet-5-5",
67
+ tools=[SofyaSearch(), SofyaFetch(), SofyaExtract()],
68
+ )
69
+ agent.invoke({"messages": [{"role": "user", "content": "What changed in the latest Node.js LTS?"}]})
70
+ ```
71
+
72
+ Errors (invalid key, out of credits, rate limits) come back to the model as a tool message instead of raising, so an agent can tell the user what happened.
73
+
74
+ ## Settings
75
+
76
+ | Argument | Env var | Default |
77
+ |---|---|---|
78
+ | `api_key` | `SOFYA_API_KEY` | required |
79
+ | `base_url` | `SOFYA_BASE_URL` | `https://sofya.co` |
80
+ | `timeout` | - | 120 seconds (research can take up to a minute) |
81
+
82
+ ## Links
83
+
84
+ - API docs: [sofya.co/docs](https://sofya.co/docs)
85
+ - MCP server, for clients that speak MCP instead: `https://mcp.sofya.co/mcp`
86
+ - Support: support@sofya.co
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,19 @@
1
+ """Sofya integration for LangChain: web search, fetch, extract and research."""
2
+
3
+ from langchain_sofya._client import SofyaAPIWrapper
4
+ from langchain_sofya._version import __version__
5
+ from langchain_sofya.tools import (
6
+ SofyaExtract,
7
+ SofyaFetch,
8
+ SofyaResearch,
9
+ SofyaSearch,
10
+ )
11
+
12
+ __all__ = [
13
+ "SofyaAPIWrapper",
14
+ "SofyaExtract",
15
+ "SofyaFetch",
16
+ "SofyaResearch",
17
+ "SofyaSearch",
18
+ "__version__",
19
+ ]
@@ -0,0 +1,111 @@
1
+ """HTTP client for the Sofya REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from langchain_core.tools import ToolException
10
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
11
+
12
+ from langchain_sofya._version import __version__
13
+
14
+ DEFAULT_BASE_URL = "https://sofya.co"
15
+ # Research and AI answers can take up to a minute; leave headroom.
16
+ DEFAULT_TIMEOUT = 120.0
17
+
18
+
19
+ class SofyaAPIWrapper(BaseModel):
20
+ """Calls the Sofya API (https://sofya.co/docs).
21
+
22
+ The API key is read from the ``SOFYA_API_KEY`` environment variable unless
23
+ passed as ``api_key``. Create one at https://sofya.co/dashboard.
24
+ """
25
+
26
+ model_config = ConfigDict(extra="forbid")
27
+
28
+ api_key: SecretStr = Field(default=SecretStr(""))
29
+ base_url: str = Field(default=DEFAULT_BASE_URL)
30
+ timeout: float = Field(default=DEFAULT_TIMEOUT)
31
+
32
+ @model_validator(mode="before")
33
+ @classmethod
34
+ def _from_env(cls, values: Any) -> Any:
35
+ if not isinstance(values, dict):
36
+ return values
37
+ if not values.get("api_key"):
38
+ key = os.environ.get("SOFYA_API_KEY", "")
39
+ if not key:
40
+ raise ValueError(
41
+ "Sofya API key not found. Pass api_key or set the "
42
+ "SOFYA_API_KEY environment variable. Get a key at "
43
+ "https://sofya.co/dashboard"
44
+ )
45
+ values["api_key"] = key
46
+ if not values.get("base_url"):
47
+ values["base_url"] = os.environ.get("SOFYA_BASE_URL") or DEFAULT_BASE_URL
48
+ return values
49
+
50
+ def _headers(self) -> dict[str, str]:
51
+ return {
52
+ "Authorization": f"Bearer {self.api_key.get_secret_value()}",
53
+ "Content-Type": "application/json",
54
+ "User-Agent": f"langchain-sofya/{__version__}",
55
+ }
56
+
57
+ def _url(self, path: str) -> str:
58
+ return self.base_url.rstrip("/") + path
59
+
60
+ def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
61
+ """POST a JSON body and return the JSON response."""
62
+ try:
63
+ with httpx.Client(timeout=self.timeout) as client:
64
+ resp = client.post(self._url(path), json=payload, headers=self._headers())
65
+ except httpx.HTTPError as exc:
66
+ raise ToolException(f"Sofya request failed: {exc}") from exc
67
+ return _handle(resp)
68
+
69
+ async def apost(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
70
+ """Async version of :meth:`post`."""
71
+ try:
72
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
73
+ resp = await client.post(self._url(path), json=payload, headers=self._headers())
74
+ except httpx.HTTPError as exc:
75
+ raise ToolException(f"Sofya request failed: {exc}") from exc
76
+ return _handle(resp)
77
+
78
+
79
+ def _handle(resp: httpx.Response) -> dict[str, Any]:
80
+ if resp.is_success:
81
+ data = resp.json()
82
+ if not isinstance(data, dict):
83
+ raise ToolException("Sofya returned an unexpected response.")
84
+ return data
85
+ raise ToolException(_error_message(resp))
86
+
87
+
88
+ def _error_message(resp: httpx.Response) -> str:
89
+ detail: Any = None
90
+ try:
91
+ body = resp.json()
92
+ detail = body.get("detail") if isinstance(body, dict) else body
93
+ if isinstance(detail, dict):
94
+ detail = detail.get("message") or detail.get("error") or detail
95
+ except ValueError:
96
+ detail = resp.text[:500] or None
97
+
98
+ status = resp.status_code
99
+ if status == 401:
100
+ prefix = "Sofya API key is missing or invalid."
101
+ elif status == 402:
102
+ prefix = "The Sofya account is out of credits. Top up at https://sofya.co/dashboard."
103
+ elif status == 429:
104
+ prefix = "Sofya rate limit reached; retry shortly."
105
+ elif status == 422:
106
+ prefix = "Sofya rejected the request parameters."
107
+ else:
108
+ prefix = f"Sofya API error (HTTP {status})."
109
+ if detail and not isinstance(detail, str):
110
+ detail = str(detail)
111
+ return f"{prefix} {detail}" if detail and detail not in prefix else prefix
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,350 @@
1
+ """LangChain tools for Sofya: web search, fetch, extract and research."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from langchain_core.callbacks import (
8
+ AsyncCallbackManagerForToolRun,
9
+ CallbackManagerForToolRun,
10
+ )
11
+ from langchain_core.tools import BaseTool
12
+ from pydantic import BaseModel, Field, model_validator
13
+
14
+ from langchain_sofya._client import SofyaAPIWrapper
15
+
16
+ _FRESHNESS = (
17
+ 'Only results from this period: "day", "week", "month", "year", '
18
+ 'or a date range "YYYY-MM-DD:YYYY-MM-DD".'
19
+ )
20
+ _TOPIC = '"general" for web search, "news" for news articles.'
21
+
22
+
23
+ class _SofyaTool(BaseTool):
24
+ """Shared setup: an API wrapper built from api_key / SOFYA_API_KEY."""
25
+
26
+ api_wrapper: SofyaAPIWrapper = Field(default=None) # type: ignore[assignment]
27
+ handle_tool_error: bool = True
28
+
29
+ @model_validator(mode="before")
30
+ @classmethod
31
+ def _build_wrapper(cls, values: Any) -> Any:
32
+ if isinstance(values, dict) and values.get("api_wrapper") is None:
33
+ values["api_wrapper"] = SofyaAPIWrapper(
34
+ **{k: values.pop(k) for k in ("api_key", "base_url", "timeout") if k in values}
35
+ )
36
+ return values
37
+
38
+ def _payload(self, given: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
39
+ """Call arguments win; unset ones fall back to the tool's own settings."""
40
+ out: dict[str, Any] = {}
41
+ for k in keys:
42
+ v = given.get(k)
43
+ if v is None:
44
+ v = getattr(self, k, None)
45
+ if v is not None:
46
+ out[k] = v
47
+ return out
48
+
49
+
50
+ # --------------------------------------------------------------------- search
51
+
52
+
53
+ class SofyaSearchInput(BaseModel):
54
+ """Input for SofyaSearch."""
55
+
56
+ query: str = Field(description="What to search the web for.")
57
+ search_depth: Literal["basic", "snippets"] | None = Field(
58
+ default=None,
59
+ description='"basic" returns the text of each result page (2 credits); '
60
+ '"snippets" returns short snippets only (1 credit).',
61
+ )
62
+ max_results: int | None = Field(
63
+ default=None, ge=1, le=20, description="How many results to return, 1-20."
64
+ )
65
+ include_answer: bool | None = Field(
66
+ default=None,
67
+ description="Add a short AI answer written from the results (+2 credits).",
68
+ )
69
+ include_domains: list[str] | None = Field(
70
+ default=None,
71
+ description='Only return results from these domains, e.g. ["github.com"]. Up to 10.',
72
+ )
73
+ exclude_domains: list[str] | None = Field(
74
+ default=None,
75
+ description='Leave out results from these domains, e.g. ["pinterest.com"]. Up to 10.',
76
+ )
77
+ topic: Literal["general", "news"] | None = Field(default=None, description=_TOPIC)
78
+ freshness: str | None = Field(default=None, description=_FRESHNESS)
79
+
80
+
81
+ _SEARCH_KEYS = (
82
+ "query",
83
+ "search_depth",
84
+ "max_results",
85
+ "include_answer",
86
+ "include_domains",
87
+ "exclude_domains",
88
+ "topic",
89
+ "freshness",
90
+ )
91
+
92
+
93
+ class SofyaSearch(_SofyaTool): # type: ignore[override]
94
+ """Search the web with Sofya.
95
+
96
+ Returns ranked results with the text of each page (or short snippets), and
97
+ optionally a short AI answer.
98
+
99
+ Setup:
100
+ .. code-block:: bash
101
+
102
+ pip install -U langchain-sofya
103
+ export SOFYA_API_KEY="sofya_live_..."
104
+
105
+ Instantiate:
106
+ .. code-block:: python
107
+
108
+ from langchain_sofya import SofyaSearch
109
+
110
+ tool = SofyaSearch(max_results=5, include_answer=True)
111
+
112
+ Invoke:
113
+ .. code-block:: python
114
+
115
+ tool.invoke({"query": "latest stable Python release"})
116
+ """
117
+
118
+ name: str = "sofya_search"
119
+ description: str = (
120
+ "Search the web for current information. Returns ranked results with "
121
+ "title, URL and page text, and optionally a short AI answer. Input is a "
122
+ "search query; you can filter by domain, topic (general or news) and "
123
+ "freshness."
124
+ )
125
+ args_schema: type[BaseModel] = SofyaSearchInput
126
+
127
+ search_depth: Literal["basic", "snippets"] | None = None
128
+ """Default depth when the call does not set one (API default: basic)."""
129
+ max_results: int | None = None
130
+ """Default number of results, 1-20 (API default: 10)."""
131
+ include_answer: bool | None = None
132
+ """Add a short AI answer by default."""
133
+ include_domains: list[str] | None = None
134
+ """Domains to restrict results to by default."""
135
+ exclude_domains: list[str] | None = None
136
+ """Domains to leave out by default."""
137
+ topic: Literal["general", "news"] | None = None
138
+ """Default topic."""
139
+ freshness: str | None = None
140
+ """Default freshness filter."""
141
+
142
+ def _run(
143
+ self,
144
+ query: str,
145
+ run_manager: CallbackManagerForToolRun | None = None,
146
+ **kwargs: Any,
147
+ ) -> dict[str, Any]:
148
+ payload = self._payload({"query": query, **kwargs}, _SEARCH_KEYS)
149
+ return self.api_wrapper.post("/v1/search", payload)
150
+
151
+ async def _arun(
152
+ self,
153
+ query: str,
154
+ run_manager: AsyncCallbackManagerForToolRun | None = None,
155
+ **kwargs: Any,
156
+ ) -> dict[str, Any]:
157
+ payload = self._payload({"query": query, **kwargs}, _SEARCH_KEYS)
158
+ return await self.api_wrapper.apost("/v1/search", payload)
159
+
160
+
161
+ # ---------------------------------------------------------------------- fetch
162
+
163
+
164
+ class SofyaFetchInput(BaseModel):
165
+ """Input for SofyaFetch."""
166
+
167
+ urls: list[str] = Field(
168
+ min_length=1,
169
+ max_length=10,
170
+ description="URLs to read, up to 10. Web pages and documents (PDF, Word, "
171
+ "Excel, PowerPoint and more).",
172
+ )
173
+
174
+
175
+ class SofyaFetch(_SofyaTool): # type: ignore[override]
176
+ """Read web pages and documents as clean markdown with Sofya.
177
+
178
+ Handles HTML pages and documents such as PDF, DOCX, XLSX and PPTX. Costs
179
+ 1 credit per URL; URLs that fail are not charged.
180
+
181
+ Instantiate:
182
+ .. code-block:: python
183
+
184
+ from langchain_sofya import SofyaFetch
185
+
186
+ tool = SofyaFetch()
187
+
188
+ Invoke:
189
+ .. code-block:: python
190
+
191
+ tool.invoke({"urls": ["https://example.com"]})
192
+ """
193
+
194
+ name: str = "sofya_fetch"
195
+ description: str = (
196
+ "Read one or more web pages or documents (PDF, Word, Excel, PowerPoint) "
197
+ "and return their text as clean markdown. Input is a list of up to 10 URLs."
198
+ )
199
+ args_schema: type[BaseModel] = SofyaFetchInput
200
+
201
+ include_raw_html: bool = False
202
+ """Also return each page's raw HTML."""
203
+
204
+ def _run(
205
+ self,
206
+ urls: list[str],
207
+ run_manager: CallbackManagerForToolRun | None = None,
208
+ ) -> dict[str, Any]:
209
+ payload: dict[str, Any] = {"urls": urls}
210
+ if self.include_raw_html:
211
+ payload["include_raw_html"] = True
212
+ return self.api_wrapper.post("/v1/fetch", payload)
213
+
214
+ async def _arun(
215
+ self,
216
+ urls: list[str],
217
+ run_manager: AsyncCallbackManagerForToolRun | None = None,
218
+ ) -> dict[str, Any]:
219
+ payload: dict[str, Any] = {"urls": urls}
220
+ if self.include_raw_html:
221
+ payload["include_raw_html"] = True
222
+ return await self.api_wrapper.apost("/v1/fetch", payload)
223
+
224
+
225
+ # -------------------------------------------------------------------- extract
226
+
227
+
228
+ class SofyaExtractInput(BaseModel):
229
+ """Input for SofyaExtract."""
230
+
231
+ url: str = Field(description="The page or document to read.")
232
+ prompt: str = Field(
233
+ description='What to pull out of it, e.g. "list every pricing tier with its price".'
234
+ )
235
+
236
+
237
+ class SofyaExtract(_SofyaTool): # type: ignore[override]
238
+ """Answer a question about one page with Sofya.
239
+
240
+ Sofya reads the page and an AI model answers the prompt from its text.
241
+ Costs 10 credits (1 when the page has too little text to answer).
242
+
243
+ Instantiate:
244
+ .. code-block:: python
245
+
246
+ from langchain_sofya import SofyaExtract
247
+
248
+ tool = SofyaExtract()
249
+
250
+ Invoke:
251
+ .. code-block:: python
252
+
253
+ tool.invoke({
254
+ "url": "https://en.wikipedia.org/wiki/Eiffel_Tower",
255
+ "prompt": "How tall is it and when did it open?",
256
+ })
257
+ """
258
+
259
+ name: str = "sofya_extract"
260
+ description: str = (
261
+ "Read one web page or document and return just the information asked "
262
+ "for. Input is a URL and a prompt describing what to extract."
263
+ )
264
+ args_schema: type[BaseModel] = SofyaExtractInput
265
+
266
+ def _run(
267
+ self,
268
+ url: str,
269
+ prompt: str,
270
+ run_manager: CallbackManagerForToolRun | None = None,
271
+ ) -> dict[str, Any]:
272
+ return self.api_wrapper.post("/v1/extract", {"url": url, "prompt": prompt})
273
+
274
+ async def _arun(
275
+ self,
276
+ url: str,
277
+ prompt: str,
278
+ run_manager: AsyncCallbackManagerForToolRun | None = None,
279
+ ) -> dict[str, Any]:
280
+ return await self.api_wrapper.apost("/v1/extract", {"url": url, "prompt": prompt})
281
+
282
+
283
+ # ------------------------------------------------------------------- research
284
+
285
+
286
+ class SofyaResearchInput(BaseModel):
287
+ """Input for SofyaResearch."""
288
+
289
+ query: str = Field(description="The research question or topic.")
290
+ topic: Literal["general", "news"] | None = Field(default=None, description=_TOPIC)
291
+ freshness: str | None = Field(default=None, description=_FRESHNESS)
292
+ max_sources: int | None = Field(
293
+ default=None, ge=5, le=30, description="Most sources to read, 5-30."
294
+ )
295
+
296
+
297
+ _RESEARCH_KEYS = ("query", "topic", "freshness", "max_sources")
298
+
299
+
300
+ class SofyaResearch(_SofyaTool): # type: ignore[override]
301
+ """Research a question across many sources with Sofya.
302
+
303
+ Splits the question into several searches, reads the sources and writes a
304
+ report with citations. Costs 50 credits and usually takes 20-60 seconds.
305
+
306
+ Instantiate:
307
+ .. code-block:: python
308
+
309
+ from langchain_sofya import SofyaResearch
310
+
311
+ tool = SofyaResearch(max_sources=15)
312
+
313
+ Invoke:
314
+ .. code-block:: python
315
+
316
+ tool.invoke({"query": "How do heat pumps perform in cold climates?"})
317
+ """
318
+
319
+ name: str = "sofya_research"
320
+ description: str = (
321
+ "Research an open-ended question across many web sources and return a "
322
+ "written report with citations and the source list. Input is the "
323
+ "question."
324
+ )
325
+ args_schema: type[BaseModel] = SofyaResearchInput
326
+
327
+ topic: Literal["general", "news"] | None = None
328
+ """Default topic."""
329
+ freshness: str | None = None
330
+ """Default freshness filter."""
331
+ max_sources: int | None = None
332
+ """Default number of sources, 5-30 (API default: 20)."""
333
+
334
+ def _run(
335
+ self,
336
+ query: str,
337
+ run_manager: CallbackManagerForToolRun | None = None,
338
+ **kwargs: Any,
339
+ ) -> dict[str, Any]:
340
+ payload = self._payload({"query": query, **kwargs}, _RESEARCH_KEYS)
341
+ return self.api_wrapper.post("/v1/research", payload)
342
+
343
+ async def _arun(
344
+ self,
345
+ query: str,
346
+ run_manager: AsyncCallbackManagerForToolRun | None = None,
347
+ **kwargs: Any,
348
+ ) -> dict[str, Any]:
349
+ payload = self._payload({"query": query, **kwargs}, _RESEARCH_KEYS)
350
+ return await self.api_wrapper.apost("/v1/research", payload)
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-sofya"
7
+ dynamic = ["version"]
8
+ description = "LangChain tools for Sofya: web search, page fetch, extraction and cited research for AI agents."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Sofya, Inc.", email = "contact@sofya.co" }]
13
+ requires-python = ">=3.10"
14
+ keywords = ["langchain", "sofya", "web search", "search api", "web scraping", "ai agents", "tools"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "langchain-core>=0.3.15,<2.0.0",
29
+ "httpx>=0.27.0,<1.0.0",
30
+ "pydantic>=2.7.4,<3.0.0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://sofya.co"
35
+ Documentation = "https://sofya.co/docs"
36
+ Repository = "https://github.com/sofya-co/langchain-sofya"
37
+ Issues = "https://github.com/sofya-co/langchain-sofya/issues"
38
+
39
+ [dependency-groups]
40
+ test = [
41
+ "pytest>=8.0",
42
+ "pytest-asyncio>=0.24",
43
+ "pytest-socket>=0.7",
44
+ "langchain-tests>=0.3.20",
45
+ ]
46
+ lint = ["ruff>=0.8", "mypy>=1.13"]
47
+
48
+ [tool.hatch.version]
49
+ path = "langchain_sofya/_version.py"
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ include = ["langchain_sofya", "tests", "README.md", "LICENSE"]
53
+
54
+ [tool.ruff]
55
+ line-length = 100
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "T201", "UP"]
59
+
60
+ [tool.mypy]
61
+ disallow_untyped_defs = true
62
+ plugins = ["pydantic.mypy"]
63
+
64
+ [tool.pytest.ini_options]
65
+ addopts = "--strict-markers --strict-config"
66
+ asyncio_mode = "auto"
67
+ asyncio_default_fixture_loop_scope = "function"
File without changes
@@ -0,0 +1,75 @@
1
+ """LangChain standard integration tests. Need SOFYA_API_KEY; they spend credits.
2
+
3
+ Research is left out by default (50 credits a call); set SOFYA_TEST_RESEARCH=1
4
+ to include it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any
11
+
12
+ import pytest
13
+ from langchain_core.tools import BaseTool
14
+ from langchain_tests.integration_tests import ToolsIntegrationTests
15
+
16
+ from langchain_sofya import SofyaExtract, SofyaFetch, SofyaResearch, SofyaSearch
17
+
18
+ pytestmark = pytest.mark.skipif(not os.environ.get("SOFYA_API_KEY"), reason="SOFYA_API_KEY not set")
19
+
20
+
21
+ class TestSofyaSearchIntegration(ToolsIntegrationTests):
22
+ @property
23
+ def tool_constructor(self) -> type[BaseTool]:
24
+ return SofyaSearch
25
+
26
+ @property
27
+ def tool_constructor_params(self) -> dict[str, Any]:
28
+ return {"max_results": 3, "search_depth": "snippets"}
29
+
30
+ @property
31
+ def tool_invoke_params_example(self) -> dict[str, Any]:
32
+ return {"query": "Python programming language"}
33
+
34
+
35
+ class TestSofyaFetchIntegration(ToolsIntegrationTests):
36
+ @property
37
+ def tool_constructor(self) -> type[BaseTool]:
38
+ return SofyaFetch
39
+
40
+ @property
41
+ def tool_constructor_params(self) -> dict[str, Any]:
42
+ return {}
43
+
44
+ @property
45
+ def tool_invoke_params_example(self) -> dict[str, Any]:
46
+ return {"urls": ["https://example.com"]}
47
+
48
+
49
+ class TestSofyaExtractIntegration(ToolsIntegrationTests):
50
+ @property
51
+ def tool_constructor(self) -> type[BaseTool]:
52
+ return SofyaExtract
53
+
54
+ @property
55
+ def tool_constructor_params(self) -> dict[str, Any]:
56
+ return {}
57
+
58
+ @property
59
+ def tool_invoke_params_example(self) -> dict[str, Any]:
60
+ return {"url": "https://example.com", "prompt": "What is the page's heading?"}
61
+
62
+
63
+ @pytest.mark.skipif(not os.environ.get("SOFYA_TEST_RESEARCH"), reason="set SOFYA_TEST_RESEARCH=1")
64
+ class TestSofyaResearchIntegration(ToolsIntegrationTests):
65
+ @property
66
+ def tool_constructor(self) -> type[BaseTool]:
67
+ return SofyaResearch
68
+
69
+ @property
70
+ def tool_constructor_params(self) -> dict[str, Any]:
71
+ return {"max_sources": 5}
72
+
73
+ @property
74
+ def tool_invoke_params_example(self) -> dict[str, Any]:
75
+ return {"query": "What is the capital of Australia and why was it chosen?"}
File without changes
@@ -0,0 +1,87 @@
1
+ """LangChain standard unit tests for each Sofya tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from langchain_core.tools import BaseTool
8
+ from langchain_tests.unit_tests import ToolsUnitTests
9
+
10
+ from langchain_sofya import SofyaExtract, SofyaFetch, SofyaResearch, SofyaSearch
11
+
12
+ _KEY = "sofya_live_test"
13
+
14
+
15
+ class TestSofyaSearchUnit(ToolsUnitTests):
16
+ @property
17
+ def tool_constructor(self) -> type[BaseTool]:
18
+ return SofyaSearch
19
+
20
+ @property
21
+ def tool_constructor_params(self) -> dict[str, Any]:
22
+ return {"api_key": _KEY, "max_results": 5}
23
+
24
+ @property
25
+ def tool_invoke_params_example(self) -> dict[str, Any]:
26
+ return {"query": "latest stable Python release", "include_answer": True}
27
+
28
+ @property
29
+ def init_from_env_params(self) -> tuple[dict[str, str], dict[str, Any], dict[str, Any]]:
30
+ return {"SOFYA_API_KEY": "sofya_live_env"}, {}, {"name": "sofya_search"}
31
+
32
+
33
+ class TestSofyaFetchUnit(ToolsUnitTests):
34
+ @property
35
+ def tool_constructor(self) -> type[BaseTool]:
36
+ return SofyaFetch
37
+
38
+ @property
39
+ def tool_constructor_params(self) -> dict[str, Any]:
40
+ return {"api_key": _KEY}
41
+
42
+ @property
43
+ def tool_invoke_params_example(self) -> dict[str, Any]:
44
+ return {"urls": ["https://example.com"]}
45
+
46
+ @property
47
+ def init_from_env_params(self) -> tuple[dict[str, str], dict[str, Any], dict[str, Any]]:
48
+ return {"SOFYA_API_KEY": "sofya_live_env"}, {}, {"name": "sofya_fetch"}
49
+
50
+
51
+ class TestSofyaExtractUnit(ToolsUnitTests):
52
+ @property
53
+ def tool_constructor(self) -> type[BaseTool]:
54
+ return SofyaExtract
55
+
56
+ @property
57
+ def tool_constructor_params(self) -> dict[str, Any]:
58
+ return {"api_key": _KEY}
59
+
60
+ @property
61
+ def tool_invoke_params_example(self) -> dict[str, Any]:
62
+ return {
63
+ "url": "https://en.wikipedia.org/wiki/Eiffel_Tower",
64
+ "prompt": "How tall is it and when did it open?",
65
+ }
66
+
67
+ @property
68
+ def init_from_env_params(self) -> tuple[dict[str, str], dict[str, Any], dict[str, Any]]:
69
+ return {"SOFYA_API_KEY": "sofya_live_env"}, {}, {"name": "sofya_extract"}
70
+
71
+
72
+ class TestSofyaResearchUnit(ToolsUnitTests):
73
+ @property
74
+ def tool_constructor(self) -> type[BaseTool]:
75
+ return SofyaResearch
76
+
77
+ @property
78
+ def tool_constructor_params(self) -> dict[str, Any]:
79
+ return {"api_key": _KEY, "max_sources": 10}
80
+
81
+ @property
82
+ def tool_invoke_params_example(self) -> dict[str, Any]:
83
+ return {"query": "How do heat pumps perform in cold climates?"}
84
+
85
+ @property
86
+ def init_from_env_params(self) -> tuple[dict[str, str], dict[str, Any], dict[str, Any]]:
87
+ return {"SOFYA_API_KEY": "sofya_live_env"}, {}, {"name": "sofya_research"}
@@ -0,0 +1,169 @@
1
+ """Behaviour tests with the HTTP layer mocked: payloads, defaults and errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import httpx
9
+ import pytest
10
+ from langchain_core.messages import ToolMessage
11
+ from langchain_core.tools import ToolException
12
+
13
+ from langchain_sofya import (
14
+ SofyaAPIWrapper,
15
+ SofyaExtract,
16
+ SofyaFetch,
17
+ SofyaResearch,
18
+ SofyaSearch,
19
+ __version__,
20
+ )
21
+
22
+
23
+ class _Recorder:
24
+ """Swaps httpx's transport for one that records requests and replies."""
25
+
26
+ def __init__(self, monkeypatch: pytest.MonkeyPatch, status: int = 200, body: Any = None):
27
+ self.requests: list[httpx.Request] = []
28
+ self.body = {"ok": True, "credits_used": 1} if body is None else body
29
+ self.status = status
30
+ transport = httpx.MockTransport(self._handle)
31
+ real_client, real_async = httpx.Client, httpx.AsyncClient
32
+
33
+ def client(*a: Any, **kw: Any) -> httpx.Client:
34
+ return real_client(*a, transport=transport, **kw)
35
+
36
+ def async_client(*a: Any, **kw: Any) -> httpx.AsyncClient:
37
+ return real_async(*a, transport=transport, **kw)
38
+
39
+ monkeypatch.setattr(httpx, "Client", client)
40
+ monkeypatch.setattr(httpx, "AsyncClient", async_client)
41
+
42
+ def _handle(self, request: httpx.Request) -> httpx.Response:
43
+ self.requests.append(request)
44
+ if isinstance(self.body, str):
45
+ return httpx.Response(self.status, text=self.body)
46
+ return httpx.Response(self.status, json=self.body)
47
+
48
+ @property
49
+ def last(self) -> httpx.Request:
50
+ return self.requests[-1]
51
+
52
+ @property
53
+ def last_json(self) -> dict[str, Any]:
54
+ return json.loads(self.last.content)
55
+
56
+
57
+ def test_missing_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
58
+ monkeypatch.delenv("SOFYA_API_KEY", raising=False)
59
+ with pytest.raises(ValueError, match="SOFYA_API_KEY"):
60
+ SofyaSearch()
61
+
62
+
63
+ def test_key_from_env_and_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
64
+ monkeypatch.setenv("SOFYA_API_KEY", "sofya_live_env")
65
+ monkeypatch.setenv("SOFYA_BASE_URL", "http://localhost:9999/")
66
+ w = SofyaAPIWrapper()
67
+ assert w.api_key.get_secret_value() == "sofya_live_env"
68
+ assert w._url("/v1/search") == "http://localhost:9999/v1/search"
69
+
70
+
71
+ def test_key_is_secret() -> None:
72
+ tool = SofyaSearch(api_key="sofya_live_secret")
73
+ assert "sofya_live_secret" not in repr(tool)
74
+
75
+
76
+ def test_search_payload_headers_and_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
77
+ rec = _Recorder(monkeypatch)
78
+ tool = SofyaSearch(api_key="k", max_results=5, include_answer=True, topic="news")
79
+ out = tool.invoke({"query": "q", "max_results": 3, "include_domains": ["github.com"]})
80
+ assert out == {"ok": True, "credits_used": 1}
81
+ assert str(rec.last.url) == "https://sofya.co/v1/search"
82
+ assert rec.last.headers["authorization"] == "Bearer k"
83
+ assert rec.last.headers["user-agent"] == f"langchain-sofya/{__version__}"
84
+ # the call's own value wins, tool defaults fill the rest, unset keys are omitted
85
+ assert rec.last_json == {
86
+ "query": "q",
87
+ "max_results": 3,
88
+ "include_answer": True,
89
+ "include_domains": ["github.com"],
90
+ "topic": "news",
91
+ }
92
+
93
+
94
+ def test_search_minimal_payload(monkeypatch: pytest.MonkeyPatch) -> None:
95
+ rec = _Recorder(monkeypatch)
96
+ SofyaSearch(api_key="k").invoke({"query": "q"})
97
+ assert rec.last_json == {"query": "q"}
98
+
99
+
100
+ def test_fetch(monkeypatch: pytest.MonkeyPatch) -> None:
101
+ rec = _Recorder(monkeypatch)
102
+ SofyaFetch(api_key="k").invoke({"urls": ["https://a.test", "https://b.test"]})
103
+ assert str(rec.last.url).endswith("/v1/fetch")
104
+ assert rec.last_json == {"urls": ["https://a.test", "https://b.test"]}
105
+ SofyaFetch(api_key="k", include_raw_html=True).invoke({"urls": ["https://a.test"]})
106
+ assert rec.last_json == {"urls": ["https://a.test"], "include_raw_html": True}
107
+
108
+
109
+ def test_fetch_rejects_too_many_urls() -> None:
110
+ tool = SofyaFetch(api_key="k")
111
+ with pytest.raises(Exception):
112
+ tool.invoke({"urls": [f"https://x{i}.test" for i in range(11)]})
113
+
114
+
115
+ def test_extract(monkeypatch: pytest.MonkeyPatch) -> None:
116
+ rec = _Recorder(monkeypatch)
117
+ SofyaExtract(api_key="k").invoke({"url": "https://a.test", "prompt": "p"})
118
+ assert str(rec.last.url).endswith("/v1/extract")
119
+ assert rec.last_json == {"url": "https://a.test", "prompt": "p"}
120
+
121
+
122
+ def test_research(monkeypatch: pytest.MonkeyPatch) -> None:
123
+ rec = _Recorder(monkeypatch)
124
+ SofyaResearch(api_key="k", max_sources=10).invoke({"query": "q", "topic": "news"})
125
+ assert str(rec.last.url).endswith("/v1/research")
126
+ assert rec.last_json == {"query": "q", "topic": "news", "max_sources": 10}
127
+
128
+
129
+ async def test_async(monkeypatch: pytest.MonkeyPatch) -> None:
130
+ rec = _Recorder(monkeypatch)
131
+ out = await SofyaSearch(api_key="k").ainvoke({"query": "q"})
132
+ assert out["ok"] is True
133
+ assert rec.last_json == {"query": "q"}
134
+
135
+
136
+ @pytest.mark.parametrize(
137
+ ("status", "body", "expected"),
138
+ [
139
+ (401, {"detail": "Invalid API key"}, "missing or invalid"),
140
+ (402, {"detail": "Insufficient credits"}, "out of credits"),
141
+ (429, {"detail": "Too many requests"}, "rate limit"),
142
+ (422, {"detail": [{"msg": "bad"}]}, "rejected the request"),
143
+ (500, "boom", "HTTP 500"),
144
+ ],
145
+ )
146
+ def test_errors_become_tool_messages(
147
+ monkeypatch: pytest.MonkeyPatch, status: int, body: Any, expected: str
148
+ ) -> None:
149
+ _Recorder(monkeypatch, status=status, body=body)
150
+ tool = SofyaSearch(api_key="k")
151
+ with pytest.raises(ToolException, match=expected):
152
+ tool._run(query="q")
153
+ # Through invoke with a tool call, the error is returned to the model.
154
+ msg = tool.invoke({"name": tool.name, "args": {"query": "q"}, "id": "1", "type": "tool_call"})
155
+ assert isinstance(msg, ToolMessage)
156
+ assert expected in str(msg.content)
157
+
158
+
159
+ def test_network_error(monkeypatch: pytest.MonkeyPatch) -> None:
160
+ real_client = httpx.Client
161
+
162
+ def boom(request: httpx.Request) -> httpx.Response:
163
+ raise httpx.ConnectError("no route")
164
+
165
+ monkeypatch.setattr(
166
+ httpx, "Client", lambda *a, **kw: real_client(*a, transport=httpx.MockTransport(boom), **kw)
167
+ )
168
+ with pytest.raises(ToolException, match="request failed"):
169
+ SofyaSearch(api_key="k")._run(query="q")