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 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
+ }
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any