langchain-contextfetch 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_contextfetch-0.1.0/.gitignore +7 -0
- langchain_contextfetch-0.1.0/LICENSE +21 -0
- langchain_contextfetch-0.1.0/PKG-INFO +125 -0
- langchain_contextfetch-0.1.0/README.md +102 -0
- langchain_contextfetch-0.1.0/langchain_contextfetch/__init__.py +19 -0
- langchain_contextfetch-0.1.0/langchain_contextfetch/_utilities.py +55 -0
- langchain_contextfetch-0.1.0/langchain_contextfetch/py.typed +0 -0
- langchain_contextfetch-0.1.0/langchain_contextfetch/retrievers.py +122 -0
- langchain_contextfetch-0.1.0/langchain_contextfetch/tools.py +131 -0
- langchain_contextfetch-0.1.0/pyproject.toml +64 -0
- langchain_contextfetch-0.1.0/tests/__init__.py +0 -0
- langchain_contextfetch-0.1.0/tests/integration_tests/__init__.py +0 -0
- langchain_contextfetch-0.1.0/tests/integration_tests/test_compile.py +7 -0
- langchain_contextfetch-0.1.0/tests/integration_tests/test_retrievers.py +25 -0
- langchain_contextfetch-0.1.0/tests/integration_tests/test_tools.py +25 -0
- langchain_contextfetch-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_contextfetch-0.1.0/tests/unit_tests/conftest.py +58 -0
- langchain_contextfetch-0.1.0/tests/unit_tests/test_imports.py +7 -0
- langchain_contextfetch-0.1.0/tests/unit_tests/test_retrievers.py +76 -0
- langchain_contextfetch-0.1.0/tests/unit_tests/test_tools.py +94 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ContextFetch
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-contextfetch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An integration package connecting ContextFetch and LangChain
|
|
5
|
+
Project-URL: Homepage, https://contextfetch.dev
|
|
6
|
+
Project-URL: Documentation, https://contextfetch.dev/docs/reference
|
|
7
|
+
Project-URL: Source Code, https://github.com/lachy3433/contextfetch/tree/main/integrations/langchain
|
|
8
|
+
Project-URL: Repository, https://github.com/lachy3433/contextfetch
|
|
9
|
+
Author: ContextFetch
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: <4.0.0,>=3.10.0
|
|
20
|
+
Requires-Dist: contextfetch<1.0.0,>=0.1.0
|
|
21
|
+
Requires-Dist: langchain-core<2.0.0,>=1.0.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# langchain-contextfetch
|
|
25
|
+
|
|
26
|
+
This package contains the LangChain integration with [ContextFetch](https://contextfetch.dev), a web search API that returns pages as Markdown.
|
|
27
|
+
|
|
28
|
+
- `ContextFetchRetriever`: a retriever that returns search results as `Document`s.
|
|
29
|
+
- `ContextFetchSearch`: a search tool for agents and tool-calling models.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -U langchain-contextfetch
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Python 3.10 or later.
|
|
38
|
+
|
|
39
|
+
## Credentials
|
|
40
|
+
|
|
41
|
+
Set your API key in the `CONTEXTFETCH_API_KEY` environment variable, or pass `api_key=` to either class:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export CONTEXTFETCH_API_KEY="your-api-key"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Without a key, requests use ContextFetch's keyless tier, which has a small daily allowance per IP address. See the [API reference](https://contextfetch.dev/docs/reference) for keys and credits.
|
|
48
|
+
|
|
49
|
+
## Retriever
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from langchain_contextfetch import ContextFetchRetriever
|
|
53
|
+
|
|
54
|
+
retriever = ContextFetchRetriever(k=3)
|
|
55
|
+
docs = retriever.invoke("pgvector HNSW index parameters")
|
|
56
|
+
|
|
57
|
+
for doc in docs:
|
|
58
|
+
print(doc.metadata["url"], doc.metadata["title"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each `Document` has:
|
|
62
|
+
|
|
63
|
+
- `page_content`: the page as Markdown (`content="text"`, the default), or only the chunks of the page relevant to the query (`content="pruned_context"`).
|
|
64
|
+
- `metadata`: `id`, `url`, `title`, `score`, `published_date` and `author`. Fields the API does not return are `None`.
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
|
|
68
|
+
| Parameter | Default | Description |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| `k` | `5` | Number of results (1 to 25). Can also be passed per call: `retriever.invoke(query, k=10)`. |
|
|
71
|
+
| `content` | `"text"` | `"text"` or `"pruned_context"`. |
|
|
72
|
+
| `include_domains` | `None` | Only return results from these domains. |
|
|
73
|
+
| `exclude_domains` | `None` | Never return results from these domains. |
|
|
74
|
+
| `start_published_date` | `None` | Only return pages published on or after this date (`YYYY-MM-DD`). |
|
|
75
|
+
| `api_key` | `CONTEXTFETCH_API_KEY` | API key. |
|
|
76
|
+
|
|
77
|
+
## Search tool
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from langchain_contextfetch import ContextFetchSearch
|
|
81
|
+
|
|
82
|
+
tool = ContextFetchSearch(num_results=3)
|
|
83
|
+
tool.invoke({"query": "pgvector HNSW index parameters"})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The model can set `query`, `include_domains`, `exclude_domains` and `start_published_date`. `num_results` (default 5, maximum 25), `content` and `api_key` are set when the tool is created. The tool's default `content` is `"pruned_context"`, so each result contributes only its query-relevant chunks to the model's context; use `content="text"` for whole pages.
|
|
87
|
+
|
|
88
|
+
The tool uses `response_format="content_and_artifact"`. Called with plain args as above, it returns the content string: each result's title, URL, published date and content. Called with a `ToolCall`, it returns a `ToolMessage` with that `content` and the raw API response as its `artifact`.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
msg = tool.invoke(
|
|
92
|
+
{
|
|
93
|
+
"args": {"query": "pgvector HNSW index parameters"},
|
|
94
|
+
"id": "1",
|
|
95
|
+
"name": tool.name,
|
|
96
|
+
"type": "tool_call",
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
msg.artifact["results"][0]["url"]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
API errors (for example HTTP 402 when credits run out) are raised as `ToolException`. To pass the error message to the model instead, create the tool with `handle_tool_error=True`.
|
|
103
|
+
|
|
104
|
+
### Use in an agent
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from langchain.agents import create_agent
|
|
108
|
+
|
|
109
|
+
agent = create_agent(model="claude-sonnet-4-6", tools=[tool])
|
|
110
|
+
agent.invoke({"messages": [{"role": "user", "content": "What changed in Python 3.14?"}]})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Async
|
|
114
|
+
|
|
115
|
+
Both classes support `ainvoke`. The underlying `contextfetch` SDK is synchronous, so async calls run in a thread executor (LangChain's default for tools and retrievers).
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
make test # unit tests, network disabled
|
|
121
|
+
make integration_test # live API tests; skipped unless CONTEXTFETCH_API_KEY is set
|
|
122
|
+
make lint
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The unit tests include LangChain's standard `ToolsUnitTests`. The integration tests run the standard `ToolsIntegrationTests` and `RetrieversIntegrationTests` suites, which make 10 searches in total.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# langchain-contextfetch
|
|
2
|
+
|
|
3
|
+
This package contains the LangChain integration with [ContextFetch](https://contextfetch.dev), a web search API that returns pages as Markdown.
|
|
4
|
+
|
|
5
|
+
- `ContextFetchRetriever`: a retriever that returns search results as `Document`s.
|
|
6
|
+
- `ContextFetchSearch`: a search tool for agents and tool-calling models.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install -U langchain-contextfetch
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Python 3.10 or later.
|
|
15
|
+
|
|
16
|
+
## Credentials
|
|
17
|
+
|
|
18
|
+
Set your API key in the `CONTEXTFETCH_API_KEY` environment variable, or pass `api_key=` to either class:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export CONTEXTFETCH_API_KEY="your-api-key"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Without a key, requests use ContextFetch's keyless tier, which has a small daily allowance per IP address. See the [API reference](https://contextfetch.dev/docs/reference) for keys and credits.
|
|
25
|
+
|
|
26
|
+
## Retriever
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from langchain_contextfetch import ContextFetchRetriever
|
|
30
|
+
|
|
31
|
+
retriever = ContextFetchRetriever(k=3)
|
|
32
|
+
docs = retriever.invoke("pgvector HNSW index parameters")
|
|
33
|
+
|
|
34
|
+
for doc in docs:
|
|
35
|
+
print(doc.metadata["url"], doc.metadata["title"])
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Each `Document` has:
|
|
39
|
+
|
|
40
|
+
- `page_content`: the page as Markdown (`content="text"`, the default), or only the chunks of the page relevant to the query (`content="pruned_context"`).
|
|
41
|
+
- `metadata`: `id`, `url`, `title`, `score`, `published_date` and `author`. Fields the API does not return are `None`.
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
|
|
45
|
+
| Parameter | Default | Description |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `k` | `5` | Number of results (1 to 25). Can also be passed per call: `retriever.invoke(query, k=10)`. |
|
|
48
|
+
| `content` | `"text"` | `"text"` or `"pruned_context"`. |
|
|
49
|
+
| `include_domains` | `None` | Only return results from these domains. |
|
|
50
|
+
| `exclude_domains` | `None` | Never return results from these domains. |
|
|
51
|
+
| `start_published_date` | `None` | Only return pages published on or after this date (`YYYY-MM-DD`). |
|
|
52
|
+
| `api_key` | `CONTEXTFETCH_API_KEY` | API key. |
|
|
53
|
+
|
|
54
|
+
## Search tool
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from langchain_contextfetch import ContextFetchSearch
|
|
58
|
+
|
|
59
|
+
tool = ContextFetchSearch(num_results=3)
|
|
60
|
+
tool.invoke({"query": "pgvector HNSW index parameters"})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The model can set `query`, `include_domains`, `exclude_domains` and `start_published_date`. `num_results` (default 5, maximum 25), `content` and `api_key` are set when the tool is created. The tool's default `content` is `"pruned_context"`, so each result contributes only its query-relevant chunks to the model's context; use `content="text"` for whole pages.
|
|
64
|
+
|
|
65
|
+
The tool uses `response_format="content_and_artifact"`. Called with plain args as above, it returns the content string: each result's title, URL, published date and content. Called with a `ToolCall`, it returns a `ToolMessage` with that `content` and the raw API response as its `artifact`.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
msg = tool.invoke(
|
|
69
|
+
{
|
|
70
|
+
"args": {"query": "pgvector HNSW index parameters"},
|
|
71
|
+
"id": "1",
|
|
72
|
+
"name": tool.name,
|
|
73
|
+
"type": "tool_call",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
msg.artifact["results"][0]["url"]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
API errors (for example HTTP 402 when credits run out) are raised as `ToolException`. To pass the error message to the model instead, create the tool with `handle_tool_error=True`.
|
|
80
|
+
|
|
81
|
+
### Use in an agent
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from langchain.agents import create_agent
|
|
85
|
+
|
|
86
|
+
agent = create_agent(model="claude-sonnet-4-6", tools=[tool])
|
|
87
|
+
agent.invoke({"messages": [{"role": "user", "content": "What changed in Python 3.14?"}]})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Async
|
|
91
|
+
|
|
92
|
+
Both classes support `ainvoke`. The underlying `contextfetch` SDK is synchronous, so async calls run in a thread executor (LangChain's default for tools and retrievers).
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
make test # unit tests, network disabled
|
|
98
|
+
make integration_test # live API tests; skipped unless CONTEXTFETCH_API_KEY is set
|
|
99
|
+
make lint
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The unit tests include LangChain's standard `ToolsUnitTests`. The integration tests run the standard `ToolsIntegrationTests` and `RetrieversIntegrationTests` suites, which make 10 searches in total.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""LangChain integration for ContextFetch."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
from langchain_contextfetch.retrievers import ContextFetchRetriever
|
|
6
|
+
from langchain_contextfetch.tools import ContextFetchSearch
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = metadata.version(__package__ or "langchain_contextfetch")
|
|
10
|
+
except metadata.PackageNotFoundError:
|
|
11
|
+
# Case where package metadata is not available.
|
|
12
|
+
__version__ = ""
|
|
13
|
+
del metadata # optional, avoids polluting the results of dir(__package__)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ContextFetchRetriever",
|
|
17
|
+
"ContextFetchSearch",
|
|
18
|
+
"__version__",
|
|
19
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Helpers shared by the retriever and the tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from contextfetch import ContextFetch, Result, SearchResponse
|
|
8
|
+
from pydantic import SecretStr
|
|
9
|
+
|
|
10
|
+
Content = Literal["text", "pruned_context"]
|
|
11
|
+
"""Which result content to use: the whole page as Markdown, or only the chunks
|
|
12
|
+
relevant to the query."""
|
|
13
|
+
|
|
14
|
+
_RESULT_KEY = {"text": "text", "pruned_context": "prunedContext"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def search(
|
|
18
|
+
api_key: SecretStr | None,
|
|
19
|
+
query: str,
|
|
20
|
+
*,
|
|
21
|
+
num_results: int,
|
|
22
|
+
content: Content,
|
|
23
|
+
include_domains: list[str] | None = None,
|
|
24
|
+
exclude_domains: list[str] | None = None,
|
|
25
|
+
start_published_date: str | None = None,
|
|
26
|
+
) -> SearchResponse:
|
|
27
|
+
"""Run one search, asking the API only for the content that will be used."""
|
|
28
|
+
# ContextFetch() falls back to CONTEXTFETCH_API_KEY, then to the keyless tier.
|
|
29
|
+
client = ContextFetch(api_key.get_secret_value() if api_key else None)
|
|
30
|
+
return client.search(
|
|
31
|
+
query,
|
|
32
|
+
num_results=num_results,
|
|
33
|
+
include_domains=include_domains,
|
|
34
|
+
exclude_domains=exclude_domains,
|
|
35
|
+
start_published_date=start_published_date,
|
|
36
|
+
text=content == "text",
|
|
37
|
+
pruned_context=content == "pruned_context",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def page_content(result: Result, content: Content) -> str:
|
|
42
|
+
"""The text of a result for the chosen content type."""
|
|
43
|
+
return result.get(_RESULT_KEY[content]) or "" # type: ignore[return-value]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def metadata(result: Result) -> dict[str, Any]:
|
|
47
|
+
"""Result fields other than the page content."""
|
|
48
|
+
return {
|
|
49
|
+
"id": result.get("id"),
|
|
50
|
+
"url": result.get("url"),
|
|
51
|
+
"title": result.get("title"),
|
|
52
|
+
"score": result.get("score"),
|
|
53
|
+
"published_date": result.get("publishedDate"),
|
|
54
|
+
"author": result.get("author"),
|
|
55
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""ContextFetch retrievers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from langchain_core.callbacks import CallbackManagerForRetrieverRun
|
|
8
|
+
from langchain_core.documents import Document
|
|
9
|
+
from langchain_core.retrievers import BaseRetriever
|
|
10
|
+
from langchain_core.utils import secret_from_env
|
|
11
|
+
from pydantic import Field, SecretStr
|
|
12
|
+
|
|
13
|
+
from langchain_contextfetch._utilities import Content, metadata, page_content, search
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContextFetchRetriever(BaseRetriever):
|
|
17
|
+
"""ContextFetch web search retriever.
|
|
18
|
+
|
|
19
|
+
Setup:
|
|
20
|
+
Install `langchain-contextfetch` and set the environment variable
|
|
21
|
+
`CONTEXTFETCH_API_KEY`. Without a key, requests use the keyless tier.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install -U langchain-contextfetch
|
|
25
|
+
export CONTEXTFETCH_API_KEY="your-api-key"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Key init args:
|
|
29
|
+
k: int
|
|
30
|
+
Number of results to return (1 to 25). Default 5.
|
|
31
|
+
content: "text" | "pruned_context"
|
|
32
|
+
`"text"` uses the whole page as Markdown; `"pruned_context"` uses only
|
|
33
|
+
the chunks of the page relevant to the query. Default `"text"`.
|
|
34
|
+
include_domains: list[str] | None
|
|
35
|
+
Only return results from these domains.
|
|
36
|
+
exclude_domains: list[str] | None
|
|
37
|
+
Never return results from these domains.
|
|
38
|
+
start_published_date: str | None
|
|
39
|
+
Only return pages published on or after this ISO date.
|
|
40
|
+
api_key: SecretStr | None
|
|
41
|
+
ContextFetch API key. Defaults to `CONTEXTFETCH_API_KEY`.
|
|
42
|
+
|
|
43
|
+
Instantiate:
|
|
44
|
+
```python
|
|
45
|
+
from langchain_contextfetch import ContextFetchRetriever
|
|
46
|
+
|
|
47
|
+
retriever = ContextFetchRetriever(k=3)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Usage:
|
|
51
|
+
```python
|
|
52
|
+
docs = retriever.invoke("pgvector HNSW index parameters")
|
|
53
|
+
docs[0].metadata["url"]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use within a chain:
|
|
57
|
+
```python
|
|
58
|
+
from langchain_core.output_parsers import StrOutputParser
|
|
59
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
60
|
+
from langchain_core.runnables import RunnablePassthrough
|
|
61
|
+
from langchain_anthropic import ChatAnthropic
|
|
62
|
+
|
|
63
|
+
prompt = ChatPromptTemplate.from_template(
|
|
64
|
+
\"\"\"Answer the question based only on the context provided.
|
|
65
|
+
|
|
66
|
+
Context: {context}
|
|
67
|
+
|
|
68
|
+
Question: {question}\"\"\"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
model = ChatAnthropic(model="claude-sonnet-4-6")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def format_docs(docs):
|
|
75
|
+
return "\\n\\n".join(doc.page_content for doc in docs)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
chain = (
|
|
79
|
+
{"context": retriever | format_docs, "question": RunnablePassthrough()}
|
|
80
|
+
| prompt
|
|
81
|
+
| model
|
|
82
|
+
| StrOutputParser()
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
chain.invoke("How do I tune HNSW in pgvector?")
|
|
86
|
+
```
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
k: int = Field(default=5, ge=1, le=25)
|
|
90
|
+
"""Number of results to return (1 to 25)."""
|
|
91
|
+
content: Content = "text"
|
|
92
|
+
"""`"text"` (whole page as Markdown, the default) or `"pruned_context"`
|
|
93
|
+
(query-relevant chunks of each page)."""
|
|
94
|
+
include_domains: list[str] | None = None
|
|
95
|
+
"""Only return results from these domains."""
|
|
96
|
+
exclude_domains: list[str] | None = None
|
|
97
|
+
"""Never return results from these domains."""
|
|
98
|
+
start_published_date: str | None = None
|
|
99
|
+
"""Only return pages published on or after this ISO date (YYYY-MM-DD)."""
|
|
100
|
+
api_key: SecretStr | None = Field(
|
|
101
|
+
default_factory=secret_from_env("CONTEXTFETCH_API_KEY", default=None)
|
|
102
|
+
)
|
|
103
|
+
"""ContextFetch API key. Defaults to the `CONTEXTFETCH_API_KEY` env var."""
|
|
104
|
+
|
|
105
|
+
# Async: BaseRetriever runs this method in a thread executor, since the
|
|
106
|
+
# contextfetch SDK is synchronous (stdlib urllib).
|
|
107
|
+
def _get_relevant_documents(
|
|
108
|
+
self, query: str, *, run_manager: CallbackManagerForRetrieverRun, **kwargs: Any
|
|
109
|
+
) -> list[Document]:
|
|
110
|
+
response = search(
|
|
111
|
+
self.api_key,
|
|
112
|
+
query,
|
|
113
|
+
num_results=kwargs.get("k", self.k),
|
|
114
|
+
content=self.content,
|
|
115
|
+
include_domains=self.include_domains,
|
|
116
|
+
exclude_domains=self.exclude_domains,
|
|
117
|
+
start_published_date=self.start_published_date,
|
|
118
|
+
)
|
|
119
|
+
return [
|
|
120
|
+
Document(page_content=page_content(r, self.content), metadata=metadata(r))
|
|
121
|
+
for r in response.get("results", [])
|
|
122
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""ContextFetch tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from contextfetch import ContextFetchError, SearchResponse
|
|
8
|
+
from langchain_core.callbacks import CallbackManagerForToolRun
|
|
9
|
+
from langchain_core.tools import BaseTool, ToolException
|
|
10
|
+
from langchain_core.utils import secret_from_env
|
|
11
|
+
from pydantic import BaseModel, Field, SecretStr
|
|
12
|
+
|
|
13
|
+
from langchain_contextfetch._utilities import Content, page_content, search
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContextFetchSearchInput(BaseModel):
|
|
17
|
+
"""Input schema for the ContextFetch search tool.
|
|
18
|
+
|
|
19
|
+
The Field descriptions are sent to the model when performing tool calling.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
query: str = Field(description="The web search query.")
|
|
23
|
+
include_domains: list[str] | None = Field(
|
|
24
|
+
default=None, description="Only return results from these domains."
|
|
25
|
+
)
|
|
26
|
+
exclude_domains: list[str] | None = Field(
|
|
27
|
+
default=None, description="Never return results from these domains."
|
|
28
|
+
)
|
|
29
|
+
start_published_date: str | None = Field(
|
|
30
|
+
default=None,
|
|
31
|
+
description="Only return pages published on or after this date (YYYY-MM-DD).",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ContextFetchSearch(BaseTool): # type: ignore[override]
|
|
36
|
+
"""ContextFetch web search tool.
|
|
37
|
+
|
|
38
|
+
Setup:
|
|
39
|
+
Install `langchain-contextfetch` and set the environment variable
|
|
40
|
+
`CONTEXTFETCH_API_KEY`. Without a key, requests use the keyless tier.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -U langchain-contextfetch
|
|
44
|
+
export CONTEXTFETCH_API_KEY="your-api-key"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Instantiation:
|
|
48
|
+
```python
|
|
49
|
+
from langchain_contextfetch import ContextFetchSearch
|
|
50
|
+
|
|
51
|
+
tool = ContextFetchSearch(num_results=3)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Invocation with args:
|
|
55
|
+
```python
|
|
56
|
+
tool.invoke({"query": "pgvector HNSW index parameters"})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Invocation with ToolCall:
|
|
60
|
+
```python
|
|
61
|
+
tool.invoke(
|
|
62
|
+
{
|
|
63
|
+
"args": {"query": "pgvector HNSW index parameters"},
|
|
64
|
+
"id": "1",
|
|
65
|
+
"name": tool.name,
|
|
66
|
+
"type": "tool_call",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Returns a `ToolMessage` whose `content` lists each result's title, URL,
|
|
72
|
+
published date and page content, and whose `artifact` is the raw API
|
|
73
|
+
response (a dict with `results`).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
name: str = "contextfetch_search"
|
|
77
|
+
"""The name that is passed to the model when performing tool calling."""
|
|
78
|
+
description: str = (
|
|
79
|
+
"Search the web. Returns the top results, each with its title, URL, "
|
|
80
|
+
"published date and page content as Markdown."
|
|
81
|
+
)
|
|
82
|
+
"""The description that is passed to the model when performing tool calling."""
|
|
83
|
+
args_schema: type[BaseModel] = ContextFetchSearchInput
|
|
84
|
+
"""The schema that is passed to the model when performing tool calling."""
|
|
85
|
+
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
|
86
|
+
|
|
87
|
+
num_results: int = Field(default=5, ge=1, le=25)
|
|
88
|
+
"""Number of results per search (1 to 25)."""
|
|
89
|
+
content: Content = "pruned_context"
|
|
90
|
+
"""`"pruned_context"` (query-relevant chunks of each page, the default) or
|
|
91
|
+
`"text"` (whole page as Markdown)."""
|
|
92
|
+
api_key: SecretStr | None = Field(
|
|
93
|
+
default_factory=secret_from_env("CONTEXTFETCH_API_KEY", default=None)
|
|
94
|
+
)
|
|
95
|
+
"""ContextFetch API key. Defaults to the `CONTEXTFETCH_API_KEY` env var."""
|
|
96
|
+
|
|
97
|
+
# Async: BaseTool runs this method in a thread executor, since the
|
|
98
|
+
# contextfetch SDK is synchronous (stdlib urllib).
|
|
99
|
+
def _run(
|
|
100
|
+
self,
|
|
101
|
+
query: str,
|
|
102
|
+
include_domains: list[str] | None = None,
|
|
103
|
+
exclude_domains: list[str] | None = None,
|
|
104
|
+
start_published_date: str | None = None,
|
|
105
|
+
*,
|
|
106
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
107
|
+
) -> tuple[str, SearchResponse]:
|
|
108
|
+
try:
|
|
109
|
+
response = search(
|
|
110
|
+
self.api_key,
|
|
111
|
+
query,
|
|
112
|
+
num_results=self.num_results,
|
|
113
|
+
content=self.content,
|
|
114
|
+
include_domains=include_domains,
|
|
115
|
+
exclude_domains=exclude_domains,
|
|
116
|
+
start_published_date=start_published_date,
|
|
117
|
+
)
|
|
118
|
+
except ContextFetchError as e:
|
|
119
|
+
# Raised as ToolException so handle_tool_error can pass it to the model.
|
|
120
|
+
raise ToolException(str(e)) from e
|
|
121
|
+
results = response.get("results", [])
|
|
122
|
+
if not results:
|
|
123
|
+
return "No results found.", response
|
|
124
|
+
content = "\n\n---\n\n".join(
|
|
125
|
+
f"Title: {r.get('title') or ''}\n"
|
|
126
|
+
f"URL: {r.get('url', '')}\n"
|
|
127
|
+
f"Published: {r.get('publishedDate') or 'unknown'}\n\n"
|
|
128
|
+
f"{page_content(r, self.content)}"
|
|
129
|
+
for r in results
|
|
130
|
+
)
|
|
131
|
+
return content, response
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-contextfetch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An integration package connecting ContextFetch and LangChain"
|
|
9
|
+
authors = [{ name = "ContextFetch" }]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.10.0,<4.0.0"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"langchain-core>=1.0.0,<2.0.0",
|
|
24
|
+
"contextfetch>=0.1.0,<1.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://contextfetch.dev"
|
|
29
|
+
Documentation = "https://contextfetch.dev/docs/reference"
|
|
30
|
+
"Source Code" = "https://github.com/lachy3433/contextfetch/tree/main/integrations/langchain"
|
|
31
|
+
Repository = "https://github.com/lachy3433/contextfetch"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
test = [
|
|
35
|
+
"pytest>=9.0.3,<10.0.0",
|
|
36
|
+
"pytest-asyncio>=1.3.0,<2.0.0",
|
|
37
|
+
"pytest-socket>=0.7.0,<1.0.0",
|
|
38
|
+
"langchain-tests==1.1.9",
|
|
39
|
+
]
|
|
40
|
+
test_integration = []
|
|
41
|
+
lint = ["ruff>=0.13.1,<0.14.0"]
|
|
42
|
+
typing = ["mypy>=1.10,<3.0"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
only-include = ["langchain_contextfetch", "tests", "README.md", "LICENSE"]
|
|
46
|
+
|
|
47
|
+
[tool.mypy]
|
|
48
|
+
disallow_untyped_defs = true
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E", "F", "I", "T201"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
54
|
+
ban-relative-imports = "all"
|
|
55
|
+
|
|
56
|
+
[tool.coverage.run]
|
|
57
|
+
omit = ["tests/*"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
addopts = "--strict-markers --strict-config --durations=5"
|
|
61
|
+
markers = [
|
|
62
|
+
"compile: mark placeholder test used to compile integration tests without running them",
|
|
63
|
+
]
|
|
64
|
+
asyncio_mode = "auto"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from langchain_tests.integration_tests import RetrieversIntegrationTests
|
|
5
|
+
|
|
6
|
+
from langchain_contextfetch import ContextFetchRetriever
|
|
7
|
+
|
|
8
|
+
# These call the live API (1 credit per search; this suite makes 6 searches).
|
|
9
|
+
pytestmark = pytest.mark.skipif(
|
|
10
|
+
not os.environ.get("CONTEXTFETCH_API_KEY"), reason="CONTEXTFETCH_API_KEY not set"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestContextFetchRetriever(RetrieversIntegrationTests):
|
|
15
|
+
@property
|
|
16
|
+
def retriever_constructor(self) -> type[ContextFetchRetriever]:
|
|
17
|
+
return ContextFetchRetriever
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def retriever_constructor_params(self) -> dict:
|
|
21
|
+
return {"k": 2}
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def retriever_query_example(self) -> str:
|
|
25
|
+
return "python asyncio tutorial"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from langchain_tests.integration_tests import ToolsIntegrationTests
|
|
5
|
+
|
|
6
|
+
from langchain_contextfetch import ContextFetchSearch
|
|
7
|
+
|
|
8
|
+
# These call the live API (1 credit per search; this suite makes 4 searches).
|
|
9
|
+
pytestmark = pytest.mark.skipif(
|
|
10
|
+
not os.environ.get("CONTEXTFETCH_API_KEY"), reason="CONTEXTFETCH_API_KEY not set"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestContextFetchSearch(ToolsIntegrationTests):
|
|
15
|
+
@property
|
|
16
|
+
def tool_constructor(self) -> type[ContextFetchSearch]:
|
|
17
|
+
return ContextFetchSearch
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def tool_constructor_params(self) -> dict:
|
|
21
|
+
return {"num_results": 2}
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def tool_invoke_params_example(self) -> dict:
|
|
25
|
+
return {"query": "python asyncio tutorial"}
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Unit test fixtures: a fake ContextFetch transport, so no test touches the network."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from contextfetch import ContextFetch
|
|
10
|
+
|
|
11
|
+
RESULTS: list[dict[str, Any]] = [
|
|
12
|
+
{
|
|
13
|
+
"id": "r1",
|
|
14
|
+
"url": "https://example.com/a",
|
|
15
|
+
"title": "Page A",
|
|
16
|
+
"score": 0.9,
|
|
17
|
+
"publishedDate": "2026-01-02",
|
|
18
|
+
"text": "# Page A\n\nFull Markdown of A.",
|
|
19
|
+
"prunedContext": "Relevant chunk of A.",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "r2",
|
|
23
|
+
"url": "https://example.com/b",
|
|
24
|
+
"title": None,
|
|
25
|
+
"score": 0.5,
|
|
26
|
+
"text": "# Page B",
|
|
27
|
+
"prunedContext": "Relevant chunk of B.",
|
|
28
|
+
},
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeAPI:
|
|
33
|
+
"""Records each request body and answers with RESULTS (or raises `error`)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self.calls: list[dict[str, Any]] = []
|
|
37
|
+
self.results: list[dict[str, Any]] = RESULTS
|
|
38
|
+
self.error: Exception | None = None
|
|
39
|
+
|
|
40
|
+
def request(
|
|
41
|
+
self, client: ContextFetch, method: str, path: str, body: Any = None, **_: Any
|
|
42
|
+
) -> tuple[bytes, dict]:
|
|
43
|
+
self.calls.append({"path": path, "body": body, "api_key": client.api_key})
|
|
44
|
+
if self.error:
|
|
45
|
+
raise self.error
|
|
46
|
+
response = {"requestId": "req", "results": self.results[: body["numResults"]]}
|
|
47
|
+
return json.dumps(response).encode(), {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def fake_api(monkeypatch: pytest.MonkeyPatch) -> FakeAPI:
|
|
52
|
+
api = FakeAPI()
|
|
53
|
+
monkeypatch.delenv("CONTEXTFETCH_API_KEY", raising=False)
|
|
54
|
+
# Patch only the SDK's HTTP call, so its request building and parsing still run.
|
|
55
|
+
monkeypatch.setattr(
|
|
56
|
+
ContextFetch, "_request", lambda self, *a, **k: api.request(self, *a, **k)
|
|
57
|
+
)
|
|
58
|
+
return api
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from langchain_core.documents import Document
|
|
5
|
+
from pydantic import SecretStr
|
|
6
|
+
|
|
7
|
+
from langchain_contextfetch import ContextFetchRetriever
|
|
8
|
+
from tests.unit_tests.conftest import FakeAPI
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_invoke_returns_markdown_documents(fake_api: FakeAPI) -> None:
|
|
12
|
+
retriever = ContextFetchRetriever(
|
|
13
|
+
k=2,
|
|
14
|
+
api_key=SecretStr("test-key"),
|
|
15
|
+
exclude_domains=["pinterest.com"],
|
|
16
|
+
start_published_date="2026-01-01",
|
|
17
|
+
)
|
|
18
|
+
docs = retriever.invoke("q")
|
|
19
|
+
|
|
20
|
+
assert docs == [
|
|
21
|
+
Document(
|
|
22
|
+
page_content="# Page A\n\nFull Markdown of A.",
|
|
23
|
+
metadata={
|
|
24
|
+
"id": "r1",
|
|
25
|
+
"url": "https://example.com/a",
|
|
26
|
+
"title": "Page A",
|
|
27
|
+
"score": 0.9,
|
|
28
|
+
"published_date": "2026-01-02",
|
|
29
|
+
"author": None,
|
|
30
|
+
},
|
|
31
|
+
),
|
|
32
|
+
Document(
|
|
33
|
+
page_content="# Page B",
|
|
34
|
+
metadata={
|
|
35
|
+
"id": "r2",
|
|
36
|
+
"url": "https://example.com/b",
|
|
37
|
+
"title": None,
|
|
38
|
+
"score": 0.5,
|
|
39
|
+
"published_date": None,
|
|
40
|
+
"author": None,
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
]
|
|
44
|
+
assert fake_api.calls == [
|
|
45
|
+
{
|
|
46
|
+
"path": "/v1/search",
|
|
47
|
+
"body": {
|
|
48
|
+
"query": "q",
|
|
49
|
+
"numResults": 2,
|
|
50
|
+
"excludeDomains": ["pinterest.com"],
|
|
51
|
+
"startPublishedDate": "2026-01-01",
|
|
52
|
+
"contents": {"text": True},
|
|
53
|
+
},
|
|
54
|
+
"api_key": "test-key",
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_pruned_context_and_k_kwarg(fake_api: FakeAPI) -> None:
|
|
60
|
+
retriever = ContextFetchRetriever(k=5, content="pruned_context")
|
|
61
|
+
docs = retriever.invoke("q", k=1)
|
|
62
|
+
assert [d.page_content for d in docs] == ["Relevant chunk of A."]
|
|
63
|
+
assert fake_api.calls[0]["body"]["numResults"] == 1
|
|
64
|
+
assert fake_api.calls[0]["body"]["contents"] == {"pruned_context": True}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_key_from_env(fake_api: FakeAPI, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
68
|
+
monkeypatch.setenv("CONTEXTFETCH_API_KEY", "env-key")
|
|
69
|
+
ContextFetchRetriever().invoke("q")
|
|
70
|
+
assert fake_api.calls[0]["api_key"] == "env-key"
|
|
71
|
+
assert fake_api.calls[0]["body"]["numResults"] == 5
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def test_ainvoke(fake_api: FakeAPI) -> None:
|
|
75
|
+
docs = await ContextFetchRetriever(k=1).ainvoke("q")
|
|
76
|
+
assert docs[0].metadata["url"] == "https://example.com/a"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from contextfetch import PaymentRequiredError
|
|
5
|
+
from langchain_core.messages import ToolMessage
|
|
6
|
+
from langchain_core.tools import ToolException
|
|
7
|
+
from langchain_tests.unit_tests import ToolsUnitTests
|
|
8
|
+
from pydantic import SecretStr
|
|
9
|
+
|
|
10
|
+
from langchain_contextfetch import ContextFetchSearch
|
|
11
|
+
from tests.unit_tests.conftest import FakeAPI
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestContextFetchSearchStandard(ToolsUnitTests):
|
|
15
|
+
@property
|
|
16
|
+
def tool_constructor(self) -> type[ContextFetchSearch]:
|
|
17
|
+
return ContextFetchSearch
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def tool_constructor_params(self) -> dict:
|
|
21
|
+
return {"api_key": "test-key"}
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def tool_invoke_params_example(self) -> dict:
|
|
25
|
+
return {"query": "pgvector HNSW index parameters"}
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def init_from_env_params(self) -> tuple[dict, dict, dict]:
|
|
29
|
+
return {"CONTEXTFETCH_API_KEY": "env-key"}, {}, {"api_key": "env-key"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tool_call(args: dict) -> dict:
|
|
33
|
+
return {"args": args, "id": "1", "name": "contextfetch_search", "type": "tool_call"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_tool_call_returns_content_and_artifact(fake_api: FakeAPI) -> None:
|
|
37
|
+
tool = ContextFetchSearch(num_results=2, api_key=SecretStr("test-key"))
|
|
38
|
+
msg = tool.invoke(_tool_call({"query": "q", "include_domains": ["example.com"]}))
|
|
39
|
+
|
|
40
|
+
assert isinstance(msg, ToolMessage)
|
|
41
|
+
assert "URL: https://example.com/a" in msg.content
|
|
42
|
+
assert "Relevant chunk of A." in msg.content # pruned_context is the default
|
|
43
|
+
assert "Full Markdown of A." not in msg.content
|
|
44
|
+
assert "Published: unknown" in msg.content # result b has no publishedDate
|
|
45
|
+
assert msg.artifact["results"][0]["id"] == "r1"
|
|
46
|
+
assert fake_api.calls == [
|
|
47
|
+
{
|
|
48
|
+
"path": "/v1/search",
|
|
49
|
+
"body": {
|
|
50
|
+
"query": "q",
|
|
51
|
+
"numResults": 2,
|
|
52
|
+
"includeDomains": ["example.com"],
|
|
53
|
+
"contents": {"pruned_context": True},
|
|
54
|
+
},
|
|
55
|
+
"api_key": "test-key",
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_text_content(fake_api: FakeAPI) -> None:
|
|
61
|
+
tool = ContextFetchSearch(content="text")
|
|
62
|
+
msg = tool.invoke(_tool_call({"query": "q"}))
|
|
63
|
+
assert "Full Markdown of A." in msg.content
|
|
64
|
+
assert fake_api.calls[0]["body"]["contents"] == {"text": True}
|
|
65
|
+
assert fake_api.calls[0]["api_key"] is None # keyless tier
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_no_results(fake_api: FakeAPI) -> None:
|
|
69
|
+
fake_api.results = []
|
|
70
|
+
msg = ContextFetchSearch().invoke(_tool_call({"query": "q"}))
|
|
71
|
+
assert msg.content == "No results found."
|
|
72
|
+
assert msg.artifact == {"requestId": "req", "results": []}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_api_error_is_tool_exception(fake_api: FakeAPI) -> None:
|
|
76
|
+
fake_api.error = PaymentRequiredError("HTTP 402 Buy credits.", status=402)
|
|
77
|
+
with pytest.raises(ToolException, match="Buy credits"):
|
|
78
|
+
ContextFetchSearch().invoke({"query": "q"})
|
|
79
|
+
|
|
80
|
+
tool = ContextFetchSearch(handle_tool_error=True)
|
|
81
|
+
msg = tool.invoke(_tool_call({"query": "q"}))
|
|
82
|
+
assert msg.status == "error"
|
|
83
|
+
assert "Buy credits" in msg.content
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def test_async_invoke(fake_api: FakeAPI) -> None:
|
|
87
|
+
msg = await ContextFetchSearch().ainvoke(_tool_call({"query": "q"}))
|
|
88
|
+
assert "Page A" in msg.content
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_api_key_is_secret() -> None:
|
|
92
|
+
tool = ContextFetchSearch(api_key="test-key") # type: ignore[arg-type]
|
|
93
|
+
assert tool.api_key == SecretStr("test-key")
|
|
94
|
+
assert "test-key" not in repr(tool)
|