langchain-doccheap 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_doccheap-0.1.0/.gitignore +11 -0
- langchain_doccheap-0.1.0/CHANGELOG.md +7 -0
- langchain_doccheap-0.1.0/LICENSE +21 -0
- langchain_doccheap-0.1.0/PKG-INFO +127 -0
- langchain_doccheap-0.1.0/README.md +89 -0
- langchain_doccheap-0.1.0/pyproject.toml +71 -0
- langchain_doccheap-0.1.0/src/langchain_doccheap/__init__.py +29 -0
- langchain_doccheap-0.1.0/src/langchain_doccheap/_version.py +1 -0
- langchain_doccheap-0.1.0/src/langchain_doccheap/py.typed +0 -0
- langchain_doccheap-0.1.0/src/langchain_doccheap/toolkit.py +56 -0
- langchain_doccheap-0.1.0/src/langchain_doccheap/tools.py +207 -0
- langchain_doccheap-0.1.0/tests/__init__.py +0 -0
- langchain_doccheap-0.1.0/tests/conftest.py +46 -0
- langchain_doccheap-0.1.0/tests/test_standard.py +78 -0
- langchain_doccheap-0.1.0/tests/test_tools.py +175 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Document Cheap 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,127 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-doccheap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain tools for doc.cheap: read passports, ID cards and driver's licences into JSON.
|
|
5
|
+
Project-URL: Homepage, https://doc.cheap
|
|
6
|
+
Project-URL: Documentation, https://doc.cheap/docs
|
|
7
|
+
Project-URL: Source, https://gitlab.com/doccheap/langchain-doccheap
|
|
8
|
+
Project-URL: Changelog, https://gitlab.com/doccheap/langchain-doccheap/-/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Teoh Chin Heng <admin@doc.cheap>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: doc.cheap,document-recognition,id-card,kyc,langchain,mrz,ocr,passport,tools
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: doc-cheap<1,>=0.1.0
|
|
26
|
+
Requires-Dist: langchain-core<2,>=1.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: langchain-tests<2,>=1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: langchain-tests<2,>=1.0; extra == 'test'
|
|
35
|
+
Requires-Dist: pytest-cov>=5; extra == 'test'
|
|
36
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# langchain-doccheap
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/langchain-doccheap/)
|
|
42
|
+
|
|
43
|
+
[LangChain](https://python.langchain.com/) tools for
|
|
44
|
+
[doc.cheap](https://doc.cheap): an agent gives them a photo or scan of a
|
|
45
|
+
passport, national ID card or driving licence and gets back the printed fields –
|
|
46
|
+
name, date of birth, document number, expiry, the machine-readable zone – as
|
|
47
|
+
JSON.
|
|
48
|
+
|
|
49
|
+
One recognised document costs one credit, $0.01. Registering gives 100 free
|
|
50
|
+
documents every month. An unreadable image, an empty frame or an unsupported
|
|
51
|
+
document type costs nothing, and every answer says whether it was billed.
|
|
52
|
+
|
|
53
|
+
The package needs Python 3.10 or later. It is built on the
|
|
54
|
+
[doc-cheap](https://pypi.org/project/doc-cheap/) client and `langchain-core` 1.x.
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
pip install langchain-doccheap
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## The tools
|
|
63
|
+
|
|
64
|
+
| Tool | Name the model sees | What it does |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `DocCheapScanDocument` | `doc_cheap_scan_document` | Reads one JPEG or PNG image, from `image_path` or `image_base64`, with an optional `reference` label and `expect_country` (ISO 3166-1 alpha-3). |
|
|
67
|
+
| `DocCheapGetScan` | `doc_cheap_get_scan` | Returns a stored result by `scan_id`. |
|
|
68
|
+
| `DocCheapListScans` | `doc_cheap_list_scans` | Returns one page of stored results (`limit`, `cursor`). |
|
|
69
|
+
| `DocCheapDeleteScan` | `doc_cheap_delete_scan` | Deletes a stored result by `scan_id`. |
|
|
70
|
+
| `DocCheapGetUsage` | `doc_cheap_get_usage` | Returns this month's free credits, the paid credits, the credits spent and the scan counters. |
|
|
71
|
+
|
|
72
|
+
Every tool returns the API's answer as a JSON string. An API error – an unknown
|
|
73
|
+
scan, a used-up rate limit, a wrong key – comes back to the model as a short
|
|
74
|
+
text such as `doc.cheap error: 404 scan_not_found: …` instead of stopping the
|
|
75
|
+
agent; pass `handle_tool_error=False` to have it raised as a `ToolException`.
|
|
76
|
+
|
|
77
|
+
## Quick start
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from langchain_doccheap import DocCheapScanDocument
|
|
81
|
+
|
|
82
|
+
scan = DocCheapScanDocument(api_key="sk_sandbox_public")
|
|
83
|
+
print(scan.invoke({"image_path": "passport.jpg"}))
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
With an agent, take all five tools from the toolkit. They share one client and
|
|
87
|
+
its connections:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from langchain.agents import create_agent
|
|
91
|
+
from langchain_doccheap import DocCheapToolkit
|
|
92
|
+
|
|
93
|
+
tools = DocCheapToolkit().get_tools() # reads DOC_CHEAP_API_KEY
|
|
94
|
+
agent = create_agent("openai:gpt-4.1-mini", tools)
|
|
95
|
+
agent.invoke({"messages": [{"role": "user", "content": "Read the passport in ./passport.jpg"}]})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`DocCheapToolkit(include_delete=False)` leaves out the tool that deletes stored
|
|
99
|
+
results.
|
|
100
|
+
|
|
101
|
+
## API keys
|
|
102
|
+
|
|
103
|
+
- **Live key** – `sk_live_…`, created in the
|
|
104
|
+
[doc.cheap cabinet](https://doc.cheap/docs/concepts/api-keys-and-sessions).
|
|
105
|
+
- **Sandbox key** – `sk_sandbox_public` works without an account. It gives 10
|
|
106
|
+
free recognised documents per address in all, and at most 10 requests per
|
|
107
|
+
address an hour, whatever their answer.
|
|
108
|
+
|
|
109
|
+
Without `api_key` the tools read the key from the `DOC_CHEAP_API_KEY`
|
|
110
|
+
environment variable. `base_url` (or `DOC_CHEAP_API_BASE`) points them at
|
|
111
|
+
another address. To control timeouts, proxies or connection pooling, build a
|
|
112
|
+
`doc_cheap.Client` yourself and pass it as `client=`; the tools leave it open.
|
|
113
|
+
|
|
114
|
+
**Stored results.** A result is stored only when the scan was made with a live
|
|
115
|
+
key under a non-zero retention window. Under the sandbox key nothing is stored,
|
|
116
|
+
so getting or deleting a scan answers `scan_not_found`.
|
|
117
|
+
|
|
118
|
+
## Resources
|
|
119
|
+
|
|
120
|
+
- [doc.cheap documentation](https://doc.cheap/docs)
|
|
121
|
+
- [Your first recognition](https://doc.cheap/docs/start/first-recognition)
|
|
122
|
+
- [API reference](https://doc.cheap/docs/reference)
|
|
123
|
+
- [The doc-cheap Python client](https://pypi.org/project/doc-cheap/)
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# langchain-doccheap
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/langchain-doccheap/)
|
|
4
|
+
|
|
5
|
+
[LangChain](https://python.langchain.com/) tools for
|
|
6
|
+
[doc.cheap](https://doc.cheap): an agent gives them a photo or scan of a
|
|
7
|
+
passport, national ID card or driving licence and gets back the printed fields –
|
|
8
|
+
name, date of birth, document number, expiry, the machine-readable zone – as
|
|
9
|
+
JSON.
|
|
10
|
+
|
|
11
|
+
One recognised document costs one credit, $0.01. Registering gives 100 free
|
|
12
|
+
documents every month. An unreadable image, an empty frame or an unsupported
|
|
13
|
+
document type costs nothing, and every answer says whether it was billed.
|
|
14
|
+
|
|
15
|
+
The package needs Python 3.10 or later. It is built on the
|
|
16
|
+
[doc-cheap](https://pypi.org/project/doc-cheap/) client and `langchain-core` 1.x.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install langchain-doccheap
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## The tools
|
|
25
|
+
|
|
26
|
+
| Tool | Name the model sees | What it does |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `DocCheapScanDocument` | `doc_cheap_scan_document` | Reads one JPEG or PNG image, from `image_path` or `image_base64`, with an optional `reference` label and `expect_country` (ISO 3166-1 alpha-3). |
|
|
29
|
+
| `DocCheapGetScan` | `doc_cheap_get_scan` | Returns a stored result by `scan_id`. |
|
|
30
|
+
| `DocCheapListScans` | `doc_cheap_list_scans` | Returns one page of stored results (`limit`, `cursor`). |
|
|
31
|
+
| `DocCheapDeleteScan` | `doc_cheap_delete_scan` | Deletes a stored result by `scan_id`. |
|
|
32
|
+
| `DocCheapGetUsage` | `doc_cheap_get_usage` | Returns this month's free credits, the paid credits, the credits spent and the scan counters. |
|
|
33
|
+
|
|
34
|
+
Every tool returns the API's answer as a JSON string. An API error – an unknown
|
|
35
|
+
scan, a used-up rate limit, a wrong key – comes back to the model as a short
|
|
36
|
+
text such as `doc.cheap error: 404 scan_not_found: …` instead of stopping the
|
|
37
|
+
agent; pass `handle_tool_error=False` to have it raised as a `ToolException`.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from langchain_doccheap import DocCheapScanDocument
|
|
43
|
+
|
|
44
|
+
scan = DocCheapScanDocument(api_key="sk_sandbox_public")
|
|
45
|
+
print(scan.invoke({"image_path": "passport.jpg"}))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
With an agent, take all five tools from the toolkit. They share one client and
|
|
49
|
+
its connections:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from langchain.agents import create_agent
|
|
53
|
+
from langchain_doccheap import DocCheapToolkit
|
|
54
|
+
|
|
55
|
+
tools = DocCheapToolkit().get_tools() # reads DOC_CHEAP_API_KEY
|
|
56
|
+
agent = create_agent("openai:gpt-4.1-mini", tools)
|
|
57
|
+
agent.invoke({"messages": [{"role": "user", "content": "Read the passport in ./passport.jpg"}]})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`DocCheapToolkit(include_delete=False)` leaves out the tool that deletes stored
|
|
61
|
+
results.
|
|
62
|
+
|
|
63
|
+
## API keys
|
|
64
|
+
|
|
65
|
+
- **Live key** – `sk_live_…`, created in the
|
|
66
|
+
[doc.cheap cabinet](https://doc.cheap/docs/concepts/api-keys-and-sessions).
|
|
67
|
+
- **Sandbox key** – `sk_sandbox_public` works without an account. It gives 10
|
|
68
|
+
free recognised documents per address in all, and at most 10 requests per
|
|
69
|
+
address an hour, whatever their answer.
|
|
70
|
+
|
|
71
|
+
Without `api_key` the tools read the key from the `DOC_CHEAP_API_KEY`
|
|
72
|
+
environment variable. `base_url` (or `DOC_CHEAP_API_BASE`) points them at
|
|
73
|
+
another address. To control timeouts, proxies or connection pooling, build a
|
|
74
|
+
`doc_cheap.Client` yourself and pass it as `client=`; the tools leave it open.
|
|
75
|
+
|
|
76
|
+
**Stored results.** A result is stored only when the scan was made with a live
|
|
77
|
+
key under a non-zero retention window. Under the sandbox key nothing is stored,
|
|
78
|
+
so getting or deleting a scan answers `scan_not_found`.
|
|
79
|
+
|
|
80
|
+
## Resources
|
|
81
|
+
|
|
82
|
+
- [doc.cheap documentation](https://doc.cheap/docs)
|
|
83
|
+
- [Your first recognition](https://doc.cheap/docs/start/first-recognition)
|
|
84
|
+
- [API reference](https://doc.cheap/docs/reference)
|
|
85
|
+
- [The doc-cheap Python client](https://pypi.org/project/doc-cheap/)
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-doccheap"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain tools for doc.cheap: read passports, ID cards and driver's licences into JSON."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Teoh Chin Heng", email = "admin@doc.cheap" }]
|
|
14
|
+
keywords = ["langchain", "tools", "ocr", "passport", "id-card", "mrz", "document-recognition", "kyc", "doc.cheap"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["doc-cheap>=0.1.0,<1", "langchain-core>=1.0,<2"]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
test = ["pytest>=8", "pytest-cov>=5", "langchain-tests>=1.0,<2"]
|
|
32
|
+
dev = ["pytest>=8", "pytest-cov>=5", "langchain-tests>=1.0,<2", "ruff>=0.6", "mypy>=1.11"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://doc.cheap"
|
|
36
|
+
Documentation = "https://doc.cheap/docs"
|
|
37
|
+
Source = "https://gitlab.com/doccheap/langchain-doccheap"
|
|
38
|
+
Changelog = "https://gitlab.com/doccheap/langchain-doccheap/-/blob/main/CHANGELOG.md"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/langchain_doccheap"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["src/langchain_doccheap", "tests", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
addopts = "-q"
|
|
49
|
+
|
|
50
|
+
[tool.coverage.run]
|
|
51
|
+
branch = true
|
|
52
|
+
source = ["langchain_doccheap"]
|
|
53
|
+
|
|
54
|
+
[tool.coverage.report]
|
|
55
|
+
fail_under = 100
|
|
56
|
+
show_missing = true
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint.isort]
|
|
66
|
+
known-first-party = ["langchain_doccheap"]
|
|
67
|
+
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
strict = true
|
|
70
|
+
python_version = "3.10"
|
|
71
|
+
files = ["src", "tests"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""LangChain tools for the doc.cheap document-recognition API."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .toolkit import DocCheapToolkit
|
|
5
|
+
from .tools import (
|
|
6
|
+
DocCheapDeleteScan,
|
|
7
|
+
DocCheapGetScan,
|
|
8
|
+
DocCheapGetUsage,
|
|
9
|
+
DocCheapListScans,
|
|
10
|
+
DocCheapScanDocument,
|
|
11
|
+
ListScansInput,
|
|
12
|
+
ScanDocumentInput,
|
|
13
|
+
ScanIdInput,
|
|
14
|
+
UsageInput,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DocCheapDeleteScan",
|
|
19
|
+
"DocCheapGetScan",
|
|
20
|
+
"DocCheapGetUsage",
|
|
21
|
+
"DocCheapListScans",
|
|
22
|
+
"DocCheapScanDocument",
|
|
23
|
+
"DocCheapToolkit",
|
|
24
|
+
"ListScansInput",
|
|
25
|
+
"ScanDocumentInput",
|
|
26
|
+
"ScanIdInput",
|
|
27
|
+
"UsageInput",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""All doc.cheap tools behind one client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from doc_cheap import API_KEY_ENV, Client
|
|
6
|
+
from langchain_core.tools import BaseTool, BaseToolkit
|
|
7
|
+
from langchain_core.utils import secret_from_env
|
|
8
|
+
from pydantic import ConfigDict, Field, PrivateAttr, SecretStr
|
|
9
|
+
|
|
10
|
+
from .tools import (
|
|
11
|
+
_USER_AGENT,
|
|
12
|
+
DocCheapDeleteScan,
|
|
13
|
+
DocCheapGetScan,
|
|
14
|
+
DocCheapGetUsage,
|
|
15
|
+
DocCheapListScans,
|
|
16
|
+
DocCheapScanDocument,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DocCheapToolkit(BaseToolkit):
|
|
21
|
+
"""The five doc.cheap tools, sharing one client and its connections.
|
|
22
|
+
|
|
23
|
+
The API key is the ``api_key`` argument, else the ``DOC_CHEAP_API_KEY``
|
|
24
|
+
environment variable. Pass ``include_delete=False`` to leave out the tool
|
|
25
|
+
that deletes stored results.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
29
|
+
|
|
30
|
+
api_key: SecretStr | None = Field(default_factory=secret_from_env(API_KEY_ENV, default=None))
|
|
31
|
+
base_url: str | None = None
|
|
32
|
+
client: Client | None = Field(default=None, exclude=True)
|
|
33
|
+
include_delete: bool = True
|
|
34
|
+
|
|
35
|
+
_owned_client: Client | None = PrivateAttr(default=None)
|
|
36
|
+
|
|
37
|
+
def _shared_client(self) -> Client:
|
|
38
|
+
if self.client is not None:
|
|
39
|
+
return self.client
|
|
40
|
+
if self._owned_client is None:
|
|
41
|
+
key = self.api_key.get_secret_value() if self.api_key is not None else None
|
|
42
|
+
self._owned_client = Client(key, base_url=self.base_url, user_agent=_USER_AGENT)
|
|
43
|
+
return self._owned_client
|
|
44
|
+
|
|
45
|
+
def get_tools(self) -> list[BaseTool]:
|
|
46
|
+
"""Returns the tools, all sending their requests through one client."""
|
|
47
|
+
client = self._shared_client()
|
|
48
|
+
tools: list[BaseTool] = [
|
|
49
|
+
DocCheapScanDocument(client=client),
|
|
50
|
+
DocCheapGetScan(client=client),
|
|
51
|
+
DocCheapListScans(client=client),
|
|
52
|
+
DocCheapGetUsage(client=client),
|
|
53
|
+
]
|
|
54
|
+
if self.include_delete:
|
|
55
|
+
tools.append(DocCheapDeleteScan(client=client))
|
|
56
|
+
return tools
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""LangChain tools that call the doc.cheap document-recognition API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from doc_cheap import API_KEY_ENV, Client, DocCheapError
|
|
9
|
+
from langchain_core.callbacks import CallbackManagerForToolRun
|
|
10
|
+
from langchain_core.tools import BaseTool, ToolException
|
|
11
|
+
from langchain_core.utils import secret_from_env
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, SecretStr, model_validator
|
|
13
|
+
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
|
|
16
|
+
_USER_AGENT = f"langchain-doccheap/{__version__}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_json(value: Any) -> str:
|
|
20
|
+
# Tool output goes back to a chat model as text, so the API's JSON answer
|
|
21
|
+
# is passed through as JSON rather than as a Python repr.
|
|
22
|
+
return json.dumps(value, ensure_ascii=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _DocCheapTool(BaseTool):
|
|
26
|
+
"""Shared configuration of the doc.cheap tools.
|
|
27
|
+
|
|
28
|
+
The API key is the ``api_key`` argument, else the ``DOC_CHEAP_API_KEY``
|
|
29
|
+
environment variable. A ``client`` passed in is used as given and stays
|
|
30
|
+
the caller's to close; otherwise the tool creates one on first use.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
34
|
+
|
|
35
|
+
api_key: SecretStr | None = Field(default_factory=secret_from_env(API_KEY_ENV, default=None))
|
|
36
|
+
"""The doc.cheap API key; ``sk_sandbox_public`` works without an account."""
|
|
37
|
+
|
|
38
|
+
base_url: str | None = None
|
|
39
|
+
"""The API address; the client's default when omitted."""
|
|
40
|
+
|
|
41
|
+
client: Client | None = Field(default=None, exclude=True)
|
|
42
|
+
"""A ready ``doc_cheap.Client`` to send the requests through."""
|
|
43
|
+
|
|
44
|
+
_owned_client: Client | None = PrivateAttr(default=None)
|
|
45
|
+
|
|
46
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
47
|
+
# An API error goes back to the model as the tool's answer, so an
|
|
48
|
+
# agent can react to it, unless the caller chose otherwise.
|
|
49
|
+
kwargs.setdefault("handle_tool_error", True)
|
|
50
|
+
super().__init__(**kwargs)
|
|
51
|
+
|
|
52
|
+
def _client(self) -> Client:
|
|
53
|
+
if self.client is not None:
|
|
54
|
+
return self.client
|
|
55
|
+
if self._owned_client is None:
|
|
56
|
+
key = self.api_key.get_secret_value() if self.api_key is not None else None
|
|
57
|
+
self._owned_client = Client(key, base_url=self.base_url, user_agent=_USER_AGENT)
|
|
58
|
+
return self._owned_client
|
|
59
|
+
|
|
60
|
+
def _call(self, operation: str, **kwargs: Any) -> str:
|
|
61
|
+
try:
|
|
62
|
+
result = getattr(self._client(), operation)(**kwargs)
|
|
63
|
+
except DocCheapError as err:
|
|
64
|
+
raise ToolException(f"doc.cheap error: {err}") from err
|
|
65
|
+
return _to_json(result)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ScanDocumentInput(BaseModel):
|
|
69
|
+
"""Arguments of :class:`DocCheapScanDocument`."""
|
|
70
|
+
|
|
71
|
+
image_path: str | None = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description="Path of a local JPEG or PNG file holding the document.",
|
|
74
|
+
)
|
|
75
|
+
image_base64: str | None = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
description="The JPEG or PNG image as base64 text, with or without a data: prefix.",
|
|
78
|
+
)
|
|
79
|
+
reference: str | None = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
max_length=128,
|
|
82
|
+
description="An optional label of your own, stored with the result.",
|
|
83
|
+
)
|
|
84
|
+
expect_country: str | None = Field(
|
|
85
|
+
default=None,
|
|
86
|
+
pattern=r"^[A-Z]{3}$",
|
|
87
|
+
description="Optional ISO 3166-1 alpha-3 code of the issuing country, e.g. GBR.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@model_validator(mode="after")
|
|
91
|
+
def _one_image(self) -> ScanDocumentInput:
|
|
92
|
+
if (self.image_path is None) == (self.image_base64 is None):
|
|
93
|
+
raise ValueError("give exactly one of image_path or image_base64")
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DocCheapScanDocument(_DocCheapTool):
|
|
98
|
+
"""Reads a passport, national ID card or driving licence into JSON."""
|
|
99
|
+
|
|
100
|
+
name: str = "doc_cheap_scan_document"
|
|
101
|
+
description: str = (
|
|
102
|
+
"Reads a photo or scan of a passport, national ID card or driving licence "
|
|
103
|
+
"and returns the printed fields (name, date of birth, document number, "
|
|
104
|
+
"expiry, machine-readable zone) as JSON. Give image_path or image_base64. "
|
|
105
|
+
"One recognised document costs one credit; an image with no readable "
|
|
106
|
+
"document costs nothing and comes back with billed set to false."
|
|
107
|
+
)
|
|
108
|
+
args_schema: type[BaseModel] = ScanDocumentInput
|
|
109
|
+
|
|
110
|
+
def _run(
|
|
111
|
+
self,
|
|
112
|
+
image_path: str | None = None,
|
|
113
|
+
image_base64: str | None = None,
|
|
114
|
+
reference: str | None = None,
|
|
115
|
+
expect_country: str | None = None,
|
|
116
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
117
|
+
) -> str:
|
|
118
|
+
return self._call(
|
|
119
|
+
"recognize",
|
|
120
|
+
path=image_path,
|
|
121
|
+
image_base64=image_base64,
|
|
122
|
+
reference=reference,
|
|
123
|
+
expect_country=expect_country,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ScanIdInput(BaseModel):
|
|
128
|
+
"""Arguments of the tools that act on one stored scan."""
|
|
129
|
+
|
|
130
|
+
scan_id: str = Field(min_length=1, description="The id of a scan, as returned by a scan.")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class DocCheapGetScan(_DocCheapTool):
|
|
134
|
+
"""Returns a stored scan result."""
|
|
135
|
+
|
|
136
|
+
name: str = "doc_cheap_get_scan"
|
|
137
|
+
description: str = (
|
|
138
|
+
"Returns a stored doc.cheap scan result by its id, as JSON. Results are "
|
|
139
|
+
"stored only for live keys with a retention window. Costs nothing."
|
|
140
|
+
)
|
|
141
|
+
args_schema: type[BaseModel] = ScanIdInput
|
|
142
|
+
|
|
143
|
+
def _run(self, scan_id: str, run_manager: CallbackManagerForToolRun | None = None) -> str:
|
|
144
|
+
return self._call("get_scan", scan_id=scan_id)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ListScansInput(BaseModel):
|
|
148
|
+
"""Arguments of :class:`DocCheapListScans`."""
|
|
149
|
+
|
|
150
|
+
limit: int | None = Field(
|
|
151
|
+
default=None, ge=1, le=100, description="How many results to return, 1 to 100."
|
|
152
|
+
)
|
|
153
|
+
cursor: str | None = Field(
|
|
154
|
+
default=None,
|
|
155
|
+
max_length=256,
|
|
156
|
+
description="The next_cursor value of the previous page, for the next page.",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class DocCheapListScans(_DocCheapTool):
|
|
161
|
+
"""Lists stored scan results, newest first."""
|
|
162
|
+
|
|
163
|
+
name: str = "doc_cheap_list_scans"
|
|
164
|
+
description: str = (
|
|
165
|
+
"Lists stored doc.cheap scan results, newest first, as JSON with a "
|
|
166
|
+
"next_cursor for the next page. Costs nothing."
|
|
167
|
+
)
|
|
168
|
+
args_schema: type[BaseModel] = ListScansInput
|
|
169
|
+
|
|
170
|
+
def _run(
|
|
171
|
+
self,
|
|
172
|
+
limit: int | None = None,
|
|
173
|
+
cursor: str | None = None,
|
|
174
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
175
|
+
) -> str:
|
|
176
|
+
return self._call("list_scans", limit=limit, cursor=cursor)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class DocCheapDeleteScan(_DocCheapTool):
|
|
180
|
+
"""Deletes a stored scan result."""
|
|
181
|
+
|
|
182
|
+
name: str = "doc_cheap_delete_scan"
|
|
183
|
+
description: str = (
|
|
184
|
+
"Deletes a stored doc.cheap scan result by its id. It cannot be undone. Costs nothing."
|
|
185
|
+
)
|
|
186
|
+
args_schema: type[BaseModel] = ScanIdInput
|
|
187
|
+
|
|
188
|
+
def _run(self, scan_id: str, run_manager: CallbackManagerForToolRun | None = None) -> str:
|
|
189
|
+
return self._call("delete_scan", scan_id=scan_id)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class UsageInput(BaseModel):
|
|
193
|
+
"""The usage tool takes no arguments."""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class DocCheapGetUsage(_DocCheapTool):
|
|
197
|
+
"""Returns the account's credits and scan counters."""
|
|
198
|
+
|
|
199
|
+
name: str = "doc_cheap_get_usage"
|
|
200
|
+
description: str = (
|
|
201
|
+
"Returns this month's free doc.cheap credits, the paid credits, the "
|
|
202
|
+
"credits spent and the scan counters, as JSON. Costs nothing."
|
|
203
|
+
)
|
|
204
|
+
args_schema: type[BaseModel] = UsageInput
|
|
205
|
+
|
|
206
|
+
def _run(self, run_manager: CallbackManagerForToolRun | None = None) -> str:
|
|
207
|
+
return self._call("usage")
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
from doc_cheap import Client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Recorder:
|
|
13
|
+
"""Answers every request with a queued response and remembers the requests."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.requests: list[httpx.Request] = []
|
|
17
|
+
self.responses: list[httpx.Response] = []
|
|
18
|
+
|
|
19
|
+
def queue(self, status: int, body: Any) -> None:
|
|
20
|
+
self.responses.append(httpx.Response(status, json=body))
|
|
21
|
+
|
|
22
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
23
|
+
self.requests.append(request)
|
|
24
|
+
return self.responses.pop(0)
|
|
25
|
+
|
|
26
|
+
def body(self, index: int = -1) -> Any:
|
|
27
|
+
return json.loads(self.requests[index].content)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture(autouse=True)
|
|
31
|
+
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
32
|
+
monkeypatch.delenv("DOC_CHEAP_API_KEY", raising=False)
|
|
33
|
+
monkeypatch.delenv("DOC_CHEAP_API_BASE", raising=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def recorder() -> Recorder:
|
|
38
|
+
return Recorder()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def client(recorder: Recorder) -> Iterator[Client]:
|
|
43
|
+
http = httpx.Client(transport=httpx.MockTransport(recorder))
|
|
44
|
+
with Client("sk_test_key", base_url="https://api.test", http_client=http) as c:
|
|
45
|
+
yield c
|
|
46
|
+
http.close()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""LangChain's own standard checks for a tool package."""
|
|
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_doccheap import (
|
|
11
|
+
DocCheapDeleteScan,
|
|
12
|
+
DocCheapGetScan,
|
|
13
|
+
DocCheapGetUsage,
|
|
14
|
+
DocCheapListScans,
|
|
15
|
+
DocCheapScanDocument,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_ENV: tuple[dict[str, str], dict[str, Any], dict[str, Any]] = (
|
|
19
|
+
{"DOC_CHEAP_API_KEY": "sk_env_key"},
|
|
20
|
+
{},
|
|
21
|
+
{"api_key": "sk_env_key"},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Base(ToolsUnitTests):
|
|
26
|
+
@property
|
|
27
|
+
def tool_constructor_params(self) -> dict[str, Any]:
|
|
28
|
+
return {"api_key": "sk_test_key"}
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def init_from_env_params(self) -> tuple[dict[str, str], dict[str, Any], dict[str, Any]]:
|
|
32
|
+
return _ENV
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestScanDocument(_Base):
|
|
36
|
+
@property
|
|
37
|
+
def tool_constructor(self) -> type[BaseTool]:
|
|
38
|
+
return DocCheapScanDocument
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def tool_invoke_params_example(self) -> dict[str, Any]:
|
|
42
|
+
return {"image_base64": "aGVsbG8=", "expect_country": "GBR"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestGetScan(_Base):
|
|
46
|
+
@property
|
|
47
|
+
def tool_constructor(self) -> type[BaseTool]:
|
|
48
|
+
return DocCheapGetScan
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def tool_invoke_params_example(self) -> dict[str, Any]:
|
|
52
|
+
return {"scan_id": "scn_1"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestListScans(_Base):
|
|
56
|
+
@property
|
|
57
|
+
def tool_constructor(self) -> type[BaseTool]:
|
|
58
|
+
return DocCheapListScans
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def tool_invoke_params_example(self) -> dict[str, Any]:
|
|
62
|
+
return {"limit": 10}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestDeleteScan(_Base):
|
|
66
|
+
@property
|
|
67
|
+
def tool_constructor(self) -> type[BaseTool]:
|
|
68
|
+
return DocCheapDeleteScan
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def tool_invoke_params_example(self) -> dict[str, Any]:
|
|
72
|
+
return {"scan_id": "scn_1"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestGetUsage(_Base):
|
|
76
|
+
@property
|
|
77
|
+
def tool_constructor(self) -> type[BaseTool]:
|
|
78
|
+
return DocCheapGetUsage
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
from doc_cheap import Client
|
|
10
|
+
from langchain_core.tools import ToolException
|
|
11
|
+
from pydantic import SecretStr, ValidationError
|
|
12
|
+
|
|
13
|
+
from langchain_doccheap import (
|
|
14
|
+
DocCheapDeleteScan,
|
|
15
|
+
DocCheapGetScan,
|
|
16
|
+
DocCheapGetUsage,
|
|
17
|
+
DocCheapListScans,
|
|
18
|
+
DocCheapScanDocument,
|
|
19
|
+
DocCheapToolkit,
|
|
20
|
+
ScanDocumentInput,
|
|
21
|
+
__version__,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .conftest import Recorder
|
|
25
|
+
|
|
26
|
+
SCAN = {"id": "scn_1", "meta": {"status": "unsupported_document", "billed": False}}
|
|
27
|
+
ERROR = {"error": {"code": "scan_not_found", "message": "No such scan.", "request_id": "req_1"}}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_scan_from_base64_sends_the_image_and_returns_json(
|
|
31
|
+
client: Client, recorder: Recorder
|
|
32
|
+
) -> None:
|
|
33
|
+
recorder.queue(201, SCAN)
|
|
34
|
+
tool = DocCheapScanDocument(client=client)
|
|
35
|
+
out = tool.invoke({"image_base64": "aGVsbG8=", "reference": "case-7", "expect_country": "GBR"})
|
|
36
|
+
assert json.loads(out) == SCAN
|
|
37
|
+
request = recorder.requests[0]
|
|
38
|
+
assert request.method == "POST"
|
|
39
|
+
assert request.url.path == "/v1/scans"
|
|
40
|
+
assert recorder.body() == {
|
|
41
|
+
"image": "aGVsbG8=",
|
|
42
|
+
"reference": "case-7",
|
|
43
|
+
"options": {"expect_country": "GBR"},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_scan_from_path_reads_the_file(client: Client, recorder: Recorder, tmp_path: Path) -> None:
|
|
48
|
+
image = tmp_path / "doc.png"
|
|
49
|
+
image.write_bytes(b"\x89PNG-bytes")
|
|
50
|
+
recorder.queue(201, SCAN)
|
|
51
|
+
DocCheapScanDocument(client=client).invoke({"image_path": str(image)})
|
|
52
|
+
assert recorder.body() == {"image": base64.b64encode(b"\x89PNG-bytes").decode()}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize(
|
|
56
|
+
"args",
|
|
57
|
+
[{}, {"image_path": "a.png", "image_base64": "aGVsbG8="}],
|
|
58
|
+
)
|
|
59
|
+
def test_scan_needs_exactly_one_image(args: dict[str, str]) -> None:
|
|
60
|
+
with pytest.raises(ValidationError, match="exactly one of image_path or image_base64"):
|
|
61
|
+
ScanDocumentInput(**args)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_scan_input_refuses_a_bad_country() -> None:
|
|
65
|
+
with pytest.raises(ValidationError):
|
|
66
|
+
ScanDocumentInput(image_base64="aGVsbG8=", expect_country="gb")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_get_scan(client: Client, recorder: Recorder) -> None:
|
|
70
|
+
recorder.queue(200, SCAN)
|
|
71
|
+
out = DocCheapGetScan(client=client).invoke({"scan_id": "scn_1"})
|
|
72
|
+
assert json.loads(out) == SCAN
|
|
73
|
+
assert recorder.requests[0].url.path == "/v1/scans/scn_1"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_list_scans_passes_limit_and_cursor(client: Client, recorder: Recorder) -> None:
|
|
77
|
+
page: dict[str, object] = {"scans": [], "next_cursor": None}
|
|
78
|
+
recorder.queue(200, page)
|
|
79
|
+
out = DocCheapListScans(client=client).invoke({"limit": 5, "cursor": "c1"})
|
|
80
|
+
assert json.loads(out) == page
|
|
81
|
+
params = recorder.requests[0].url.params
|
|
82
|
+
assert params["limit"] == "5"
|
|
83
|
+
assert params["cursor"] == "c1"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_list_scans_without_arguments(client: Client, recorder: Recorder) -> None:
|
|
87
|
+
recorder.queue(200, {"scans": [], "next_cursor": None})
|
|
88
|
+
DocCheapListScans(client=client).invoke({})
|
|
89
|
+
assert str(recorder.requests[0].url) == "https://api.test/v1/scans"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_delete_scan(client: Client, recorder: Recorder) -> None:
|
|
93
|
+
recorder.queue(200, {"id": "scn_1", "deleted": True})
|
|
94
|
+
out = DocCheapDeleteScan(client=client).invoke({"scan_id": "scn_1"})
|
|
95
|
+
assert json.loads(out) == {"id": "scn_1", "deleted": True}
|
|
96
|
+
assert recorder.requests[0].method == "DELETE"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_usage(client: Client, recorder: Recorder) -> None:
|
|
100
|
+
usage = {"free_allowance": {"remaining": 100}}
|
|
101
|
+
recorder.queue(200, usage)
|
|
102
|
+
out = DocCheapGetUsage(client=client).invoke({})
|
|
103
|
+
assert json.loads(out) == usage
|
|
104
|
+
assert recorder.requests[0].url.path == "/v1/usage"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_api_error_is_returned_to_the_model_as_text(client: Client, recorder: Recorder) -> None:
|
|
108
|
+
recorder.queue(404, ERROR)
|
|
109
|
+
out = DocCheapGetScan(client=client).invoke({"scan_id": "scn_x"})
|
|
110
|
+
assert out == "doc.cheap error: 404 scan_not_found: No such scan."
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_api_error_is_raised_when_handling_is_off(client: Client, recorder: Recorder) -> None:
|
|
114
|
+
recorder.queue(404, ERROR)
|
|
115
|
+
tool = DocCheapGetScan(client=client, handle_tool_error=False)
|
|
116
|
+
with pytest.raises(ToolException, match="scan_not_found"):
|
|
117
|
+
tool.invoke({"scan_id": "scn_x"})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_missing_key_is_reported_not_raised() -> None:
|
|
121
|
+
out = DocCheapGetUsage().invoke({})
|
|
122
|
+
assert "no API key" in out
|
|
123
|
+
assert "sk_sandbox_public" in out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_tool_creates_its_own_client_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
127
|
+
seen: list[httpx.Request] = []
|
|
128
|
+
|
|
129
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
130
|
+
seen.append(request)
|
|
131
|
+
return httpx.Response(200, json={})
|
|
132
|
+
|
|
133
|
+
transport = httpx.MockTransport(handler)
|
|
134
|
+
original = httpx.Client.__init__
|
|
135
|
+
|
|
136
|
+
def patched(self: httpx.Client, *args: object, **kwargs: object) -> None:
|
|
137
|
+
kwargs["transport"] = transport
|
|
138
|
+
original(self, *args, **kwargs) # type: ignore[arg-type]
|
|
139
|
+
|
|
140
|
+
monkeypatch.setattr(httpx.Client, "__init__", patched)
|
|
141
|
+
monkeypatch.setenv("DOC_CHEAP_API_KEY", "sk_env_key")
|
|
142
|
+
tool = DocCheapGetUsage(base_url="https://api.example")
|
|
143
|
+
tool.invoke({})
|
|
144
|
+
tool.invoke({})
|
|
145
|
+
assert len(seen) == 2
|
|
146
|
+
assert seen[0].headers["authorization"] == "Bearer sk_env_key"
|
|
147
|
+
assert str(seen[0].url) == "https://api.example/v1/usage"
|
|
148
|
+
assert seen[0].headers["user-agent"].startswith(f"langchain-doccheap/{__version__} ")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_toolkit_shares_the_given_client(client: Client) -> None:
|
|
152
|
+
tools = DocCheapToolkit(client=client).get_tools()
|
|
153
|
+
assert [t.name for t in tools] == [
|
|
154
|
+
"doc_cheap_scan_document",
|
|
155
|
+
"doc_cheap_get_scan",
|
|
156
|
+
"doc_cheap_list_scans",
|
|
157
|
+
"doc_cheap_get_usage",
|
|
158
|
+
"doc_cheap_delete_scan",
|
|
159
|
+
]
|
|
160
|
+
assert all(getattr(t, "client", None) is client for t in tools)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_toolkit_can_leave_out_delete() -> None:
|
|
164
|
+
tools = DocCheapToolkit(api_key=SecretStr("sk_test_key"), include_delete=False).get_tools()
|
|
165
|
+
assert "doc_cheap_delete_scan" not in [t.name for t in tools]
|
|
166
|
+
clients = {id(getattr(t, "client", None)) for t in tools}
|
|
167
|
+
assert len(clients) == 1
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_toolkit_creates_its_client_once() -> None:
|
|
171
|
+
toolkit = DocCheapToolkit(api_key=SecretStr("sk_test_key"), base_url="https://api.example")
|
|
172
|
+
first = toolkit.get_tools()[0]
|
|
173
|
+
second = toolkit.get_tools()[0]
|
|
174
|
+
assert getattr(first, "client", None) is getattr(second, "client", None)
|
|
175
|
+
assert getattr(first, "client").base_url == "https://api.example" # noqa: B009
|