dehydrator 0.2.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.
- dehydrator-0.2.0/.claude/settings.local.json +23 -0
- dehydrator-0.2.0/.gitignore +14 -0
- dehydrator-0.2.0/PKG-INFO +289 -0
- dehydrator-0.2.0/README.md +264 -0
- dehydrator-0.2.0/benchmarks/__init__.py +0 -0
- dehydrator-0.2.0/benchmarks/_tools.py +2097 -0
- dehydrator-0.2.0/benchmarks/search_quality.py +106 -0
- dehydrator-0.2.0/benchmarks/token_savings.py +102 -0
- dehydrator-0.2.0/benchmarks/token_savings_openai.py +74 -0
- dehydrator-0.2.0/pyproject.toml +63 -0
- dehydrator-0.2.0/src/dehydrator/__init__.py +16 -0
- dehydrator-0.2.0/src/dehydrator/_adapter.py +267 -0
- dehydrator-0.2.0/src/dehydrator/_client.py +178 -0
- dehydrator-0.2.0/src/dehydrator/_index.py +67 -0
- dehydrator-0.2.0/src/dehydrator/_interceptor.py +75 -0
- dehydrator-0.2.0/src/dehydrator/_openai_client.py +175 -0
- dehydrator-0.2.0/src/dehydrator/_search_tool.py +45 -0
- dehydrator-0.2.0/src/dehydrator/_tokenizer.py +73 -0
- dehydrator-0.2.0/src/dehydrator/_types.py +50 -0
- dehydrator-0.2.0/src/dehydrator/py.typed +0 -0
- dehydrator-0.2.0/tests/__init__.py +0 -0
- dehydrator-0.2.0/tests/test_client.py +233 -0
- dehydrator-0.2.0/tests/test_index.py +184 -0
- dehydrator-0.2.0/tests/test_interceptor.py +334 -0
- dehydrator-0.2.0/tests/test_mcp_tools.py +114 -0
- dehydrator-0.2.0/tests/test_openai_client.py +252 -0
- dehydrator-0.2.0/tests/test_openai_interceptor.py +236 -0
- dehydrator-0.2.0/tests/test_tokenizer.py +108 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"WebFetch(domain:www.anthropic.com)",
|
|
5
|
+
"WebSearch",
|
|
6
|
+
"WebFetch(domain:github.com)",
|
|
7
|
+
"WebFetch(domain:medium.com)",
|
|
8
|
+
"WebFetch(domain:raw.githubusercontent.com)",
|
|
9
|
+
"WebFetch(domain:docs.anthropic.com)",
|
|
10
|
+
"Bash(uv init:*)",
|
|
11
|
+
"Bash(uv sync:*)",
|
|
12
|
+
"Bash(uv run pytest:*)",
|
|
13
|
+
"Bash(uv run ruff check:*)",
|
|
14
|
+
"Bash(uv run mypy:*)",
|
|
15
|
+
"Bash(uv run ruff:*)",
|
|
16
|
+
"WebFetch(domain:gofastmcp.com)",
|
|
17
|
+
"WebFetch(domain:platform.openai.com)",
|
|
18
|
+
"WebFetch(domain:modelcontextprotocol.github.io)",
|
|
19
|
+
"WebFetch(domain:gist.github.com)",
|
|
20
|
+
"Bash(wc:*)"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dehydrator
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Client-side BM25 tool search for LLM APIs — Anthropic, OpenAI-compatible, and MCP
|
|
5
|
+
Project-URL: Homepage, https://github.com/arrmlet/dehydrator
|
|
6
|
+
Project-URL: Repository, https://github.com/arrmlet/dehydrator
|
|
7
|
+
Project-URL: Issues, https://github.com/arrmlet/dehydrator/issues
|
|
8
|
+
Author-email: arrmlet <trubavolodymyr@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: anthropic,bm25,context-window,lazy-loading,llm,mcp,openai,tools
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: anthropic>=0.40.0
|
|
22
|
+
Requires-Dist: mcp>=1.26.0
|
|
23
|
+
Requires-Dist: rank-bm25>=0.2.2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Dehydrator
|
|
27
|
+
|
|
28
|
+
Client-side BM25 tool search for LLM APIs. Use thousands of tools without bloating the context window.
|
|
29
|
+
|
|
30
|
+
Works with **Anthropic**, **OpenAI**, and any **OpenAI-compatible** provider (Groq, OpenRouter, Chutes, etc.). Accepts tools from **MCP servers** natively.
|
|
31
|
+
|
|
32
|
+
## The problem
|
|
33
|
+
|
|
34
|
+
LLM APIs require you to send all tool definitions in every request. With 100+ tools, this wastes tokens and degrades tool selection. Anthropic offers a server-side `tool_search_tool_bm25`, but it's not available on all platforms (e.g. Bedrock) and doesn't work with ZDR. Dehydrator gives you the same capability client-side, so it works everywhere — with any provider.
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
Dehydrator wraps your LLM client and replaces the full tool list with a single `tool_search` tool. When the model needs a tool, it searches by description. Dehydrator intercepts the call, runs BM25 locally, and re-calls the API with only the matched tools injected.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
User request
|
|
42
|
+
│
|
|
43
|
+
▼
|
|
44
|
+
┌─────────────────────────────┐
|
|
45
|
+
│ API call #1 │
|
|
46
|
+
│ tools = [tool_search] │
|
|
47
|
+
│ │
|
|
48
|
+
│ Model responds: │
|
|
49
|
+
│ tool_search("send email") │
|
|
50
|
+
└─────────────┬───────────────┘
|
|
51
|
+
│ intercepted by Dehydrator
|
|
52
|
+
▼
|
|
53
|
+
┌─────────────────────────────┐
|
|
54
|
+
│ BM25 search (local) │
|
|
55
|
+
│ → matches: send_email, │
|
|
56
|
+
│ send_slack_message │
|
|
57
|
+
└─────────────┬───────────────┘
|
|
58
|
+
│
|
|
59
|
+
▼
|
|
60
|
+
┌─────────────────────────────┐
|
|
61
|
+
│ API call #2 │
|
|
62
|
+
│ tools = [tool_search, │
|
|
63
|
+
│ send_email, │
|
|
64
|
+
│ send_slack_message]│
|
|
65
|
+
│ │
|
|
66
|
+
│ Model responds: │
|
|
67
|
+
│ send_email({...}) │
|
|
68
|
+
└─────────────────────────────┘
|
|
69
|
+
│
|
|
70
|
+
▼
|
|
71
|
+
Returned to you
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Only the tools the model actually needs are ever sent. Discovered tools persist across turns within a conversation.
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install dehydrator
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Quick start
|
|
83
|
+
|
|
84
|
+
### Anthropic
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import anthropic
|
|
88
|
+
from dehydrator import DehydratedClient
|
|
89
|
+
|
|
90
|
+
client = DehydratedClient(
|
|
91
|
+
anthropic.Anthropic(),
|
|
92
|
+
tools=tools,
|
|
93
|
+
top_k=5,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
response = client.messages.create(
|
|
97
|
+
model="claude-sonnet-4-6",
|
|
98
|
+
max_tokens=1024,
|
|
99
|
+
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The response is a standard `anthropic.types.Message`.
|
|
104
|
+
|
|
105
|
+
### OpenAI-compatible (OpenAI, Groq, OpenRouter, Chutes, etc.)
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from openai import OpenAI
|
|
109
|
+
from dehydrator import OpenAIDehydratedClient
|
|
110
|
+
|
|
111
|
+
client = OpenAIDehydratedClient(
|
|
112
|
+
OpenAI(),
|
|
113
|
+
tools=tools,
|
|
114
|
+
top_k=5,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
response = client.chat.completions.create(
|
|
118
|
+
model="gpt-4o",
|
|
119
|
+
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Works with any client that implements `client.chat.completions.create()`. No `openai` import required — fully duck-typed.
|
|
124
|
+
|
|
125
|
+
### MCP tools
|
|
126
|
+
|
|
127
|
+
Tools from MCP servers use `inputSchema` (camelCase) instead of `input_schema`. Dehydrator accepts both formats automatically:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
# MCP format tools work directly
|
|
131
|
+
tools = [
|
|
132
|
+
{"name": "get_weather", "description": "...", "inputSchema": {...}},
|
|
133
|
+
]
|
|
134
|
+
client = DehydratedClient(anthropic.Anthropic(), tools=tools)
|
|
135
|
+
|
|
136
|
+
# Or use mcp.types.Tool objects with ToolIndex.from_mcp()
|
|
137
|
+
from dehydrator import ToolIndex
|
|
138
|
+
|
|
139
|
+
tools = await session.list_tools() # returns list[mcp.types.Tool]
|
|
140
|
+
index = ToolIndex.from_mcp(tools, top_k=5)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## API
|
|
144
|
+
|
|
145
|
+
### `DehydratedClient(client, tools, *, top_k=5, always_available=None, max_search_rounds=3)`
|
|
146
|
+
|
|
147
|
+
Wraps an `anthropic.Anthropic` client.
|
|
148
|
+
|
|
149
|
+
| Parameter | Type | Description |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `client` | `anthropic.Anthropic` | An Anthropic SDK client instance |
|
|
152
|
+
| `tools` | `list[dict]` | Tool definitions (Anthropic or MCP format) |
|
|
153
|
+
| `top_k` | `int` | Max tools returned per search (default: 5) |
|
|
154
|
+
| `always_available` | `list[str]` | Tool names to include in every request, bypassing search |
|
|
155
|
+
| `max_search_rounds` | `int` | Max search iterations per `create()` call (default: 3) |
|
|
156
|
+
|
|
157
|
+
#### Methods
|
|
158
|
+
|
|
159
|
+
- **`client.messages.create(**kwargs)`** — Same signature as the Anthropic SDK. The `tools` kwarg is ignored (Dehydrator manages tools). Returns `anthropic.types.Message`.
|
|
160
|
+
- **`client.reset_discoveries()`** — Clears discovered tools. Call this when starting a new conversation.
|
|
161
|
+
- **`client.inner`** — Access the underlying `anthropic.Anthropic` client.
|
|
162
|
+
|
|
163
|
+
### `AsyncDehydratedClient`
|
|
164
|
+
|
|
165
|
+
Same API as `DehydratedClient`, but wraps `anthropic.AsyncAnthropic` and `create()` is async.
|
|
166
|
+
|
|
167
|
+
### `OpenAIDehydratedClient(client, tools, *, top_k=5, always_available=None, max_search_rounds=3)`
|
|
168
|
+
|
|
169
|
+
Wraps any OpenAI-compatible client.
|
|
170
|
+
|
|
171
|
+
| Parameter | Type | Description |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `client` | any | Any client with `client.chat.completions.create()` |
|
|
174
|
+
| `tools` | `list[dict]` | Tool definitions (Anthropic or MCP format — converted to OpenAI format automatically) |
|
|
175
|
+
| `top_k` | `int` | Max tools returned per search (default: 5) |
|
|
176
|
+
| `always_available` | `list[str]` | Tool names to include in every request, bypassing search |
|
|
177
|
+
| `max_search_rounds` | `int` | Max search iterations per `create()` call (default: 3) |
|
|
178
|
+
|
|
179
|
+
#### Methods
|
|
180
|
+
|
|
181
|
+
- **`client.chat.completions.create(**kwargs)`** — Same signature as the OpenAI SDK. The `tools` kwarg is ignored. Returns the provider's response object.
|
|
182
|
+
- **`client.reset_discoveries()`** — Clears discovered tools.
|
|
183
|
+
- **`client.inner`** — Access the underlying client.
|
|
184
|
+
|
|
185
|
+
### `AsyncOpenAIDehydratedClient`
|
|
186
|
+
|
|
187
|
+
Same API as `OpenAIDehydratedClient`, but `create()` is async.
|
|
188
|
+
|
|
189
|
+
### `ToolIndex`
|
|
190
|
+
|
|
191
|
+
The BM25 index is also available standalone if you want to use it directly.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from dehydrator import ToolIndex
|
|
195
|
+
|
|
196
|
+
index = ToolIndex(tools, top_k=5)
|
|
197
|
+
matched_names = index.search("weather forecast")
|
|
198
|
+
matched_tools = index.get_tools(matched_names)
|
|
199
|
+
|
|
200
|
+
# From MCP Tool objects
|
|
201
|
+
index = ToolIndex.from_mcp(mcp_tools, top_k=5)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Always-available tools
|
|
205
|
+
|
|
206
|
+
Some tools should always be in context (e.g. a `help` tool). Pass their names to `always_available`:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
client = DehydratedClient(
|
|
210
|
+
anthropic.Anthropic(),
|
|
211
|
+
tools=tools,
|
|
212
|
+
always_available=["help", "get_current_user"],
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
These tools are sent in every request without requiring a search.
|
|
217
|
+
|
|
218
|
+
## Multi-turn conversations
|
|
219
|
+
|
|
220
|
+
Discovered tools persist across calls to `create()`. If the model found `send_email` in turn 1, it's still available in turn 2 without re-searching.
|
|
221
|
+
|
|
222
|
+
Call `client.reset_discoveries()` when starting a new conversation:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
# Turn 1: model discovers send_email
|
|
226
|
+
response = client.messages.create(...)
|
|
227
|
+
|
|
228
|
+
# Turn 2: send_email is still available
|
|
229
|
+
response = client.messages.create(...)
|
|
230
|
+
|
|
231
|
+
# New conversation
|
|
232
|
+
client.reset_discoveries()
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Benchmarks
|
|
236
|
+
|
|
237
|
+
Benchmarked against **139 real tool definitions** from 6 popular MCP servers (Chrome DevTools, GitHub, Playwright, Filesystem, Git, Notion).
|
|
238
|
+
|
|
239
|
+
### Token savings
|
|
240
|
+
|
|
241
|
+
Sending all tools in every request is expensive. Dehydrator replaces them with a single `tool_search` tool and only injects the tools the model actually needs:
|
|
242
|
+
|
|
243
|
+
| Tools | top_k=3 | top_k=5 | top_k=10 | Baseline |
|
|
244
|
+
|------:|--------:|--------:|---------:|---------:|
|
|
245
|
+
| 50 | 274 tokens (94%) | 349 tokens (93%) | 678 tokens (86%) | 4,864 |
|
|
246
|
+
| 100 | 274 tokens (97%) | 349 tokens (96%) | 678 tokens (92%) | 8,954 |
|
|
247
|
+
| 200 | 274 tokens (98%) | 349 tokens (98%) | 678 tokens (96%) | 18,159 |
|
|
248
|
+
|
|
249
|
+
With 200 tools and `top_k=5`, you go from **18,159 → 349 tokens** per request — a **98% reduction**.
|
|
250
|
+
|
|
251
|
+
### Search quality
|
|
252
|
+
|
|
253
|
+
BM25 finds the right tools reliably across all 6 MCP servers:
|
|
254
|
+
|
|
255
|
+
| Metric | k=3 | k=5 | k=10 |
|
|
256
|
+
|--------|----:|----:|-----:|
|
|
257
|
+
| Precision@k | 51.1% | 32.7% | 17.3% |
|
|
258
|
+
| Recall@k | 88.6% | 95.3% | 98.3% |
|
|
259
|
+
| **MRR** | | **95.8%** | |
|
|
260
|
+
|
|
261
|
+
30/30 test queries found at least one correct tool in the top 10. The right tool is ranked #1 or #2 in almost every case.
|
|
262
|
+
|
|
263
|
+
### Run the benchmarks
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
uv run python benchmarks/search_quality.py # local, no API key
|
|
267
|
+
uv run python benchmarks/token_savings_openai.py # local, uses tiktoken
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Limitations
|
|
271
|
+
|
|
272
|
+
- **No streaming** — `stream=True` raises `NotImplementedError`. Planned for a future release.
|
|
273
|
+
- **Reserved tool name** — You cannot have a tool named `tool_search`. Dehydrator will raise `ValueError` if you do.
|
|
274
|
+
|
|
275
|
+
## Development
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
git clone https://github.com/Arrmlet/dehydrator.git
|
|
279
|
+
cd dehydrator
|
|
280
|
+
uv sync
|
|
281
|
+
|
|
282
|
+
uv run pytest # tests
|
|
283
|
+
uv run ruff check src/ # lint
|
|
284
|
+
uv run mypy src/ # type check
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## License
|
|
288
|
+
|
|
289
|
+
MIT
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Dehydrator
|
|
2
|
+
|
|
3
|
+
Client-side BM25 tool search for LLM APIs. Use thousands of tools without bloating the context window.
|
|
4
|
+
|
|
5
|
+
Works with **Anthropic**, **OpenAI**, and any **OpenAI-compatible** provider (Groq, OpenRouter, Chutes, etc.). Accepts tools from **MCP servers** natively.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
LLM APIs require you to send all tool definitions in every request. With 100+ tools, this wastes tokens and degrades tool selection. Anthropic offers a server-side `tool_search_tool_bm25`, but it's not available on all platforms (e.g. Bedrock) and doesn't work with ZDR. Dehydrator gives you the same capability client-side, so it works everywhere — with any provider.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
Dehydrator wraps your LLM client and replaces the full tool list with a single `tool_search` tool. When the model needs a tool, it searches by description. Dehydrator intercepts the call, runs BM25 locally, and re-calls the API with only the matched tools injected.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
User request
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
┌─────────────────────────────┐
|
|
20
|
+
│ API call #1 │
|
|
21
|
+
│ tools = [tool_search] │
|
|
22
|
+
│ │
|
|
23
|
+
│ Model responds: │
|
|
24
|
+
│ tool_search("send email") │
|
|
25
|
+
└─────────────┬───────────────┘
|
|
26
|
+
│ intercepted by Dehydrator
|
|
27
|
+
▼
|
|
28
|
+
┌─────────────────────────────┐
|
|
29
|
+
│ BM25 search (local) │
|
|
30
|
+
│ → matches: send_email, │
|
|
31
|
+
│ send_slack_message │
|
|
32
|
+
└─────────────┬───────────────┘
|
|
33
|
+
│
|
|
34
|
+
▼
|
|
35
|
+
┌─────────────────────────────┐
|
|
36
|
+
│ API call #2 │
|
|
37
|
+
│ tools = [tool_search, │
|
|
38
|
+
│ send_email, │
|
|
39
|
+
│ send_slack_message]│
|
|
40
|
+
│ │
|
|
41
|
+
│ Model responds: │
|
|
42
|
+
│ send_email({...}) │
|
|
43
|
+
└─────────────────────────────┘
|
|
44
|
+
│
|
|
45
|
+
▼
|
|
46
|
+
Returned to you
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Only the tools the model actually needs are ever sent. Discovered tools persist across turns within a conversation.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install dehydrator
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
### Anthropic
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import anthropic
|
|
63
|
+
from dehydrator import DehydratedClient
|
|
64
|
+
|
|
65
|
+
client = DehydratedClient(
|
|
66
|
+
anthropic.Anthropic(),
|
|
67
|
+
tools=tools,
|
|
68
|
+
top_k=5,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
response = client.messages.create(
|
|
72
|
+
model="claude-sonnet-4-6",
|
|
73
|
+
max_tokens=1024,
|
|
74
|
+
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The response is a standard `anthropic.types.Message`.
|
|
79
|
+
|
|
80
|
+
### OpenAI-compatible (OpenAI, Groq, OpenRouter, Chutes, etc.)
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from openai import OpenAI
|
|
84
|
+
from dehydrator import OpenAIDehydratedClient
|
|
85
|
+
|
|
86
|
+
client = OpenAIDehydratedClient(
|
|
87
|
+
OpenAI(),
|
|
88
|
+
tools=tools,
|
|
89
|
+
top_k=5,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
response = client.chat.completions.create(
|
|
93
|
+
model="gpt-4o",
|
|
94
|
+
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Works with any client that implements `client.chat.completions.create()`. No `openai` import required — fully duck-typed.
|
|
99
|
+
|
|
100
|
+
### MCP tools
|
|
101
|
+
|
|
102
|
+
Tools from MCP servers use `inputSchema` (camelCase) instead of `input_schema`. Dehydrator accepts both formats automatically:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
# MCP format tools work directly
|
|
106
|
+
tools = [
|
|
107
|
+
{"name": "get_weather", "description": "...", "inputSchema": {...}},
|
|
108
|
+
]
|
|
109
|
+
client = DehydratedClient(anthropic.Anthropic(), tools=tools)
|
|
110
|
+
|
|
111
|
+
# Or use mcp.types.Tool objects with ToolIndex.from_mcp()
|
|
112
|
+
from dehydrator import ToolIndex
|
|
113
|
+
|
|
114
|
+
tools = await session.list_tools() # returns list[mcp.types.Tool]
|
|
115
|
+
index = ToolIndex.from_mcp(tools, top_k=5)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## API
|
|
119
|
+
|
|
120
|
+
### `DehydratedClient(client, tools, *, top_k=5, always_available=None, max_search_rounds=3)`
|
|
121
|
+
|
|
122
|
+
Wraps an `anthropic.Anthropic` client.
|
|
123
|
+
|
|
124
|
+
| Parameter | Type | Description |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `client` | `anthropic.Anthropic` | An Anthropic SDK client instance |
|
|
127
|
+
| `tools` | `list[dict]` | Tool definitions (Anthropic or MCP format) |
|
|
128
|
+
| `top_k` | `int` | Max tools returned per search (default: 5) |
|
|
129
|
+
| `always_available` | `list[str]` | Tool names to include in every request, bypassing search |
|
|
130
|
+
| `max_search_rounds` | `int` | Max search iterations per `create()` call (default: 3) |
|
|
131
|
+
|
|
132
|
+
#### Methods
|
|
133
|
+
|
|
134
|
+
- **`client.messages.create(**kwargs)`** — Same signature as the Anthropic SDK. The `tools` kwarg is ignored (Dehydrator manages tools). Returns `anthropic.types.Message`.
|
|
135
|
+
- **`client.reset_discoveries()`** — Clears discovered tools. Call this when starting a new conversation.
|
|
136
|
+
- **`client.inner`** — Access the underlying `anthropic.Anthropic` client.
|
|
137
|
+
|
|
138
|
+
### `AsyncDehydratedClient`
|
|
139
|
+
|
|
140
|
+
Same API as `DehydratedClient`, but wraps `anthropic.AsyncAnthropic` and `create()` is async.
|
|
141
|
+
|
|
142
|
+
### `OpenAIDehydratedClient(client, tools, *, top_k=5, always_available=None, max_search_rounds=3)`
|
|
143
|
+
|
|
144
|
+
Wraps any OpenAI-compatible client.
|
|
145
|
+
|
|
146
|
+
| Parameter | Type | Description |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `client` | any | Any client with `client.chat.completions.create()` |
|
|
149
|
+
| `tools` | `list[dict]` | Tool definitions (Anthropic or MCP format — converted to OpenAI format automatically) |
|
|
150
|
+
| `top_k` | `int` | Max tools returned per search (default: 5) |
|
|
151
|
+
| `always_available` | `list[str]` | Tool names to include in every request, bypassing search |
|
|
152
|
+
| `max_search_rounds` | `int` | Max search iterations per `create()` call (default: 3) |
|
|
153
|
+
|
|
154
|
+
#### Methods
|
|
155
|
+
|
|
156
|
+
- **`client.chat.completions.create(**kwargs)`** — Same signature as the OpenAI SDK. The `tools` kwarg is ignored. Returns the provider's response object.
|
|
157
|
+
- **`client.reset_discoveries()`** — Clears discovered tools.
|
|
158
|
+
- **`client.inner`** — Access the underlying client.
|
|
159
|
+
|
|
160
|
+
### `AsyncOpenAIDehydratedClient`
|
|
161
|
+
|
|
162
|
+
Same API as `OpenAIDehydratedClient`, but `create()` is async.
|
|
163
|
+
|
|
164
|
+
### `ToolIndex`
|
|
165
|
+
|
|
166
|
+
The BM25 index is also available standalone if you want to use it directly.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from dehydrator import ToolIndex
|
|
170
|
+
|
|
171
|
+
index = ToolIndex(tools, top_k=5)
|
|
172
|
+
matched_names = index.search("weather forecast")
|
|
173
|
+
matched_tools = index.get_tools(matched_names)
|
|
174
|
+
|
|
175
|
+
# From MCP Tool objects
|
|
176
|
+
index = ToolIndex.from_mcp(mcp_tools, top_k=5)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Always-available tools
|
|
180
|
+
|
|
181
|
+
Some tools should always be in context (e.g. a `help` tool). Pass their names to `always_available`:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
client = DehydratedClient(
|
|
185
|
+
anthropic.Anthropic(),
|
|
186
|
+
tools=tools,
|
|
187
|
+
always_available=["help", "get_current_user"],
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
These tools are sent in every request without requiring a search.
|
|
192
|
+
|
|
193
|
+
## Multi-turn conversations
|
|
194
|
+
|
|
195
|
+
Discovered tools persist across calls to `create()`. If the model found `send_email` in turn 1, it's still available in turn 2 without re-searching.
|
|
196
|
+
|
|
197
|
+
Call `client.reset_discoveries()` when starting a new conversation:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
# Turn 1: model discovers send_email
|
|
201
|
+
response = client.messages.create(...)
|
|
202
|
+
|
|
203
|
+
# Turn 2: send_email is still available
|
|
204
|
+
response = client.messages.create(...)
|
|
205
|
+
|
|
206
|
+
# New conversation
|
|
207
|
+
client.reset_discoveries()
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Benchmarks
|
|
211
|
+
|
|
212
|
+
Benchmarked against **139 real tool definitions** from 6 popular MCP servers (Chrome DevTools, GitHub, Playwright, Filesystem, Git, Notion).
|
|
213
|
+
|
|
214
|
+
### Token savings
|
|
215
|
+
|
|
216
|
+
Sending all tools in every request is expensive. Dehydrator replaces them with a single `tool_search` tool and only injects the tools the model actually needs:
|
|
217
|
+
|
|
218
|
+
| Tools | top_k=3 | top_k=5 | top_k=10 | Baseline |
|
|
219
|
+
|------:|--------:|--------:|---------:|---------:|
|
|
220
|
+
| 50 | 274 tokens (94%) | 349 tokens (93%) | 678 tokens (86%) | 4,864 |
|
|
221
|
+
| 100 | 274 tokens (97%) | 349 tokens (96%) | 678 tokens (92%) | 8,954 |
|
|
222
|
+
| 200 | 274 tokens (98%) | 349 tokens (98%) | 678 tokens (96%) | 18,159 |
|
|
223
|
+
|
|
224
|
+
With 200 tools and `top_k=5`, you go from **18,159 → 349 tokens** per request — a **98% reduction**.
|
|
225
|
+
|
|
226
|
+
### Search quality
|
|
227
|
+
|
|
228
|
+
BM25 finds the right tools reliably across all 6 MCP servers:
|
|
229
|
+
|
|
230
|
+
| Metric | k=3 | k=5 | k=10 |
|
|
231
|
+
|--------|----:|----:|-----:|
|
|
232
|
+
| Precision@k | 51.1% | 32.7% | 17.3% |
|
|
233
|
+
| Recall@k | 88.6% | 95.3% | 98.3% |
|
|
234
|
+
| **MRR** | | **95.8%** | |
|
|
235
|
+
|
|
236
|
+
30/30 test queries found at least one correct tool in the top 10. The right tool is ranked #1 or #2 in almost every case.
|
|
237
|
+
|
|
238
|
+
### Run the benchmarks
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
uv run python benchmarks/search_quality.py # local, no API key
|
|
242
|
+
uv run python benchmarks/token_savings_openai.py # local, uses tiktoken
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Limitations
|
|
246
|
+
|
|
247
|
+
- **No streaming** — `stream=True` raises `NotImplementedError`. Planned for a future release.
|
|
248
|
+
- **Reserved tool name** — You cannot have a tool named `tool_search`. Dehydrator will raise `ValueError` if you do.
|
|
249
|
+
|
|
250
|
+
## Development
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
git clone https://github.com/Arrmlet/dehydrator.git
|
|
254
|
+
cd dehydrator
|
|
255
|
+
uv sync
|
|
256
|
+
|
|
257
|
+
uv run pytest # tests
|
|
258
|
+
uv run ruff check src/ # lint
|
|
259
|
+
uv run mypy src/ # type check
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT
|
|
File without changes
|