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.
- langchain_sofya-0.1.0/.gitignore +9 -0
- langchain_sofya-0.1.0/LICENSE +21 -0
- langchain_sofya-0.1.0/PKG-INFO +118 -0
- langchain_sofya-0.1.0/README.md +90 -0
- langchain_sofya-0.1.0/langchain_sofya/__init__.py +19 -0
- langchain_sofya-0.1.0/langchain_sofya/_client.py +111 -0
- langchain_sofya-0.1.0/langchain_sofya/_version.py +1 -0
- langchain_sofya-0.1.0/langchain_sofya/py.typed +0 -0
- langchain_sofya-0.1.0/langchain_sofya/tools.py +350 -0
- langchain_sofya-0.1.0/pyproject.toml +67 -0
- langchain_sofya-0.1.0/tests/__init__.py +0 -0
- langchain_sofya-0.1.0/tests/integration_tests/__init__.py +0 -0
- langchain_sofya-0.1.0/tests/integration_tests/test_standard.py +75 -0
- langchain_sofya-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_sofya-0.1.0/tests/unit_tests/test_standard.py +87 -0
- langchain_sofya-0.1.0/tests/unit_tests/test_tools.py +169 -0
|
@@ -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
|
|
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")
|