toolrank 0.1.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.
- toolrank/__init__.py +20 -0
- toolrank/adapters/__init__.py +7 -0
- toolrank/adapters/backends.py +426 -0
- toolrank/adapters/bm25.py +78 -0
- toolrank/adapters/chat_api.py +69 -0
- toolrank/adapters/clm.py +169 -0
- toolrank/adapters/dense.py +103 -0
- toolrank/adapters/embeddings_api.py +221 -0
- toolrank/adapters/heads_np.py +220 -0
- toolrank/adapters/hybrid.py +68 -0
- toolrank/adapters/index_faiss.py +88 -0
- toolrank/adapters/index_numpy.py +141 -0
- toolrank/adapters/index_pgvector.py +97 -0
- toolrank/adapters/mcp_client.py +147 -0
- toolrank/adapters/mcp_proxy.py +391 -0
- toolrank/adapters/rest.py +594 -0
- toolrank/build.py +245 -0
- toolrank/cli.py +938 -0
- toolrank/client.py +193 -0
- toolrank/cut.py +85 -0
- toolrank/datasets/__init__.py +12 -0
- toolrank/datasets/jsonl.py +173 -0
- toolrank/datasets/livemcpbench.py +92 -0
- toolrank/datasets/mcp_zero.py +252 -0
- toolrank/datasets/synthetic.py +213 -0
- toolrank/datasets/toolret.py +117 -0
- toolrank/datasets/toolret_train.py +37 -0
- toolrank/domain.py +106 -0
- toolrank/eval/__init__.py +4 -0
- toolrank/eval/metrics.py +85 -0
- toolrank/eval/runner.py +159 -0
- toolrank/eval/table.py +227 -0
- toolrank/finetune.py +586 -0
- toolrank/formats.py +189 -0
- toolrank/ingest/__init__.py +5 -0
- toolrank/ingest/mcp.py +187 -0
- toolrank/ingest/openapi.py +352 -0
- toolrank/ingest/sync.py +154 -0
- toolrank/ingest/text.py +143 -0
- toolrank/integrations/__init__.py +15 -0
- toolrank/integrations/_common.py +40 -0
- toolrank/integrations/anthropic.py +285 -0
- toolrank/integrations/langgraph.py +202 -0
- toolrank/integrations/litellm.py +259 -0
- toolrank/integrations/llamaindex.py +190 -0
- toolrank/integrations/openai.py +250 -0
- toolrank/names.py +35 -0
- toolrank/ports.py +97 -0
- toolrank/retriever.py +348 -0
- toolrank/usage.py +232 -0
- toolrank-0.1.0.dist-info/METADATA +172 -0
- toolrank-0.1.0.dist-info/RECORD +56 -0
- toolrank-0.1.0.dist-info/WHEEL +4 -0
- toolrank-0.1.0.dist-info/entry_points.txt +2 -0
- toolrank-0.1.0.dist-info/licenses/LICENSE +202 -0
- toolrank-0.1.0.dist-info/licenses/NOTICE +41 -0
toolrank/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Toolrank: tool retrieval for LLM agents with hundreds of tools.
|
|
2
|
+
|
|
3
|
+
The package is organised as ports and adapters:
|
|
4
|
+
|
|
5
|
+
* ``toolrank.domain`` - plain dataclasses (Tool, Query, RankedList, EvalReport).
|
|
6
|
+
* ``toolrank.ports`` - Protocols every adapter implements (Scorer, TextEncoder, VectorIndex, ...).
|
|
7
|
+
* ``toolrank.adapters`` - BM25, dense (any OpenAI-compatible embeddings endpoint), heads, indexes,
|
|
8
|
+
the MCP proxy and REST API, MCP and OpenAPI backends.
|
|
9
|
+
* ``toolrank.ingest`` - MCP servers and OpenAPI specs -> an ingest dir (``tools.jsonl``).
|
|
10
|
+
* ``toolrank.retriever`` - query-time retrieval over an ingest dir, shared by search and serve.
|
|
11
|
+
* ``toolrank.client`` - a standard-library client for ``toolrank serve``'s REST API.
|
|
12
|
+
* ``toolrank.integrations`` - Claude's and OpenAI's tool search, LangGraph, LlamaIndex, LiteLLM.
|
|
13
|
+
* ``toolrank.datasets`` - benchmark converters and the on-disk JSONL format.
|
|
14
|
+
* ``toolrank.eval`` - trec_eval-compatible metrics and the benchmark runner.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from toolrank.domain import EvalReport, Query, RankedList, Tool
|
|
18
|
+
|
|
19
|
+
__all__ = ["EvalReport", "Query", "RankedList", "Tool", "__version__"]
|
|
20
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Adapters: concrete Scorers and TextEncoders behind the ports in ``toolrank.ports``."""
|
|
2
|
+
|
|
3
|
+
from toolrank.adapters.bm25 import BM25Scorer
|
|
4
|
+
from toolrank.adapters.dense import DenseScorer
|
|
5
|
+
from toolrank.adapters.embeddings_api import EmbeddingCache, OpenAIEmbeddings
|
|
6
|
+
|
|
7
|
+
__all__ = ["BM25Scorer", "DenseScorer", "EmbeddingCache", "OpenAIEmbeddings"]
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Where ``call_tool`` goes: MCP servers (long-lived client sessions) and OpenAPI operations (HTTP).
|
|
2
|
+
|
|
3
|
+
MCP: one ``MCPBackend`` per configured server. Its task, started on the first call inside the
|
|
4
|
+
proxy's task group (``Backends.running()``), owns the ``mcp.Client``: connect (bounded by
|
|
5
|
+
``connect_timeout``), list the tools once, stay connected, and after a failure wait for the next
|
|
6
|
+
caller before reconnecting (with backoff). Callers use the session directly with
|
|
7
|
+
``read_timeout_seconds``, so a timed-out call stops there, and a call in flight when the backend
|
|
8
|
+
failed is never resent. Every failure becomes an ``isError`` result naming the server; the
|
|
9
|
+
backend's ``serverInfo`` stamp is removed so clients see the proxy's.
|
|
10
|
+
|
|
11
|
+
OpenAPI: ``OpenAPIExecutor`` turns a tool's ``doc["http"]`` routing and the agent's arguments into
|
|
12
|
+
one HTTP request (``httpx2``): path parameters percent-encoded, query parameters by their OpenAPI
|
|
13
|
+
style (``form`` explode → repeated keys, ``deepObject`` → ``k[sub]=v`` / ``k[0]=v``), booleans as
|
|
14
|
+
``true``/``false``, JSON bodies, and form bodies nested with brackets the way Stripe reads them.
|
|
15
|
+
Safety: only GET and HEAD unless ``allow_write``; configured headers (credentials) go only to the
|
|
16
|
+
configured base URL (a path that does not start with ``/`` is refused: appended to the base URL it
|
|
17
|
+
could name another host; and the composed URL must have the base URL's origin); redirects are not followed; responses are cut at ``max_chars``. Multipart,
|
|
18
|
+
header and cookie parameters are refused for now.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
import re
|
|
25
|
+
import tempfile
|
|
26
|
+
import urllib.parse
|
|
27
|
+
from collections.abc import AsyncIterator, Sequence
|
|
28
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from toolrank.domain import Tool
|
|
33
|
+
from toolrank.ingest.mcp import OpenAPIBackend, ServerConfig
|
|
34
|
+
|
|
35
|
+
SAFE_METHODS = ("GET", "HEAD")
|
|
36
|
+
MAX_RESPONSE_CHARS = 25_000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CallOutcome:
|
|
41
|
+
result: Any # mcp_types.CallToolResult
|
|
42
|
+
outcome: str # one of toolrank.usage.OUTCOMES
|
|
43
|
+
http_status: int | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Refused(ValueError):
|
|
47
|
+
"""The proxy will not make this call (policy or an unsupported shape), whatever the backend says."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def error_result(text: str) -> Any:
|
|
51
|
+
from mcp_types import CallToolResult, TextContent
|
|
52
|
+
|
|
53
|
+
return CallToolResult(content=[TextContent(type="text", text=text)], is_error=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _strip_server_info(result: Any) -> Any:
|
|
57
|
+
from mcp_types import SERVER_INFO_META_KEY
|
|
58
|
+
|
|
59
|
+
meta = getattr(result, "meta", None)
|
|
60
|
+
if isinstance(meta, dict) and SERVER_INFO_META_KEY in meta:
|
|
61
|
+
rest = {k: v for k, v in meta.items() if k != SERVER_INFO_META_KEY}
|
|
62
|
+
return result.model_copy(update={"meta": rest or None})
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# -- MCP --------------------------------------------------------------------------------------
|
|
67
|
+
class MCPBackend:
|
|
68
|
+
"""One MCP server behind the proxy. State changes happen under ``_cond``: callers raise
|
|
69
|
+
``_want`` and wait until a connection exists or an attempt that started after them failed; the
|
|
70
|
+
worker connects when wanted. A caller that sees the transport break sets that connection's own
|
|
71
|
+
``broken`` event, so a late failure never tears down a newer connection."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, cfg: ServerConfig, *, connect_timeout: float = 60.0):
|
|
74
|
+
self.cfg, self.connect_timeout = cfg, connect_timeout
|
|
75
|
+
self._session: tuple[Any, Any] | None = None # (client, its broken event)
|
|
76
|
+
self._error: str | None = None
|
|
77
|
+
self._delay = 0.0
|
|
78
|
+
self._want = False
|
|
79
|
+
self._attempt = 0 # attempts started
|
|
80
|
+
self._failed = 0 # the latest attempt that failed
|
|
81
|
+
self._started = False
|
|
82
|
+
|
|
83
|
+
def _start(self, tg: Any) -> None:
|
|
84
|
+
if self._started:
|
|
85
|
+
return
|
|
86
|
+
import anyio
|
|
87
|
+
|
|
88
|
+
self._cond = anyio.Condition()
|
|
89
|
+
self._started = True
|
|
90
|
+
tg.start_soon(self._run, name=f"toolrank-backend-{self.cfg.name}")
|
|
91
|
+
|
|
92
|
+
async def _run(self) -> None:
|
|
93
|
+
import shutil
|
|
94
|
+
|
|
95
|
+
import anyio
|
|
96
|
+
import httpx2
|
|
97
|
+
from mcp import Client
|
|
98
|
+
|
|
99
|
+
from toolrank.adapters.mcp_client import _reason, _stderr_tail, _transport, list_all
|
|
100
|
+
|
|
101
|
+
while True:
|
|
102
|
+
async with self._cond:
|
|
103
|
+
while not self._want:
|
|
104
|
+
await self._cond.wait()
|
|
105
|
+
self._want = False
|
|
106
|
+
self._attempt += 1
|
|
107
|
+
attempt = self._attempt
|
|
108
|
+
if self._delay:
|
|
109
|
+
await anyio.sleep(self._delay)
|
|
110
|
+
failure = None
|
|
111
|
+
broken = anyio.Event()
|
|
112
|
+
with tempfile.TemporaryFile("w+", encoding="utf-8") as errlog:
|
|
113
|
+
try:
|
|
114
|
+
if self.cfg.transport == "stdio" and shutil.which(self.cfg.command) is None:
|
|
115
|
+
raise FileNotFoundError(f"command not found: {self.cfg.command}")
|
|
116
|
+
# the deadline bounds the connection only; it is lifted once connected
|
|
117
|
+
with anyio.CancelScope(deadline=anyio.current_time() + self.connect_timeout) as scope:
|
|
118
|
+
async with AsyncExitStack() as stack:
|
|
119
|
+
http = None
|
|
120
|
+
if self.cfg.transport == "http":
|
|
121
|
+
http = await stack.enter_async_context(
|
|
122
|
+
httpx2.AsyncClient(
|
|
123
|
+
headers=dict(self.cfg.headers), timeout=httpx2.Timeout(30, read=None)
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
client = await stack.enter_async_context(
|
|
127
|
+
Client(_transport(self.cfg, errlog, http), cache=None)
|
|
128
|
+
)
|
|
129
|
+
await list_all(client)
|
|
130
|
+
scope.deadline = math.inf
|
|
131
|
+
async with self._cond:
|
|
132
|
+
self._session, self._error, self._delay = (client, broken), None, 0.0
|
|
133
|
+
self._cond.notify_all()
|
|
134
|
+
await broken.wait()
|
|
135
|
+
if scope.cancelled_caught:
|
|
136
|
+
failure = _reason(self.cfg, TimeoutError(), self.connect_timeout) + _stderr_tail(
|
|
137
|
+
errlog
|
|
138
|
+
)
|
|
139
|
+
except FileNotFoundError as e:
|
|
140
|
+
failure = f"{self.cfg.name}: {e}"
|
|
141
|
+
except Exception as e: # the SDK raises exception groups, transport and validation errors
|
|
142
|
+
failure = _reason(self.cfg, e, self.connect_timeout) + _stderr_tail(errlog)
|
|
143
|
+
async with self._cond:
|
|
144
|
+
self._session = None
|
|
145
|
+
if failure:
|
|
146
|
+
self._error, self._failed = failure, attempt
|
|
147
|
+
self._delay = min(max(self._delay * 2, 1.0), 30.0)
|
|
148
|
+
self._cond.notify_all()
|
|
149
|
+
|
|
150
|
+
async def _connection(self) -> tuple[Any, Any] | None:
|
|
151
|
+
import anyio
|
|
152
|
+
|
|
153
|
+
session = self._session
|
|
154
|
+
if session is not None:
|
|
155
|
+
return session
|
|
156
|
+
async with self._cond:
|
|
157
|
+
self._want = True
|
|
158
|
+
first = self._attempt + 1 # the next attempt to start is (also) ours
|
|
159
|
+
self._cond.notify_all()
|
|
160
|
+
with anyio.move_on_after(self.connect_timeout + self._delay + 5):
|
|
161
|
+
while self._session is None and self._failed < first:
|
|
162
|
+
await self._cond.wait()
|
|
163
|
+
return self._session
|
|
164
|
+
|
|
165
|
+
async def call(self, tg: Any, name: str, arguments: dict[str, Any], *, timeout: float) -> CallOutcome:
|
|
166
|
+
from mcp.shared.exceptions import MCPError
|
|
167
|
+
from mcp_types import CONNECTION_CLOSED, REQUEST_TIMEOUT
|
|
168
|
+
|
|
169
|
+
self._start(tg)
|
|
170
|
+
server = self.cfg.name
|
|
171
|
+
session = await self._connection()
|
|
172
|
+
if session is None:
|
|
173
|
+
return CallOutcome(error_result(self._error or f"{server}: not connected yet"), "protocol_error")
|
|
174
|
+
client, broken = session
|
|
175
|
+
try:
|
|
176
|
+
result = await client.call_tool(name, arguments, read_timeout_seconds=timeout)
|
|
177
|
+
except MCPError as e:
|
|
178
|
+
if e.code == REQUEST_TIMEOUT:
|
|
179
|
+
return CallOutcome(
|
|
180
|
+
error_result(f"{server}: {name} did not answer within {timeout:g} s"), "timeout"
|
|
181
|
+
)
|
|
182
|
+
if e.code == CONNECTION_CLOSED:
|
|
183
|
+
self._drop(session)
|
|
184
|
+
return CallOutcome(
|
|
185
|
+
error_result(f"{server}: the connection closed during the call; it was not retried"),
|
|
186
|
+
"protocol_error",
|
|
187
|
+
)
|
|
188
|
+
return CallOutcome(error_result(f"{server}: {e.message}"), "protocol_error")
|
|
189
|
+
except (RuntimeError, ValueError) as e: # output-schema checks, validation: the session is fine
|
|
190
|
+
return CallOutcome(error_result(f"{server}: {type(e).__name__}: {e}"), "protocol_error")
|
|
191
|
+
except Exception as e: # a broken transport: reconnect on the next call
|
|
192
|
+
self._drop(session)
|
|
193
|
+
return CallOutcome(
|
|
194
|
+
error_result(f"{server}: {type(e).__name__}: {e}; the call was not retried"), "protocol_error"
|
|
195
|
+
)
|
|
196
|
+
return CallOutcome(_strip_server_info(result), "tool_error" if result.is_error else "ok")
|
|
197
|
+
|
|
198
|
+
def _drop(self, session: tuple[Any, Any]) -> None:
|
|
199
|
+
"""Retire ``session`` (only if it is still current); the next caller reconnects."""
|
|
200
|
+
if self._session is session:
|
|
201
|
+
self._session = None
|
|
202
|
+
session[1].set()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# -- OpenAPI ----------------------------------------------------------------------------------
|
|
206
|
+
def _scalar(v: Any) -> str:
|
|
207
|
+
if isinstance(v, bool):
|
|
208
|
+
return "true" if v else "false"
|
|
209
|
+
return "" if v is None else str(v)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _bracket(prefix: str, value: Any) -> list[tuple[str, str]]:
|
|
213
|
+
"""``{"a": {"b": 1}, "c": [2]}`` -> ``a[b]=1``, ``c[0]=2`` (Stripe's nesting, deepObject)."""
|
|
214
|
+
if isinstance(value, dict):
|
|
215
|
+
return [p for k, v in value.items() for p in _bracket(f"{prefix}[{k}]", v)]
|
|
216
|
+
if isinstance(value, list):
|
|
217
|
+
return [p for i, v in enumerate(value) for p in _bracket(f"{prefix}[{i}]", v)]
|
|
218
|
+
return [(prefix, _scalar(value))]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _query_pairs(name: str, value: Any, style: str, explode: bool) -> list[tuple[str, str]]:
|
|
222
|
+
if style == "deepObject":
|
|
223
|
+
return _bracket(name, value)
|
|
224
|
+
if isinstance(value, list):
|
|
225
|
+
if style == "form" and explode:
|
|
226
|
+
return [(name, _scalar(v)) for v in value]
|
|
227
|
+
sep = {"spaceDelimited": " ", "pipeDelimited": "|"}.get(style, ",")
|
|
228
|
+
return [(name, sep.join(_scalar(v) for v in value))]
|
|
229
|
+
if isinstance(value, dict):
|
|
230
|
+
if style == "form" and explode:
|
|
231
|
+
return [(str(k), _scalar(v)) for k, v in value.items()]
|
|
232
|
+
return [(name, ",".join(f"{k},{_scalar(v)}" for k, v in value.items()))]
|
|
233
|
+
return [(name, _scalar(value))]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def same_origin(url: str, base: str) -> bool:
|
|
237
|
+
"""Whether a request to ``url`` goes to ``base``'s scheme, host and port, as the HTTP client
|
|
238
|
+
reads the two: the guard that keeps a source's headers (credentials) on its own base URL."""
|
|
239
|
+
import httpx2
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
a, b = httpx2.URL(url), httpx2.URL(base)
|
|
243
|
+
except httpx2.InvalidURL:
|
|
244
|
+
return False
|
|
245
|
+
return (a.scheme, a.host, a.port) == (b.scheme, b.host, b.port)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class OpenAPIExecutor:
|
|
249
|
+
def __init__(
|
|
250
|
+
self,
|
|
251
|
+
configs: dict[str, OpenAPIBackend],
|
|
252
|
+
*,
|
|
253
|
+
allow_write: bool = False,
|
|
254
|
+
timeout: float = 60.0,
|
|
255
|
+
transport: Any = None,
|
|
256
|
+
max_chars: int = MAX_RESPONSE_CHARS,
|
|
257
|
+
):
|
|
258
|
+
self.configs, self.allow_write, self.timeout = configs, allow_write, timeout
|
|
259
|
+
self.transport, self.max_chars = transport, max_chars
|
|
260
|
+
self._client: Any = None
|
|
261
|
+
|
|
262
|
+
def build(self, tool: Tool, arguments: dict[str, Any] | None) -> dict[str, Any]:
|
|
263
|
+
"""The request (``httpx2.AsyncClient.request`` keyword arguments) for one call; raises
|
|
264
|
+
``Refused`` for policy and unsupported shapes, ``ValueError`` for bad arguments."""
|
|
265
|
+
http = tool.doc.get("http") or {}
|
|
266
|
+
method = str(http.get("method") or "GET").upper()
|
|
267
|
+
source = tool.category
|
|
268
|
+
if method not in SAFE_METHODS and not self.allow_write:
|
|
269
|
+
raise Refused(
|
|
270
|
+
f"{tool.id} is a {method} operation; this proxy only sends GET and HEAD "
|
|
271
|
+
"(start it with --allow-write to allow changes)"
|
|
272
|
+
)
|
|
273
|
+
cfg = self.configs.get(source)
|
|
274
|
+
base = cfg.base_url if cfg is not None and cfg.base_url else str(http.get("base_url") or "")
|
|
275
|
+
if not re.match(r"https?://", base):
|
|
276
|
+
raise Refused(
|
|
277
|
+
f"{source}: no absolute base URL ({base!r}); set openapi.{source}.base_url in the config"
|
|
278
|
+
)
|
|
279
|
+
headers = dict(cfg.headers) if cfg is not None and cfg.base_url else {}
|
|
280
|
+
spec = http.get("args") or {}
|
|
281
|
+
path = str(http.get("path") or "")
|
|
282
|
+
if not path.startswith("/"): # "@host/x" or ".host/x" after the base URL names another host
|
|
283
|
+
raise Refused(f"{tool.id}: the path {path!r} does not start with '/'")
|
|
284
|
+
query: list[tuple[str, str]] = []
|
|
285
|
+
body: dict[str, Any] = {}
|
|
286
|
+
whole, has_whole = None, False
|
|
287
|
+
for key, value in (arguments or {}).items():
|
|
288
|
+
arg = spec.get(key)
|
|
289
|
+
if arg is None:
|
|
290
|
+
raise ValueError(f"{tool.id} has no argument {key!r}")
|
|
291
|
+
where, name = arg.get("in"), str(arg.get("name", key))
|
|
292
|
+
if where == "path":
|
|
293
|
+
text = _scalar(value)
|
|
294
|
+
if text in (".", ".."): # a dot segment would move the request up the path
|
|
295
|
+
raise ValueError(f"{tool.id}: path parameter {name!r} cannot be {text!r}")
|
|
296
|
+
path = path.replace("{" + name + "}", urllib.parse.quote(text, safe=""))
|
|
297
|
+
elif where == "query":
|
|
298
|
+
if isinstance(value, (list, dict)) and "style" not in arg:
|
|
299
|
+
raise Refused(f"{tool.id}: re-ingest the spec to pass a list or an object in {key!r}")
|
|
300
|
+
query += _query_pairs(
|
|
301
|
+
name, value, str(arg.get("style", "form")), bool(arg.get("explode", True))
|
|
302
|
+
)
|
|
303
|
+
elif where == "body":
|
|
304
|
+
if name:
|
|
305
|
+
body[name] = value
|
|
306
|
+
else:
|
|
307
|
+
whole, has_whole = value, True
|
|
308
|
+
else:
|
|
309
|
+
raise Refused(f"{tool.id}: {where} parameters are not supported by the proxy yet")
|
|
310
|
+
missing = re.findall(r"\{([^}]+)\}", path)
|
|
311
|
+
if missing:
|
|
312
|
+
raise ValueError(f"{tool.id} is missing path parameter(s): {', '.join(missing)}")
|
|
313
|
+
url = base.rstrip("/") + path
|
|
314
|
+
if not same_origin(url, base): # the URL that is sent, however it came to be composed
|
|
315
|
+
raise Refused(f"{tool.id}: {url!r} is not a URL on {base}")
|
|
316
|
+
req: dict[str, Any] = {"method": method, "url": url, "params": query, "headers": headers}
|
|
317
|
+
payload = whole if has_whole else (body or None)
|
|
318
|
+
if payload is not None:
|
|
319
|
+
media = str(http.get("body_media_type") or "application/json")
|
|
320
|
+
if "json" in media:
|
|
321
|
+
req["json"] = payload
|
|
322
|
+
elif media == "application/x-www-form-urlencoded" and isinstance(payload, dict):
|
|
323
|
+
pairs = [p for k, v in payload.items() for p in _bracket(k, v)]
|
|
324
|
+
req["content"] = urllib.parse.urlencode(pairs).encode("utf-8")
|
|
325
|
+
req["headers"] = {**headers, "Content-Type": media}
|
|
326
|
+
else:
|
|
327
|
+
raise Refused(f"{tool.id}: {media} bodies are not supported by the proxy yet")
|
|
328
|
+
return req
|
|
329
|
+
|
|
330
|
+
def _http(self) -> Any:
|
|
331
|
+
import httpx2
|
|
332
|
+
|
|
333
|
+
if self._client is None:
|
|
334
|
+
self._client = httpx2.AsyncClient(
|
|
335
|
+
timeout=httpx2.Timeout(self.timeout), follow_redirects=False, transport=self.transport
|
|
336
|
+
)
|
|
337
|
+
return self._client
|
|
338
|
+
|
|
339
|
+
async def aclose(self) -> None:
|
|
340
|
+
if self._client is not None:
|
|
341
|
+
await self._client.aclose()
|
|
342
|
+
self._client = None
|
|
343
|
+
|
|
344
|
+
async def call(self, tool: Tool, arguments: dict[str, Any] | None) -> CallOutcome:
|
|
345
|
+
import httpx2
|
|
346
|
+
from mcp_types import CallToolResult, TextContent
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
req = self.build(tool, arguments)
|
|
350
|
+
except Refused as e:
|
|
351
|
+
return CallOutcome(error_result(str(e)), "refused")
|
|
352
|
+
except ValueError as e:
|
|
353
|
+
return CallOutcome(error_result(str(e)), "tool_error")
|
|
354
|
+
try:
|
|
355
|
+
resp = await self._http().request(**req)
|
|
356
|
+
except httpx2.TimeoutException:
|
|
357
|
+
return CallOutcome(
|
|
358
|
+
error_result(f"{tool.category}: no answer within {self.timeout:g} s"), "timeout"
|
|
359
|
+
)
|
|
360
|
+
except httpx2.HTTPError as e:
|
|
361
|
+
return CallOutcome(error_result(f"{tool.category}: {type(e).__name__}: {e}"), "protocol_error")
|
|
362
|
+
status = resp.status_code
|
|
363
|
+
if 300 <= status < 400:
|
|
364
|
+
text = f"HTTP {status}: redirect to {resp.headers.get('location', '?')} (not followed)"
|
|
365
|
+
else:
|
|
366
|
+
body = resp.text
|
|
367
|
+
if len(body) > self.max_chars:
|
|
368
|
+
body = body[: self.max_chars] + f"\n… truncated: {len(body)} characters in total"
|
|
369
|
+
text = f"HTTP {status}\n{body}"
|
|
370
|
+
failed = status >= 300
|
|
371
|
+
result = CallToolResult(content=[TextContent(type="text", text=text)], is_error=failed)
|
|
372
|
+
return CallOutcome(result, "tool_error" if failed else "ok", status)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# -- router -----------------------------------------------------------------------------------
|
|
376
|
+
class Backends:
|
|
377
|
+
"""``call_tool``'s router: MCP tools to their server's session, OpenAPI tools over HTTP."""
|
|
378
|
+
|
|
379
|
+
def __init__(
|
|
380
|
+
self,
|
|
381
|
+
servers: Sequence[ServerConfig] = (),
|
|
382
|
+
openapi: dict[str, OpenAPIBackend] | None = None,
|
|
383
|
+
*,
|
|
384
|
+
allow_write: bool = False,
|
|
385
|
+
call_timeout: float = 60.0,
|
|
386
|
+
connect_timeout: float = 60.0,
|
|
387
|
+
transport: Any = None,
|
|
388
|
+
):
|
|
389
|
+
self.mcp = {c.name: MCPBackend(c, connect_timeout=connect_timeout) for c in servers}
|
|
390
|
+
self.http = OpenAPIExecutor(
|
|
391
|
+
openapi or {}, allow_write=allow_write, timeout=call_timeout, transport=transport
|
|
392
|
+
)
|
|
393
|
+
self.call_timeout = call_timeout
|
|
394
|
+
self._tg: Any = None
|
|
395
|
+
|
|
396
|
+
@asynccontextmanager
|
|
397
|
+
async def running(self) -> AsyncIterator[Backends]:
|
|
398
|
+
"""The task group the MCP sessions live in; leaving it closes every backend."""
|
|
399
|
+
import anyio
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
async with anyio.create_task_group() as tg:
|
|
403
|
+
self._tg = tg
|
|
404
|
+
try:
|
|
405
|
+
yield self
|
|
406
|
+
finally:
|
|
407
|
+
tg.cancel_scope.cancel()
|
|
408
|
+
finally:
|
|
409
|
+
self._tg = None
|
|
410
|
+
await self.http.aclose()
|
|
411
|
+
|
|
412
|
+
async def call(self, tool: Tool, arguments: dict[str, Any] | None) -> CallOutcome:
|
|
413
|
+
if "http" in tool.doc:
|
|
414
|
+
return await self.http.call(tool, arguments)
|
|
415
|
+
backend = self.mcp.get(tool.category)
|
|
416
|
+
if backend is None:
|
|
417
|
+
return CallOutcome(
|
|
418
|
+
error_result(
|
|
419
|
+
f"{tool.category}: this MCP server is not configured for the proxy (--config or --server)"
|
|
420
|
+
),
|
|
421
|
+
"refused",
|
|
422
|
+
)
|
|
423
|
+
if self._tg is None:
|
|
424
|
+
raise RuntimeError("Backends.call() outside Backends.running()")
|
|
425
|
+
name = str(tool.doc.get("name") or tool.id)
|
|
426
|
+
return await backend.call(self._tg, name, dict(arguments or {}), timeout=self.call_timeout)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Lexical baseline: BM25 via ``bm25s`` (the ToolRet paper's "BM25s" row).
|
|
2
|
+
|
|
3
|
+
Tokenisation: lowercase, English stopwords, optional Snowball stemming when ``PyStemmer`` is
|
|
4
|
+
installed (``pip install toolrank[stem]``). Anthropic's Tool Search Tool BM25 variant and most
|
|
5
|
+
gateways' "keyword search" are this class of model, so this row is the floor every learned
|
|
6
|
+
scorer must clear.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from toolrank.domain import Query, RankedList, Tool
|
|
17
|
+
from toolrank.formats import QUERY_FORMATS, TOOL_FORMATS, NamedFormatter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _stemmer(lang: str = "english"):
|
|
21
|
+
try:
|
|
22
|
+
import Stemmer # PyStemmer
|
|
23
|
+
|
|
24
|
+
return Stemmer.Stemmer(lang)
|
|
25
|
+
except ImportError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BM25Scorer:
|
|
30
|
+
name = "bm25"
|
|
31
|
+
score_kind = "bm25"
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
tool_format: NamedFormatter | str = "documentation",
|
|
36
|
+
query_format: NamedFormatter | str = "plain",
|
|
37
|
+
*,
|
|
38
|
+
k1: float = 1.5,
|
|
39
|
+
b: float = 0.75,
|
|
40
|
+
stem: bool = True,
|
|
41
|
+
stopwords: str | None = "en",
|
|
42
|
+
):
|
|
43
|
+
self.tool_format = TOOL_FORMATS[tool_format] if isinstance(tool_format, str) else tool_format
|
|
44
|
+
self.query_format = QUERY_FORMATS[query_format] if isinstance(query_format, str) else query_format
|
|
45
|
+
self.k1, self.b, self.stopwords = k1, b, stopwords
|
|
46
|
+
self.stemmer = _stemmer() if stem else None
|
|
47
|
+
self.name = f"bm25/{self.tool_format.name}/{self.query_format.name}" + (
|
|
48
|
+
"" if self.stemmer else "/nostem"
|
|
49
|
+
)
|
|
50
|
+
self._ids: list[str] = []
|
|
51
|
+
self._bm25 = None
|
|
52
|
+
self._lock = threading.Lock() # a server ranks from several threads; the stemmer is not shared-safe
|
|
53
|
+
|
|
54
|
+
def index(self, tools: Iterable[Tool]) -> None:
|
|
55
|
+
import bm25s
|
|
56
|
+
|
|
57
|
+
tools = list(tools)
|
|
58
|
+
self._ids = [t.id for t in tools]
|
|
59
|
+
texts = [self.tool_format(t) for t in tools]
|
|
60
|
+
toks = bm25s.tokenize(texts, stopwords=self.stopwords, stemmer=self.stemmer, show_progress=False)
|
|
61
|
+
self._bm25 = bm25s.BM25(k1=self.k1, b=self.b)
|
|
62
|
+
self._bm25.index(toks, show_progress=False)
|
|
63
|
+
|
|
64
|
+
def rank(self, queries: Sequence[Query], k: int) -> list[RankedList]:
|
|
65
|
+
import bm25s
|
|
66
|
+
|
|
67
|
+
if self._bm25 is None:
|
|
68
|
+
raise RuntimeError("call index() before rank()")
|
|
69
|
+
texts = [self.query_format(q) for q in queries]
|
|
70
|
+
kk = min(k, len(self._ids))
|
|
71
|
+
with self._lock:
|
|
72
|
+
toks = bm25s.tokenize(texts, stopwords=self.stopwords, stemmer=self.stemmer, show_progress=False)
|
|
73
|
+
docs, scores = self._bm25.retrieve(toks, k=kk, show_progress=False, n_threads=1)
|
|
74
|
+
docs, scores = np.asarray(docs), np.asarray(scores, dtype=np.float32)
|
|
75
|
+
return [
|
|
76
|
+
RankedList(q.id, [self._ids[int(j)] for j in docs[i]], [float(s) for s in scores[i]])
|
|
77
|
+
for i, q in enumerate(queries)
|
|
78
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""A ``ChatModel`` over any OpenAI-compatible ``/v1/chat/completions`` endpoint.
|
|
2
|
+
|
|
3
|
+
Used to generate benchmark queries (MCP-Zero) with a local model, e.g. ``vllm serve
|
|
4
|
+
Qwen/Qwen3-8B`` in the default (generate) runner. Greedy by default so a rerun asks the same
|
|
5
|
+
questions. ``extra_body`` is merged into every request for server-specific switches, such as
|
|
6
|
+
``{"chat_template_kwargs": {"enable_thinking": False}}`` to turn off Qwen3's thinking on vLLM.
|
|
7
|
+
|
|
8
|
+
Standard library only (``urllib``), like the embeddings adapter.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from toolrank.adapters.embeddings_api import env_api_key
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenAIChat:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
model: str,
|
|
26
|
+
base_url: str = "http://127.0.0.1:8093/v1",
|
|
27
|
+
*,
|
|
28
|
+
api_key: str | None = None,
|
|
29
|
+
temperature: float = 0.0,
|
|
30
|
+
max_tokens: int = 256,
|
|
31
|
+
extra_body: dict[str, Any] | None = None,
|
|
32
|
+
timeout: float = 300.0,
|
|
33
|
+
max_retries: int = 3,
|
|
34
|
+
):
|
|
35
|
+
self.model, self.base_url = model, base_url.rstrip("/")
|
|
36
|
+
self.api_key = api_key or env_api_key(self.base_url, "TOOLRANK_CHAT_API_KEY")
|
|
37
|
+
self.temperature, self.max_tokens = temperature, max_tokens
|
|
38
|
+
self.extra_body = dict(extra_body or {})
|
|
39
|
+
self.timeout, self.max_retries = timeout, max_retries
|
|
40
|
+
self.name = f"chat/{model}"
|
|
41
|
+
|
|
42
|
+
def complete(self, system: str, user: str) -> str:
|
|
43
|
+
body: dict[str, Any] = {
|
|
44
|
+
"model": self.model,
|
|
45
|
+
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
|
|
46
|
+
"temperature": self.temperature,
|
|
47
|
+
"max_tokens": self.max_tokens,
|
|
48
|
+
**self.extra_body,
|
|
49
|
+
}
|
|
50
|
+
req = urllib.request.Request(
|
|
51
|
+
f"{self.base_url}/chat/completions",
|
|
52
|
+
data=json.dumps(body).encode("utf-8"),
|
|
53
|
+
headers={"Content-Type": "application/json"},
|
|
54
|
+
method="POST",
|
|
55
|
+
)
|
|
56
|
+
if self.api_key: # never copied onto a redirect, which may point anywhere
|
|
57
|
+
req.add_unredirected_header("Authorization", f"Bearer {self.api_key}")
|
|
58
|
+
last: Exception | None = None
|
|
59
|
+
for attempt in range(self.max_retries):
|
|
60
|
+
try:
|
|
61
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
62
|
+
j = json.loads(r.read().decode("utf-8"))
|
|
63
|
+
break
|
|
64
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e: # noqa: PERF203
|
|
65
|
+
last = e
|
|
66
|
+
time.sleep(1.5 * (attempt + 1))
|
|
67
|
+
else:
|
|
68
|
+
raise RuntimeError(f"chat endpoint {self.base_url} unreachable: {last}") from last
|
|
69
|
+
return str(j["choices"][0]["message"].get("content") or "")
|