dehydrator 0.2.0__py3-none-any.whl
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/__init__.py +16 -0
- dehydrator/_adapter.py +267 -0
- dehydrator/_client.py +178 -0
- dehydrator/_index.py +67 -0
- dehydrator/_interceptor.py +75 -0
- dehydrator/_openai_client.py +175 -0
- dehydrator/_search_tool.py +45 -0
- dehydrator/_tokenizer.py +73 -0
- dehydrator/_types.py +50 -0
- dehydrator/py.typed +0 -0
- dehydrator-0.2.0.dist-info/METADATA +289 -0
- dehydrator-0.2.0.dist-info/RECORD +13 -0
- dehydrator-0.2.0.dist-info/WHEEL +4 -0
dehydrator/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Dehydrator — Client-side BM25 tool search for LLM APIs."""
|
|
2
|
+
|
|
3
|
+
from dehydrator._client import AsyncDehydratedClient, DehydratedClient
|
|
4
|
+
from dehydrator._index import ToolIndex
|
|
5
|
+
from dehydrator._openai_client import (
|
|
6
|
+
AsyncOpenAIDehydratedClient,
|
|
7
|
+
OpenAIDehydratedClient,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AsyncDehydratedClient",
|
|
12
|
+
"AsyncOpenAIDehydratedClient",
|
|
13
|
+
"DehydratedClient",
|
|
14
|
+
"OpenAIDehydratedClient",
|
|
15
|
+
"ToolIndex",
|
|
16
|
+
]
|
dehydrator/_adapter.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
from dehydrator._index import ToolIndex
|
|
7
|
+
from dehydrator._search_tool import SEARCH_TOOL_DEFINITION, SEARCH_TOOL_NAME
|
|
8
|
+
from dehydrator._types import (
|
|
9
|
+
ToolParam,
|
|
10
|
+
get_tool_description,
|
|
11
|
+
get_tool_name,
|
|
12
|
+
get_tool_schema,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _format_search_result(matched_tools: list[ToolParam]) -> str:
|
|
17
|
+
"""Format matched tools into a human-readable result."""
|
|
18
|
+
if not matched_tools:
|
|
19
|
+
return "No matching tools found. Try a different search query."
|
|
20
|
+
lines = ["Found the following tools:\n"]
|
|
21
|
+
for tool in matched_tools:
|
|
22
|
+
desc = get_tool_description(tool)
|
|
23
|
+
lines.append(f"- **{get_tool_name(tool)}**: {desc}")
|
|
24
|
+
lines.append("\nThese tools are now available for you to use.")
|
|
25
|
+
return "\n".join(lines)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProviderAdapter(Protocol):
|
|
29
|
+
"""Protocol for provider-specific response handling."""
|
|
30
|
+
|
|
31
|
+
def build_tools(
|
|
32
|
+
self,
|
|
33
|
+
index: ToolIndex,
|
|
34
|
+
always_available: list[ToolParam],
|
|
35
|
+
discovered: set[str],
|
|
36
|
+
) -> list[ToolParam]: ...
|
|
37
|
+
|
|
38
|
+
def has_search_call(self, response: Any) -> bool: ...
|
|
39
|
+
|
|
40
|
+
def has_non_search_tool_call(self, response: Any) -> bool: ...
|
|
41
|
+
|
|
42
|
+
def process_search_calls(
|
|
43
|
+
self,
|
|
44
|
+
response: Any,
|
|
45
|
+
index: ToolIndex,
|
|
46
|
+
discovered: set[str],
|
|
47
|
+
) -> list[dict[str, Any]]: ...
|
|
48
|
+
|
|
49
|
+
def append_search_round(
|
|
50
|
+
self,
|
|
51
|
+
messages: list[dict[str, Any]],
|
|
52
|
+
response: Any,
|
|
53
|
+
search_results: list[dict[str, Any]],
|
|
54
|
+
) -> list[dict[str, Any]]: ...
|
|
55
|
+
|
|
56
|
+
def call_api(self, client: Any, **kwargs: Any) -> Any: ...
|
|
57
|
+
|
|
58
|
+
async def acall_api(self, client: Any, **kwargs: Any) -> Any: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AnthropicAdapter:
|
|
62
|
+
"""Adapter for the Anthropic Messages API."""
|
|
63
|
+
|
|
64
|
+
def build_tools(
|
|
65
|
+
self,
|
|
66
|
+
index: ToolIndex,
|
|
67
|
+
always_available: list[ToolParam],
|
|
68
|
+
discovered: set[str],
|
|
69
|
+
) -> list[ToolParam]:
|
|
70
|
+
tools: list[ToolParam] = [SEARCH_TOOL_DEFINITION]
|
|
71
|
+
tools.extend(always_available)
|
|
72
|
+
tools.extend(index.get_tools(sorted(discovered)))
|
|
73
|
+
return tools
|
|
74
|
+
|
|
75
|
+
def has_search_call(self, response: Any) -> bool:
|
|
76
|
+
return any(
|
|
77
|
+
block.type == "tool_use" and block.name == SEARCH_TOOL_NAME
|
|
78
|
+
for block in response.content
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def has_non_search_tool_call(self, response: Any) -> bool:
|
|
82
|
+
return any(
|
|
83
|
+
block.type == "tool_use" and block.name != SEARCH_TOOL_NAME
|
|
84
|
+
for block in response.content
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def process_search_calls(
|
|
88
|
+
self,
|
|
89
|
+
response: Any,
|
|
90
|
+
index: ToolIndex,
|
|
91
|
+
discovered: set[str],
|
|
92
|
+
) -> list[dict[str, Any]]:
|
|
93
|
+
results: list[dict[str, Any]] = []
|
|
94
|
+
for block in response.content:
|
|
95
|
+
if block.type == "tool_use" and block.name == SEARCH_TOOL_NAME:
|
|
96
|
+
query = str(block.input.get("query", ""))
|
|
97
|
+
matched_names = index.search(query)
|
|
98
|
+
discovered.update(matched_names)
|
|
99
|
+
matched_tools = index.get_tools(matched_names)
|
|
100
|
+
results.append(
|
|
101
|
+
{
|
|
102
|
+
"type": "tool_result",
|
|
103
|
+
"tool_use_id": block.id,
|
|
104
|
+
"content": _format_search_result(matched_tools),
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
return results
|
|
108
|
+
|
|
109
|
+
def append_search_round(
|
|
110
|
+
self,
|
|
111
|
+
messages: list[dict[str, Any]],
|
|
112
|
+
response: Any,
|
|
113
|
+
search_results: list[dict[str, Any]],
|
|
114
|
+
) -> list[dict[str, Any]]:
|
|
115
|
+
messages = list(messages)
|
|
116
|
+
messages.append(
|
|
117
|
+
{
|
|
118
|
+
"role": "assistant",
|
|
119
|
+
"content": _response_content_to_params(response),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
messages.append(
|
|
123
|
+
{
|
|
124
|
+
"role": "user",
|
|
125
|
+
"content": search_results,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
return messages
|
|
129
|
+
|
|
130
|
+
def call_api(self, client: Any, **kwargs: Any) -> Any:
|
|
131
|
+
return client.messages.create(**kwargs)
|
|
132
|
+
|
|
133
|
+
async def acall_api(self, client: Any, **kwargs: Any) -> Any:
|
|
134
|
+
return await client.messages.create(**kwargs)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _response_content_to_params(
|
|
138
|
+
response: Any,
|
|
139
|
+
) -> list[dict[str, Any]]:
|
|
140
|
+
"""Convert Anthropic response content blocks to message param format."""
|
|
141
|
+
blocks: list[dict[str, Any]] = []
|
|
142
|
+
for block in response.content:
|
|
143
|
+
if block.type == "text":
|
|
144
|
+
blocks.append({"type": "text", "text": block.text})
|
|
145
|
+
elif block.type == "tool_use":
|
|
146
|
+
blocks.append(
|
|
147
|
+
{
|
|
148
|
+
"type": "tool_use",
|
|
149
|
+
"id": block.id,
|
|
150
|
+
"name": block.name,
|
|
151
|
+
"input": block.input,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
elif block.type == "thinking":
|
|
155
|
+
blocks.append(
|
|
156
|
+
{
|
|
157
|
+
"type": "thinking",
|
|
158
|
+
"thinking": block.thinking,
|
|
159
|
+
"signature": block.signature,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
elif block.type == "redacted_thinking":
|
|
163
|
+
blocks.append(
|
|
164
|
+
{
|
|
165
|
+
"type": "redacted_thinking",
|
|
166
|
+
"data": block.data,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
return blocks
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class OpenAIAdapter:
|
|
173
|
+
"""Adapter for OpenAI-compatible APIs."""
|
|
174
|
+
|
|
175
|
+
def _to_openai_tool(self, tool: ToolParam) -> ToolParam:
|
|
176
|
+
"""Convert an Anthropic/MCP tool dict to OpenAI function format."""
|
|
177
|
+
return {
|
|
178
|
+
"type": "function",
|
|
179
|
+
"function": {
|
|
180
|
+
"name": get_tool_name(tool),
|
|
181
|
+
"description": get_tool_description(tool),
|
|
182
|
+
"parameters": get_tool_schema(tool),
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def build_tools(
|
|
187
|
+
self,
|
|
188
|
+
index: ToolIndex,
|
|
189
|
+
always_available: list[ToolParam],
|
|
190
|
+
discovered: set[str],
|
|
191
|
+
) -> list[ToolParam]:
|
|
192
|
+
raw: list[ToolParam] = [SEARCH_TOOL_DEFINITION]
|
|
193
|
+
raw.extend(always_available)
|
|
194
|
+
raw.extend(index.get_tools(sorted(discovered)))
|
|
195
|
+
return [self._to_openai_tool(t) for t in raw]
|
|
196
|
+
|
|
197
|
+
def has_search_call(self, response: Any) -> bool:
|
|
198
|
+
tool_calls = _get_openai_tool_calls(response)
|
|
199
|
+
return any(tc.function.name == SEARCH_TOOL_NAME for tc in tool_calls)
|
|
200
|
+
|
|
201
|
+
def has_non_search_tool_call(self, response: Any) -> bool:
|
|
202
|
+
tool_calls = _get_openai_tool_calls(response)
|
|
203
|
+
return any(tc.function.name != SEARCH_TOOL_NAME for tc in tool_calls)
|
|
204
|
+
|
|
205
|
+
def process_search_calls(
|
|
206
|
+
self,
|
|
207
|
+
response: Any,
|
|
208
|
+
index: ToolIndex,
|
|
209
|
+
discovered: set[str],
|
|
210
|
+
) -> list[dict[str, Any]]:
|
|
211
|
+
results: list[dict[str, Any]] = []
|
|
212
|
+
for tc in _get_openai_tool_calls(response):
|
|
213
|
+
if tc.function.name == SEARCH_TOOL_NAME:
|
|
214
|
+
raw_args = tc.function.arguments
|
|
215
|
+
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
|
216
|
+
query = str(args.get("query", ""))
|
|
217
|
+
matched_names = index.search(query)
|
|
218
|
+
discovered.update(matched_names)
|
|
219
|
+
matched_tools = index.get_tools(matched_names)
|
|
220
|
+
results.append(
|
|
221
|
+
{
|
|
222
|
+
"role": "tool",
|
|
223
|
+
"tool_call_id": tc.id,
|
|
224
|
+
"content": _format_search_result(matched_tools),
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
return results
|
|
228
|
+
|
|
229
|
+
def append_search_round(
|
|
230
|
+
self,
|
|
231
|
+
messages: list[dict[str, Any]],
|
|
232
|
+
response: Any,
|
|
233
|
+
search_results: list[dict[str, Any]],
|
|
234
|
+
) -> list[dict[str, Any]]:
|
|
235
|
+
messages = list(messages)
|
|
236
|
+
message = response.choices[0].message
|
|
237
|
+
assistant_msg: dict[str, Any] = {
|
|
238
|
+
"role": "assistant",
|
|
239
|
+
"content": message.content or "",
|
|
240
|
+
}
|
|
241
|
+
if message.tool_calls:
|
|
242
|
+
assistant_msg["tool_calls"] = [
|
|
243
|
+
{
|
|
244
|
+
"id": tc.id,
|
|
245
|
+
"type": "function",
|
|
246
|
+
"function": {
|
|
247
|
+
"name": tc.function.name,
|
|
248
|
+
"arguments": tc.function.arguments,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
for tc in message.tool_calls
|
|
252
|
+
]
|
|
253
|
+
messages.append(assistant_msg)
|
|
254
|
+
messages.extend(search_results)
|
|
255
|
+
return messages
|
|
256
|
+
|
|
257
|
+
def call_api(self, client: Any, **kwargs: Any) -> Any:
|
|
258
|
+
return client.chat.completions.create(**kwargs)
|
|
259
|
+
|
|
260
|
+
async def acall_api(self, client: Any, **kwargs: Any) -> Any:
|
|
261
|
+
return await client.chat.completions.create(**kwargs)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _get_openai_tool_calls(response: Any) -> list[Any]:
|
|
265
|
+
"""Extract tool_calls from an OpenAI-compatible response."""
|
|
266
|
+
message = response.choices[0].message
|
|
267
|
+
return message.tool_calls or []
|
dehydrator/_client.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import anthropic
|
|
6
|
+
|
|
7
|
+
from dehydrator._adapter import AnthropicAdapter
|
|
8
|
+
from dehydrator._index import ToolIndex
|
|
9
|
+
from dehydrator._interceptor import async_send, send
|
|
10
|
+
from dehydrator._search_tool import SEARCH_TOOL_NAME
|
|
11
|
+
from dehydrator._types import ToolParam, get_tool_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Messages:
|
|
15
|
+
"""Namespace that mimics ``client.messages`` for sync usage."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, parent: DehydratedClient) -> None:
|
|
18
|
+
self._parent = parent
|
|
19
|
+
|
|
20
|
+
def create(self, **kwargs: Any) -> anthropic.types.Message:
|
|
21
|
+
if kwargs.get("stream"):
|
|
22
|
+
raise NotImplementedError(
|
|
23
|
+
"Streaming is not yet supported by DehydratedClient. "
|
|
24
|
+
"Pass stream=False or omit it."
|
|
25
|
+
)
|
|
26
|
+
# Strip tools from kwargs — we manage them
|
|
27
|
+
kwargs.pop("tools", None)
|
|
28
|
+
return cast(
|
|
29
|
+
anthropic.types.Message,
|
|
30
|
+
send(
|
|
31
|
+
client=self._parent._client,
|
|
32
|
+
adapter=AnthropicAdapter(),
|
|
33
|
+
index=self._parent._index,
|
|
34
|
+
always_available=self._parent._always_available,
|
|
35
|
+
discovered=self._parent._discovered,
|
|
36
|
+
max_search_rounds=self._parent._max_search_rounds,
|
|
37
|
+
**kwargs,
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _AsyncMessages:
|
|
43
|
+
"""Namespace that mimics ``client.messages`` for async usage."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, parent: AsyncDehydratedClient) -> None:
|
|
46
|
+
self._parent = parent
|
|
47
|
+
|
|
48
|
+
async def create(self, **kwargs: Any) -> anthropic.types.Message:
|
|
49
|
+
if kwargs.get("stream"):
|
|
50
|
+
raise NotImplementedError(
|
|
51
|
+
"Streaming is not yet supported by AsyncDehydratedClient. "
|
|
52
|
+
"Pass stream=False or omit it."
|
|
53
|
+
)
|
|
54
|
+
kwargs.pop("tools", None)
|
|
55
|
+
return cast(
|
|
56
|
+
anthropic.types.Message,
|
|
57
|
+
await async_send(
|
|
58
|
+
client=self._parent._client,
|
|
59
|
+
adapter=AnthropicAdapter(),
|
|
60
|
+
index=self._parent._index,
|
|
61
|
+
always_available=self._parent._always_available,
|
|
62
|
+
discovered=self._parent._discovered,
|
|
63
|
+
max_search_rounds=self._parent._max_search_rounds,
|
|
64
|
+
**kwargs,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DehydratedClient:
|
|
70
|
+
"""Wraps an ``anthropic.Anthropic`` client with transparent BM25 tool search.
|
|
71
|
+
|
|
72
|
+
Instead of sending all tools in every request, only a search tool and
|
|
73
|
+
``always_available`` tools are sent. When Claude calls the search tool,
|
|
74
|
+
BM25 is run locally and matching tools are added to the next request.
|
|
75
|
+
|
|
76
|
+
Usage::
|
|
77
|
+
|
|
78
|
+
client = DehydratedClient(
|
|
79
|
+
anthropic.Anthropic(),
|
|
80
|
+
tools=all_my_tools,
|
|
81
|
+
top_k=5,
|
|
82
|
+
)
|
|
83
|
+
response = client.messages.create(
|
|
84
|
+
model="claude-sonnet-4-6",
|
|
85
|
+
max_tokens=1024,
|
|
86
|
+
messages=[{"role": "user", "content": "Send an email"}],
|
|
87
|
+
)
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
client: anthropic.Anthropic,
|
|
93
|
+
tools: list[ToolParam],
|
|
94
|
+
*,
|
|
95
|
+
top_k: int = 5,
|
|
96
|
+
always_available: list[str] | None = None,
|
|
97
|
+
max_search_rounds: int = 3,
|
|
98
|
+
) -> None:
|
|
99
|
+
self._validate_tool_names(tools)
|
|
100
|
+
self._client = client
|
|
101
|
+
all_tools, self._always_available = self._split_tools(
|
|
102
|
+
tools, always_available or []
|
|
103
|
+
)
|
|
104
|
+
if not all_tools:
|
|
105
|
+
raise ValueError("No searchable tools provided.")
|
|
106
|
+
self._index = ToolIndex(all_tools, top_k=top_k)
|
|
107
|
+
self._discovered: set[str] = set()
|
|
108
|
+
self._max_search_rounds = max_search_rounds
|
|
109
|
+
self.messages = _Messages(self)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def inner(self) -> anthropic.Anthropic:
|
|
113
|
+
"""The underlying Anthropic client."""
|
|
114
|
+
return self._client
|
|
115
|
+
|
|
116
|
+
def reset_discoveries(self) -> None:
|
|
117
|
+
"""Clear discovered tools. Call this when starting a new conversation."""
|
|
118
|
+
self._discovered.clear()
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _validate_tool_names(tools: list[ToolParam]) -> None:
|
|
122
|
+
for tool in tools:
|
|
123
|
+
if get_tool_name(tool) == SEARCH_TOOL_NAME:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"Tool name {SEARCH_TOOL_NAME!r} is reserved by Dehydrator. "
|
|
126
|
+
"Please rename your tool."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _split_tools(
|
|
131
|
+
tools: list[ToolParam], always_names: list[str]
|
|
132
|
+
) -> tuple[list[ToolParam], list[ToolParam]]:
|
|
133
|
+
always_set = set(always_names)
|
|
134
|
+
always: list[ToolParam] = []
|
|
135
|
+
searchable: list[ToolParam] = []
|
|
136
|
+
for tool in tools:
|
|
137
|
+
if get_tool_name(tool) in always_set:
|
|
138
|
+
always.append(tool)
|
|
139
|
+
else:
|
|
140
|
+
searchable.append(tool)
|
|
141
|
+
return searchable, always
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class AsyncDehydratedClient:
|
|
145
|
+
"""Async version of :class:`DehydratedClient`.
|
|
146
|
+
|
|
147
|
+
Wraps an ``anthropic.AsyncAnthropic`` client.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
client: anthropic.AsyncAnthropic,
|
|
153
|
+
tools: list[ToolParam],
|
|
154
|
+
*,
|
|
155
|
+
top_k: int = 5,
|
|
156
|
+
always_available: list[str] | None = None,
|
|
157
|
+
max_search_rounds: int = 3,
|
|
158
|
+
) -> None:
|
|
159
|
+
DehydratedClient._validate_tool_names(tools)
|
|
160
|
+
self._client = client
|
|
161
|
+
all_tools, self._always_available = DehydratedClient._split_tools(
|
|
162
|
+
tools, always_available or []
|
|
163
|
+
)
|
|
164
|
+
if not all_tools:
|
|
165
|
+
raise ValueError("No searchable tools provided.")
|
|
166
|
+
self._index = ToolIndex(all_tools, top_k=top_k)
|
|
167
|
+
self._discovered: set[str] = set()
|
|
168
|
+
self._max_search_rounds = max_search_rounds
|
|
169
|
+
self.messages = _AsyncMessages(self)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def inner(self) -> anthropic.AsyncAnthropic:
|
|
173
|
+
"""The underlying async Anthropic client."""
|
|
174
|
+
return self._client
|
|
175
|
+
|
|
176
|
+
def reset_discoveries(self) -> None:
|
|
177
|
+
"""Clear discovered tools. Call this when starting a new conversation."""
|
|
178
|
+
self._discovered.clear()
|
dehydrator/_index.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rank_bm25 import BM25L
|
|
6
|
+
|
|
7
|
+
from dehydrator._tokenizer import tokenize_query, tokenize_tool
|
|
8
|
+
from dehydrator._types import ToolParam, get_tool_name, mcp_tool_to_dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolIndex:
|
|
12
|
+
"""BM25 search index over tool definitions."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, tools: list[ToolParam], *, top_k: int = 5) -> None:
|
|
15
|
+
if not tools:
|
|
16
|
+
raise ValueError("tools must not be empty")
|
|
17
|
+
self._tools_by_name: dict[str, ToolParam] = {}
|
|
18
|
+
corpus: list[list[str]] = []
|
|
19
|
+
names: list[str] = []
|
|
20
|
+
for tool in tools:
|
|
21
|
+
name = get_tool_name(tool)
|
|
22
|
+
if name in self._tools_by_name:
|
|
23
|
+
raise ValueError(f"Duplicate tool name: {name!r}")
|
|
24
|
+
self._tools_by_name[name] = tool
|
|
25
|
+
names.append(name)
|
|
26
|
+
corpus.append(tokenize_tool(tool))
|
|
27
|
+
self._names = names
|
|
28
|
+
self._bm25 = BM25L(corpus)
|
|
29
|
+
self._top_k = top_k
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_mcp(cls, tools: list[Any], *, top_k: int = 5) -> ToolIndex:
|
|
33
|
+
"""Create a ToolIndex from a list of ``mcp.types.Tool`` objects."""
|
|
34
|
+
return cls([mcp_tool_to_dict(t) for t in tools], top_k=top_k)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def tool_names(self) -> list[str]:
|
|
38
|
+
"""All indexed tool names."""
|
|
39
|
+
return list(self._names)
|
|
40
|
+
|
|
41
|
+
def search(self, query: str) -> list[str]:
|
|
42
|
+
"""Return up to *top_k* tool names ranked by BM25 relevance.
|
|
43
|
+
|
|
44
|
+
Only tools with a positive score are returned.
|
|
45
|
+
"""
|
|
46
|
+
tokens = tokenize_query(query)
|
|
47
|
+
if not tokens:
|
|
48
|
+
return []
|
|
49
|
+
scores = self._bm25.get_scores(tokens)
|
|
50
|
+
scored = [
|
|
51
|
+
(name, float(score))
|
|
52
|
+
for name, score in zip(self._names, scores)
|
|
53
|
+
if score > 0
|
|
54
|
+
]
|
|
55
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
56
|
+
return [name for name, _ in scored[: self._top_k]]
|
|
57
|
+
|
|
58
|
+
def get_tools(self, names: list[str]) -> list[ToolParam]:
|
|
59
|
+
"""Return full tool definitions for the given names.
|
|
60
|
+
|
|
61
|
+
Unknown names are silently skipped.
|
|
62
|
+
"""
|
|
63
|
+
return [self._tools_by_name[n] for n in names if n in self._tools_by_name]
|
|
64
|
+
|
|
65
|
+
def get_tool(self, name: str) -> ToolParam | None:
|
|
66
|
+
"""Return a single tool definition by name, or None."""
|
|
67
|
+
return self._tools_by_name.get(name)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dehydrator._adapter import ProviderAdapter
|
|
6
|
+
from dehydrator._index import ToolIndex
|
|
7
|
+
from dehydrator._types import ToolParam
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def send(
|
|
11
|
+
client: Any,
|
|
12
|
+
adapter: ProviderAdapter,
|
|
13
|
+
index: ToolIndex,
|
|
14
|
+
always_available: list[ToolParam],
|
|
15
|
+
discovered: set[str],
|
|
16
|
+
max_search_rounds: int,
|
|
17
|
+
**kwargs: Any,
|
|
18
|
+
) -> Any:
|
|
19
|
+
"""Synchronous send with search interception."""
|
|
20
|
+
response: Any = None
|
|
21
|
+
for _ in range(max_search_rounds):
|
|
22
|
+
tools = adapter.build_tools(index, always_available, discovered)
|
|
23
|
+
kwargs["tools"] = tools
|
|
24
|
+
response = adapter.call_api(client, **kwargs)
|
|
25
|
+
|
|
26
|
+
if not adapter.has_search_call(response):
|
|
27
|
+
return response
|
|
28
|
+
|
|
29
|
+
if adapter.has_non_search_tool_call(response):
|
|
30
|
+
adapter.process_search_calls(response, index, discovered)
|
|
31
|
+
return response
|
|
32
|
+
|
|
33
|
+
search_results = adapter.process_search_calls(response, index, discovered)
|
|
34
|
+
|
|
35
|
+
messages = list(kwargs.get("messages", []))
|
|
36
|
+
kwargs["messages"] = adapter.append_search_round(
|
|
37
|
+
messages, response, search_results
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
assert response is not None
|
|
41
|
+
return response
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def async_send(
|
|
45
|
+
client: Any,
|
|
46
|
+
adapter: ProviderAdapter,
|
|
47
|
+
index: ToolIndex,
|
|
48
|
+
always_available: list[ToolParam],
|
|
49
|
+
discovered: set[str],
|
|
50
|
+
max_search_rounds: int,
|
|
51
|
+
**kwargs: Any,
|
|
52
|
+
) -> Any:
|
|
53
|
+
"""Asynchronous send with search interception."""
|
|
54
|
+
response: Any = None
|
|
55
|
+
for _ in range(max_search_rounds):
|
|
56
|
+
tools = adapter.build_tools(index, always_available, discovered)
|
|
57
|
+
kwargs["tools"] = tools
|
|
58
|
+
response = await adapter.acall_api(client, **kwargs)
|
|
59
|
+
|
|
60
|
+
if not adapter.has_search_call(response):
|
|
61
|
+
return response
|
|
62
|
+
|
|
63
|
+
if adapter.has_non_search_tool_call(response):
|
|
64
|
+
adapter.process_search_calls(response, index, discovered)
|
|
65
|
+
return response
|
|
66
|
+
|
|
67
|
+
search_results = adapter.process_search_calls(response, index, discovered)
|
|
68
|
+
|
|
69
|
+
messages = list(kwargs.get("messages", []))
|
|
70
|
+
kwargs["messages"] = adapter.append_search_round(
|
|
71
|
+
messages, response, search_results
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert response is not None
|
|
75
|
+
return response
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dehydrator._adapter import OpenAIAdapter
|
|
6
|
+
from dehydrator._index import ToolIndex
|
|
7
|
+
from dehydrator._interceptor import async_send, send
|
|
8
|
+
from dehydrator._search_tool import SEARCH_TOOL_NAME
|
|
9
|
+
from dehydrator._types import ToolParam, get_tool_name
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _ChatCompletions:
|
|
13
|
+
"""Namespace that mimics ``client.chat.completions`` for sync usage."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, parent: OpenAIDehydratedClient) -> None:
|
|
16
|
+
self._parent = parent
|
|
17
|
+
|
|
18
|
+
def create(self, **kwargs: Any) -> Any:
|
|
19
|
+
if kwargs.get("stream"):
|
|
20
|
+
raise NotImplementedError(
|
|
21
|
+
"Streaming is not yet supported by OpenAIDehydratedClient. "
|
|
22
|
+
"Pass stream=False or omit it."
|
|
23
|
+
)
|
|
24
|
+
kwargs.pop("tools", None)
|
|
25
|
+
return send(
|
|
26
|
+
client=self._parent._client,
|
|
27
|
+
adapter=OpenAIAdapter(),
|
|
28
|
+
index=self._parent._index,
|
|
29
|
+
always_available=self._parent._always_available,
|
|
30
|
+
discovered=self._parent._discovered,
|
|
31
|
+
max_search_rounds=self._parent._max_search_rounds,
|
|
32
|
+
**kwargs,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _AsyncChatCompletions:
|
|
37
|
+
"""Namespace that mimics ``client.chat.completions`` for async usage."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, parent: AsyncOpenAIDehydratedClient) -> None:
|
|
40
|
+
self._parent = parent
|
|
41
|
+
|
|
42
|
+
async def create(self, **kwargs: Any) -> Any:
|
|
43
|
+
if kwargs.get("stream"):
|
|
44
|
+
raise NotImplementedError(
|
|
45
|
+
"Streaming is not yet supported by AsyncOpenAIDehydratedClient. "
|
|
46
|
+
"Pass stream=False or omit it."
|
|
47
|
+
)
|
|
48
|
+
kwargs.pop("tools", None)
|
|
49
|
+
return await async_send(
|
|
50
|
+
client=self._parent._client,
|
|
51
|
+
adapter=OpenAIAdapter(),
|
|
52
|
+
index=self._parent._index,
|
|
53
|
+
always_available=self._parent._always_available,
|
|
54
|
+
discovered=self._parent._discovered,
|
|
55
|
+
max_search_rounds=self._parent._max_search_rounds,
|
|
56
|
+
**kwargs,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Chat:
|
|
61
|
+
"""Namespace that mimics ``client.chat``."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, completions: _ChatCompletions | _AsyncChatCompletions) -> None:
|
|
64
|
+
self.completions = completions
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OpenAIDehydratedClient:
|
|
68
|
+
"""Wraps any OpenAI-compatible client with transparent BM25 tool search.
|
|
69
|
+
|
|
70
|
+
Works with ``openai.OpenAI``, Groq, OpenRouter, Chutes, and any other
|
|
71
|
+
client that implements ``client.chat.completions.create()``.
|
|
72
|
+
|
|
73
|
+
Usage::
|
|
74
|
+
|
|
75
|
+
from openai import OpenAI
|
|
76
|
+
client = OpenAIDehydratedClient(
|
|
77
|
+
OpenAI(),
|
|
78
|
+
tools=all_my_tools,
|
|
79
|
+
top_k=5,
|
|
80
|
+
)
|
|
81
|
+
response = client.chat.completions.create(
|
|
82
|
+
model="gpt-4o",
|
|
83
|
+
messages=[{"role": "user", "content": "Send an email"}],
|
|
84
|
+
)
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
client: Any,
|
|
90
|
+
tools: list[ToolParam],
|
|
91
|
+
*,
|
|
92
|
+
top_k: int = 5,
|
|
93
|
+
always_available: list[str] | None = None,
|
|
94
|
+
max_search_rounds: int = 3,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._validate_tool_names(tools)
|
|
97
|
+
self._client = client
|
|
98
|
+
all_tools, self._always_available = self._split_tools(
|
|
99
|
+
tools, always_available or []
|
|
100
|
+
)
|
|
101
|
+
if not all_tools:
|
|
102
|
+
raise ValueError("No searchable tools provided.")
|
|
103
|
+
self._index = ToolIndex(all_tools, top_k=top_k)
|
|
104
|
+
self._discovered: set[str] = set()
|
|
105
|
+
self._max_search_rounds = max_search_rounds
|
|
106
|
+
self.chat = _Chat(_ChatCompletions(self))
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def inner(self) -> Any:
|
|
110
|
+
"""The underlying OpenAI-compatible client."""
|
|
111
|
+
return self._client
|
|
112
|
+
|
|
113
|
+
def reset_discoveries(self) -> None:
|
|
114
|
+
"""Clear discovered tools. Call this when starting a new conversation."""
|
|
115
|
+
self._discovered.clear()
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _validate_tool_names(tools: list[ToolParam]) -> None:
|
|
119
|
+
for tool in tools:
|
|
120
|
+
if get_tool_name(tool) == SEARCH_TOOL_NAME:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Tool name {SEARCH_TOOL_NAME!r} is reserved by Dehydrator. "
|
|
123
|
+
"Please rename your tool."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _split_tools(
|
|
128
|
+
tools: list[ToolParam], always_names: list[str]
|
|
129
|
+
) -> tuple[list[ToolParam], list[ToolParam]]:
|
|
130
|
+
always_set = set(always_names)
|
|
131
|
+
always: list[ToolParam] = []
|
|
132
|
+
searchable: list[ToolParam] = []
|
|
133
|
+
for tool in tools:
|
|
134
|
+
if get_tool_name(tool) in always_set:
|
|
135
|
+
always.append(tool)
|
|
136
|
+
else:
|
|
137
|
+
searchable.append(tool)
|
|
138
|
+
return searchable, always
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AsyncOpenAIDehydratedClient:
|
|
142
|
+
"""Async version of :class:`OpenAIDehydratedClient`.
|
|
143
|
+
|
|
144
|
+
Wraps any async OpenAI-compatible client.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
client: Any,
|
|
150
|
+
tools: list[ToolParam],
|
|
151
|
+
*,
|
|
152
|
+
top_k: int = 5,
|
|
153
|
+
always_available: list[str] | None = None,
|
|
154
|
+
max_search_rounds: int = 3,
|
|
155
|
+
) -> None:
|
|
156
|
+
OpenAIDehydratedClient._validate_tool_names(tools)
|
|
157
|
+
self._client = client
|
|
158
|
+
all_tools, self._always_available = OpenAIDehydratedClient._split_tools(
|
|
159
|
+
tools, always_available or []
|
|
160
|
+
)
|
|
161
|
+
if not all_tools:
|
|
162
|
+
raise ValueError("No searchable tools provided.")
|
|
163
|
+
self._index = ToolIndex(all_tools, top_k=top_k)
|
|
164
|
+
self._discovered: set[str] = set()
|
|
165
|
+
self._max_search_rounds = max_search_rounds
|
|
166
|
+
self.chat = _Chat(_AsyncChatCompletions(self))
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def inner(self) -> Any:
|
|
170
|
+
"""The underlying async OpenAI-compatible client."""
|
|
171
|
+
return self._client
|
|
172
|
+
|
|
173
|
+
def reset_discoveries(self) -> None:
|
|
174
|
+
"""Clear discovered tools. Call this when starting a new conversation."""
|
|
175
|
+
self._discovered.clear()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dehydrator._types import ToolParam
|
|
4
|
+
|
|
5
|
+
SEARCH_TOOL_NAME = "tool_search"
|
|
6
|
+
|
|
7
|
+
_SEARCH_DESCRIPTION = (
|
|
8
|
+
"Search for available tools by describing what you want to do. "
|
|
9
|
+
"Use this before attempting to call a tool you haven't discovered yet. "
|
|
10
|
+
"Returns the names and descriptions of matching tools which will then "
|
|
11
|
+
"become available for you to use."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_SCHEMA: ToolParam = {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"query": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": (
|
|
20
|
+
"A natural language description of the action you want to "
|
|
21
|
+
"perform. Be specific — e.g. 'send an email' or "
|
|
22
|
+
"'get weather forecast'."
|
|
23
|
+
),
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
"required": ["query"],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
SEARCH_TOOL_DEFINITION: ToolParam = {
|
|
30
|
+
"name": SEARCH_TOOL_NAME,
|
|
31
|
+
"description": _SEARCH_DESCRIPTION,
|
|
32
|
+
"input_schema": _SCHEMA,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def search_tool_for_openai() -> ToolParam:
|
|
37
|
+
"""Return the search tool definition in OpenAI function-calling format."""
|
|
38
|
+
return {
|
|
39
|
+
"type": "function",
|
|
40
|
+
"function": {
|
|
41
|
+
"name": SEARCH_TOOL_NAME,
|
|
42
|
+
"description": _SEARCH_DESCRIPTION,
|
|
43
|
+
"parameters": _SCHEMA,
|
|
44
|
+
},
|
|
45
|
+
}
|
dehydrator/_tokenizer.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dehydrator._types import get_tool_description, get_tool_name, get_tool_schema
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def tokenize_tool(tool: dict[str, Any]) -> list[str]:
|
|
10
|
+
"""Extract searchable tokens from a tool definition.
|
|
11
|
+
|
|
12
|
+
Pulls text from the tool name, description, and input schema
|
|
13
|
+
(property names, descriptions, enum values) and returns a flat
|
|
14
|
+
list of lowercase tokens. Accepts both Anthropic (``input_schema``)
|
|
15
|
+
and MCP (``inputSchema``) key conventions.
|
|
16
|
+
"""
|
|
17
|
+
tokens: list[str] = []
|
|
18
|
+
|
|
19
|
+
name = get_tool_name(tool)
|
|
20
|
+
tokens.extend(_split_identifier(name))
|
|
21
|
+
|
|
22
|
+
description = get_tool_description(tool)
|
|
23
|
+
tokens.extend(_tokenize_text(description))
|
|
24
|
+
|
|
25
|
+
schema = get_tool_schema(tool)
|
|
26
|
+
_walk_schema(schema, tokens)
|
|
27
|
+
|
|
28
|
+
return tokens
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tokenize_query(query: str) -> list[str]:
|
|
32
|
+
"""Tokenize a free-text search query."""
|
|
33
|
+
return _tokenize_text(query)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _split_identifier(name: str) -> list[str]:
|
|
37
|
+
"""Split a snake_case or camelCase identifier into lowercase tokens."""
|
|
38
|
+
# First split on underscores/hyphens
|
|
39
|
+
parts = re.split(r"[_\-]+", name)
|
|
40
|
+
tokens: list[str] = []
|
|
41
|
+
for part in parts:
|
|
42
|
+
# Then split camelCase: insert boundary before uppercase letters
|
|
43
|
+
sub = re.sub(r"([a-z])([A-Z])", r"\1 \2", part)
|
|
44
|
+
for word in sub.split():
|
|
45
|
+
lower = word.lower()
|
|
46
|
+
if lower:
|
|
47
|
+
tokens.append(lower)
|
|
48
|
+
return tokens
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _tokenize_text(text: str) -> list[str]:
|
|
52
|
+
"""Lowercase and split text on non-alphanumeric characters."""
|
|
53
|
+
return [w for w in re.split(r"[^a-zA-Z0-9]+", text.lower()) if w]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _walk_schema(schema: dict[str, Any], tokens: list[str]) -> None:
|
|
57
|
+
"""Recursively extract tokens from a JSON Schema object."""
|
|
58
|
+
properties: dict[str, Any] = schema.get("properties", {})
|
|
59
|
+
for prop_name, prop_schema in properties.items():
|
|
60
|
+
tokens.extend(_split_identifier(prop_name))
|
|
61
|
+
if "description" in prop_schema:
|
|
62
|
+
tokens.extend(_tokenize_text(prop_schema["description"]))
|
|
63
|
+
if "enum" in prop_schema:
|
|
64
|
+
for val in prop_schema["enum"]:
|
|
65
|
+
if isinstance(val, str):
|
|
66
|
+
tokens.extend(_tokenize_text(val))
|
|
67
|
+
# Recurse into nested objects
|
|
68
|
+
if prop_schema.get("type") == "object":
|
|
69
|
+
_walk_schema(prop_schema, tokens)
|
|
70
|
+
# Recurse into array items
|
|
71
|
+
items = prop_schema.get("items")
|
|
72
|
+
if isinstance(items, dict) and items.get("type") == "object":
|
|
73
|
+
_walk_schema(items, tokens)
|
dehydrator/_types.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
ToolParam = dict[str, Any]
|
|
6
|
+
"""A tool definition dict (Anthropic or MCP format)."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_tool_name(tool: Any) -> str:
|
|
10
|
+
"""Extract tool name from a dict or mcp.types.Tool object."""
|
|
11
|
+
if isinstance(tool, dict):
|
|
12
|
+
return str(tool["name"])
|
|
13
|
+
return str(tool.name)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_tool_description(tool: Any) -> str:
|
|
17
|
+
"""Extract tool description from a dict or mcp.types.Tool object."""
|
|
18
|
+
if isinstance(tool, dict):
|
|
19
|
+
return str(tool.get("description", ""))
|
|
20
|
+
return str(tool.description or "")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_tool_schema(tool: Any) -> dict[str, Any]:
|
|
24
|
+
"""Extract input schema from a dict or mcp.types.Tool object.
|
|
25
|
+
|
|
26
|
+
Checks ``inputSchema`` (MCP camelCase) then ``input_schema``
|
|
27
|
+
(Anthropic snake_case).
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(tool, dict):
|
|
30
|
+
schema: Any = tool.get("inputSchema") or tool.get("input_schema")
|
|
31
|
+
if isinstance(schema, dict):
|
|
32
|
+
return schema
|
|
33
|
+
return {}
|
|
34
|
+
# mcp.types.Tool has .inputSchema
|
|
35
|
+
schema = getattr(tool, "inputSchema", None)
|
|
36
|
+
if schema is None:
|
|
37
|
+
return {}
|
|
38
|
+
if isinstance(schema, dict):
|
|
39
|
+
return schema
|
|
40
|
+
# Pydantic model — convert to dict
|
|
41
|
+
return schema.model_dump() # type: ignore[no-any-return]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mcp_tool_to_dict(tool: Any) -> ToolParam:
|
|
45
|
+
"""Convert an mcp.types.Tool object to an Anthropic-format dict."""
|
|
46
|
+
return {
|
|
47
|
+
"name": get_tool_name(tool),
|
|
48
|
+
"description": get_tool_description(tool),
|
|
49
|
+
"input_schema": get_tool_schema(tool),
|
|
50
|
+
}
|
dehydrator/py.typed
ADDED
|
File without changes
|
|
@@ -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,13 @@
|
|
|
1
|
+
dehydrator/__init__.py,sha256=hSp8Jp-wzuQsYxFiVR7spYvfaVzmaBlQoOnpLYOtNFw,429
|
|
2
|
+
dehydrator/_adapter.py,sha256=ukBmiJnEXitD-RmR6ugaBi1xLDPVzyEjJxokHAxKfjM,8685
|
|
3
|
+
dehydrator/_client.py,sha256=Z_5RGau_0GrOWvb-9zz2en0sF4XLvAdVIeUrJNG6lNQ,5937
|
|
4
|
+
dehydrator/_index.py,sha256=CBPObCBbZdumqnY6QzfPqtlY4ecBB8LFx1JotosR6O0,2326
|
|
5
|
+
dehydrator/_interceptor.py,sha256=O_3AtT30v4X-dgZOt2emGO7_LcsOPX-L7qTY6GIrQQU,2242
|
|
6
|
+
dehydrator/_openai_client.py,sha256=DRy5scqku5UV-Ch13Gmh3ojI3maStR6A0MPZq6cPQoM,5753
|
|
7
|
+
dehydrator/_search_tool.py,sha256=8xBvIqWihdt3X1IOWAdXrgB4IQqi2BzQnPFx0GVUOOo,1255
|
|
8
|
+
dehydrator/_tokenizer.py,sha256=ZMbhmESdlC5vWp8fKKPC40dch9Lbj--_DuwTCroDCuk,2550
|
|
9
|
+
dehydrator/_types.py,sha256=VoBfSdXbfgTX1pdl2ddREOw0eQ4eAu84g-b2DBvYbV0,1540
|
|
10
|
+
dehydrator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
dehydrator-0.2.0.dist-info/METADATA,sha256=b59KQulJiibSBidZbDX1AeM-s_MAN0RT1E5lAVi7Ays,9978
|
|
12
|
+
dehydrator-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
dehydrator-0.2.0.dist-info/RECORD,,
|