toolrate 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+
10
+ # Environment
11
+ .env
12
+
13
+ # Secrets — never commit
14
+ api_key.py
15
+ *.token
16
+ npm-token.txt
17
+ npmj-codes.txt
18
+ *-codes.txt
19
+ .pypirc
20
+
21
+ # IDE
22
+ .vscode/
23
+ .idea/
24
+
25
+ # Docker
26
+ docker-compose.override.yml
27
+
28
+ # Test databases
29
+ *.db
30
+
31
+ # Build artifacts
32
+ sdks/python/dist/
33
+
34
+ # OS
35
+ .DS_Store
36
+ nemo
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: toolrate
3
+ Version: 0.3.2
4
+ Summary: Reliability oracle for AI agents — pick the right tool from the start
5
+ Project-URL: Homepage, https://toolrate.ai
6
+ Project-URL: Documentation, https://api.toolrate.ai/docs
7
+ Project-URL: Repository, https://github.com/netvistamedia/toolrate
8
+ Author-email: ToolRate <bleep@toolrate.ai>
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,api,llm,reliability,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.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx>=0.24.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ToolRate Python SDK
26
+
27
+ Python client for the [ToolRate API](https://api.toolrate.ai) — the reliability oracle for AI agents.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install toolrate
33
+ ```
34
+
35
+ ## Quick start — one line of code
36
+
37
+ The `guard` function wraps any tool call with automatic reliability checking:
38
+
39
+ ```python
40
+ from toolrate import ToolRate, guard
41
+
42
+ client = ToolRate(api_key="nf_live_...")
43
+
44
+ # Wrap any tool call — assesses before, reports after, automatically
45
+ result = guard(client, "https://api.openai.com/v1/chat/completions",
46
+ lambda: openai.chat.completions.create(model="gpt-4", messages=[...]))
47
+ ```
48
+
49
+ That's it. ToolRate will:
50
+ 1. Check the tool's reliability score before calling
51
+ 2. Execute the tool call
52
+ 3. Report success/failure back (building the data moat)
53
+ 4. Classify errors automatically
54
+
55
+ ## Auto-fallback
56
+
57
+ When a tool fails, automatically try alternatives:
58
+
59
+ ```python
60
+ result = guard(
61
+ client,
62
+ "https://api.openai.com/v1/chat/completions",
63
+ lambda: openai.chat.completions.create(model="gpt-4", messages=msgs),
64
+ fallbacks=[
65
+ ("https://api.anthropic.com/v1/messages",
66
+ lambda: anthropic.messages.create(model="claude-sonnet-4-20250514", messages=msgs)),
67
+ ("https://api.groq.com/openai/v1/chat/completions",
68
+ lambda: groq.chat.completions.create(model="llama-3.3-70b", messages=msgs)),
69
+ ],
70
+ min_score=50, # Skip tools scoring below 50
71
+ )
72
+ ```
73
+
74
+ ## Decorator
75
+
76
+ ```python
77
+ from toolrate import ToolRate, toolrate_guard
78
+
79
+ client = ToolRate(api_key="nf_live_...")
80
+
81
+ @toolrate_guard(client, "https://api.stripe.com/v1/charges")
82
+ def charge_customer(amount, currency):
83
+ return stripe.Charge.create(amount=amount, currency=currency)
84
+
85
+ # Every call is now automatically assessed + reported
86
+ charge_customer(1000, "usd")
87
+ ```
88
+
89
+ ## Journey tracking
90
+
91
+ Report fallback patterns to power hidden gem discovery:
92
+
93
+ ```python
94
+ # First attempt fails
95
+ client.report("https://api.sendgrid.com/v3/mail/send",
96
+ success=False, error_category="rate_limit",
97
+ session_id="session-123", attempt_number=1)
98
+
99
+ # Fallback succeeds
100
+ client.report("https://api.resend.com/emails",
101
+ success=True, latency_ms=180,
102
+ session_id="session-123", attempt_number=2,
103
+ previous_tool="https://api.sendgrid.com/v3/mail/send")
104
+ ```
105
+
106
+ ## Discovery
107
+
108
+ Find hidden gems and fallback chains based on real agent behavior:
109
+
110
+ ```python
111
+ # Tools that shine as fallbacks
112
+ gems = client.discover_hidden_gems(category="email")
113
+
114
+ # What to try when SendGrid fails
115
+ chain = client.discover_fallback_chain("https://api.sendgrid.com/v3/mail/send")
116
+ ```
117
+
118
+ ## Direct API usage
119
+
120
+ ```python
121
+ from toolrate import ToolRate
122
+
123
+ client = ToolRate(api_key="nf_live_...")
124
+
125
+ # Assess
126
+ result = client.assess("https://api.openai.com/v1/chat/completions",
127
+ context="customer support chatbot")
128
+ print(result["reliability_score"]) # 89.0
129
+ print(result["predicted_failure_risk"]) # "low"
130
+ print(result["common_pitfalls"]) # ["timeout (8% of failures)"]
131
+ print(result["top_alternatives"]) # [{"tool": "...", "score": 90}]
132
+
133
+ # Report
134
+ client.report("https://api.openai.com/v1/chat/completions",
135
+ success=True, latency_ms=2500)
136
+
137
+ client.close()
138
+ ```
139
+
140
+ ## Async support
141
+
142
+ `AsyncToolRate` has the same interface — all methods are `async`.
143
+
144
+ ```python
145
+ from toolrate import AsyncToolRate
146
+
147
+ async with AsyncToolRate(api_key="nf_live_...") as client:
148
+ result = await client.assess("https://api.openai.com/v1/chat/completions")
149
+ ```
@@ -0,0 +1,125 @@
1
+ # ToolRate Python SDK
2
+
3
+ Python client for the [ToolRate API](https://api.toolrate.ai) — the reliability oracle for AI agents.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install toolrate
9
+ ```
10
+
11
+ ## Quick start — one line of code
12
+
13
+ The `guard` function wraps any tool call with automatic reliability checking:
14
+
15
+ ```python
16
+ from toolrate import ToolRate, guard
17
+
18
+ client = ToolRate(api_key="nf_live_...")
19
+
20
+ # Wrap any tool call — assesses before, reports after, automatically
21
+ result = guard(client, "https://api.openai.com/v1/chat/completions",
22
+ lambda: openai.chat.completions.create(model="gpt-4", messages=[...]))
23
+ ```
24
+
25
+ That's it. ToolRate will:
26
+ 1. Check the tool's reliability score before calling
27
+ 2. Execute the tool call
28
+ 3. Report success/failure back (building the data moat)
29
+ 4. Classify errors automatically
30
+
31
+ ## Auto-fallback
32
+
33
+ When a tool fails, automatically try alternatives:
34
+
35
+ ```python
36
+ result = guard(
37
+ client,
38
+ "https://api.openai.com/v1/chat/completions",
39
+ lambda: openai.chat.completions.create(model="gpt-4", messages=msgs),
40
+ fallbacks=[
41
+ ("https://api.anthropic.com/v1/messages",
42
+ lambda: anthropic.messages.create(model="claude-sonnet-4-20250514", messages=msgs)),
43
+ ("https://api.groq.com/openai/v1/chat/completions",
44
+ lambda: groq.chat.completions.create(model="llama-3.3-70b", messages=msgs)),
45
+ ],
46
+ min_score=50, # Skip tools scoring below 50
47
+ )
48
+ ```
49
+
50
+ ## Decorator
51
+
52
+ ```python
53
+ from toolrate import ToolRate, toolrate_guard
54
+
55
+ client = ToolRate(api_key="nf_live_...")
56
+
57
+ @toolrate_guard(client, "https://api.stripe.com/v1/charges")
58
+ def charge_customer(amount, currency):
59
+ return stripe.Charge.create(amount=amount, currency=currency)
60
+
61
+ # Every call is now automatically assessed + reported
62
+ charge_customer(1000, "usd")
63
+ ```
64
+
65
+ ## Journey tracking
66
+
67
+ Report fallback patterns to power hidden gem discovery:
68
+
69
+ ```python
70
+ # First attempt fails
71
+ client.report("https://api.sendgrid.com/v3/mail/send",
72
+ success=False, error_category="rate_limit",
73
+ session_id="session-123", attempt_number=1)
74
+
75
+ # Fallback succeeds
76
+ client.report("https://api.resend.com/emails",
77
+ success=True, latency_ms=180,
78
+ session_id="session-123", attempt_number=2,
79
+ previous_tool="https://api.sendgrid.com/v3/mail/send")
80
+ ```
81
+
82
+ ## Discovery
83
+
84
+ Find hidden gems and fallback chains based on real agent behavior:
85
+
86
+ ```python
87
+ # Tools that shine as fallbacks
88
+ gems = client.discover_hidden_gems(category="email")
89
+
90
+ # What to try when SendGrid fails
91
+ chain = client.discover_fallback_chain("https://api.sendgrid.com/v3/mail/send")
92
+ ```
93
+
94
+ ## Direct API usage
95
+
96
+ ```python
97
+ from toolrate import ToolRate
98
+
99
+ client = ToolRate(api_key="nf_live_...")
100
+
101
+ # Assess
102
+ result = client.assess("https://api.openai.com/v1/chat/completions",
103
+ context="customer support chatbot")
104
+ print(result["reliability_score"]) # 89.0
105
+ print(result["predicted_failure_risk"]) # "low"
106
+ print(result["common_pitfalls"]) # ["timeout (8% of failures)"]
107
+ print(result["top_alternatives"]) # [{"tool": "...", "score": 90}]
108
+
109
+ # Report
110
+ client.report("https://api.openai.com/v1/chat/completions",
111
+ success=True, latency_ms=2500)
112
+
113
+ client.close()
114
+ ```
115
+
116
+ ## Async support
117
+
118
+ `AsyncToolRate` has the same interface — all methods are `async`.
119
+
120
+ ```python
121
+ from toolrate import AsyncToolRate
122
+
123
+ async with AsyncToolRate(api_key="nf_live_...") as client:
124
+ result = await client.assess("https://api.openai.com/v1/chat/completions")
125
+ ```
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "toolrate"
7
+ version = "0.3.2"
8
+ description = "Reliability oracle for AI agents — pick the right tool from the start"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [
13
+ {name = "ToolRate", email = "bleep@toolrate.ai"},
14
+ ]
15
+ keywords = ["ai", "agents", "reliability", "tools", "llm", "api"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Libraries",
27
+ ]
28
+ dependencies = [
29
+ "httpx>=0.24.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://toolrate.ai"
34
+ Documentation = "https://api.toolrate.ai/docs"
35
+ Repository = "https://github.com/netvistamedia/toolrate"
@@ -0,0 +1,28 @@
1
+ """ToolRate Python SDK — reliability oracle for AI agents.
2
+
3
+ Before your agent calls an external tool, check ToolRate for the
4
+ reliability score, common pitfalls, and smart alternatives.
5
+ """
6
+ from .client import (
7
+ ToolRate,
8
+ AsyncToolRate,
9
+ # Backwards-compatible aliases (the package used to be called `nemoflow`)
10
+ NemoFlowClient,
11
+ AsyncNemoFlowClient,
12
+ )
13
+ from .guard import guard, toolrate_guard
14
+
15
+ # Legacy alias for the decorator that used to be called nemoflow_guard
16
+ nemoflow_guard = toolrate_guard
17
+
18
+ __all__ = [
19
+ "ToolRate",
20
+ "AsyncToolRate",
21
+ "guard",
22
+ "toolrate_guard",
23
+ # Backwards-compat exports
24
+ "NemoFlowClient",
25
+ "AsyncNemoFlowClient",
26
+ "nemoflow_guard",
27
+ ]
28
+ __version__ = "0.3.2"
@@ -0,0 +1,430 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ import httpx
6
+
7
+ _DEFAULT_BASE_URL = "https://api.toolrate.ai"
8
+ _DEFAULT_TIMEOUT = 30.0
9
+
10
+
11
+ class ToolRate:
12
+ """Synchronous client for the ToolRate API."""
13
+
14
+ def __init__(
15
+ self,
16
+ api_key: str,
17
+ base_url: str = _DEFAULT_BASE_URL,
18
+ timeout: float = _DEFAULT_TIMEOUT,
19
+ ) -> None:
20
+ self._api_key = api_key
21
+ self._base_url = base_url.rstrip("/")
22
+ self._client = httpx.Client(
23
+ base_url=self._base_url,
24
+ headers={"X-Api-Key": self._api_key},
25
+ timeout=timeout,
26
+ )
27
+
28
+ # -- Assessment ------------------------------------------------------------
29
+
30
+ def assess(
31
+ self,
32
+ tool_identifier: str,
33
+ context: str = "",
34
+ sample_payload: Optional[dict[str, Any]] = None,
35
+ ) -> dict[str, Any]:
36
+ """Assess a tool's reliability and get recommendations."""
37
+ body: dict[str, Any] = {
38
+ "tool_identifier": tool_identifier,
39
+ "context": context,
40
+ }
41
+ if sample_payload is not None:
42
+ body["sample_payload"] = sample_payload
43
+
44
+ resp = self._client.post("/v1/assess", json=body)
45
+ resp.raise_for_status()
46
+ return resp.json()
47
+
48
+ def assess_batch(
49
+ self,
50
+ tools: list[dict[str, str]],
51
+ ) -> dict[str, Any]:
52
+ """Assess up to 20 tools in a single request.
53
+
54
+ Args:
55
+ tools: List of dicts with 'tool_identifier' and optional 'context'.
56
+ Example: [{"tool_identifier": "https://api.stripe.com/v1/charges", "context": "payment"}]
57
+ """
58
+ resp = self._client.post("/v1/assess/batch", json={"tools": tools})
59
+ resp.raise_for_status()
60
+ return resp.json()
61
+
62
+ # -- Reporting -------------------------------------------------------------
63
+
64
+ def report(
65
+ self,
66
+ tool_identifier: str,
67
+ success: bool,
68
+ error_category: Optional[str] = None,
69
+ latency_ms: Optional[int] = None,
70
+ context: str = "",
71
+ session_id: Optional[str] = None,
72
+ attempt_number: Optional[int] = None,
73
+ previous_tool: Optional[str] = None,
74
+ ) -> dict[str, Any]:
75
+ """Report a tool execution outcome.
76
+
77
+ For journey tracking, include session_id, attempt_number, and
78
+ previous_tool when retrying after a failure. This data powers
79
+ the hidden gems and fallback chain features.
80
+ """
81
+ body: dict[str, Any] = {
82
+ "tool_identifier": tool_identifier,
83
+ "success": success,
84
+ "context": context,
85
+ }
86
+ if error_category is not None:
87
+ body["error_category"] = error_category
88
+ if latency_ms is not None:
89
+ body["latency_ms"] = latency_ms
90
+ if session_id is not None:
91
+ body["session_id"] = session_id
92
+ if attempt_number is not None:
93
+ body["attempt_number"] = attempt_number
94
+ if previous_tool is not None:
95
+ body["previous_tool"] = previous_tool
96
+
97
+ resp = self._client.post("/v1/report", json=body)
98
+ resp.raise_for_status()
99
+ return resp.json()
100
+
101
+ # -- Discovery -------------------------------------------------------------
102
+
103
+ def discover_hidden_gems(
104
+ self, category: Optional[str] = None, limit: int = 10
105
+ ) -> dict[str, Any]:
106
+ """Find hidden gem tools that shine as fallbacks."""
107
+ params: dict[str, Any] = {"limit": limit}
108
+ if category:
109
+ params["category"] = category
110
+ resp = self._client.get("/v1/discover/hidden-gems", params=params)
111
+ resp.raise_for_status()
112
+ return resp.json()
113
+
114
+ def discover_fallback_chain(
115
+ self, tool_identifier: str, limit: int = 5
116
+ ) -> dict[str, Any]:
117
+ """Get the best fallback tools when this tool fails."""
118
+ resp = self._client.get(
119
+ "/v1/discover/fallback-chain",
120
+ params={"tool_identifier": tool_identifier, "limit": limit},
121
+ )
122
+ resp.raise_for_status()
123
+ return resp.json()
124
+
125
+ # -- Tools -----------------------------------------------------------------
126
+
127
+ def search_tools(
128
+ self,
129
+ q: Optional[str] = None,
130
+ category: Optional[str] = None,
131
+ offset: int = 0,
132
+ limit: int = 50,
133
+ ) -> dict[str, Any]:
134
+ """Search and browse all rated tools."""
135
+ params: dict[str, Any] = {"offset": offset, "limit": limit}
136
+ if q:
137
+ params["q"] = q
138
+ if category:
139
+ params["category"] = category
140
+ resp = self._client.get("/v1/tools", params=params)
141
+ resp.raise_for_status()
142
+ return resp.json()
143
+
144
+ def list_categories(self) -> dict[str, Any]:
145
+ """List all tool categories with counts."""
146
+ resp = self._client.get("/v1/tools/categories")
147
+ resp.raise_for_status()
148
+ return resp.json()
149
+
150
+ # -- Stats -----------------------------------------------------------------
151
+
152
+ def get_stats(self) -> dict[str, Any]:
153
+ """Get platform-wide statistics."""
154
+ resp = self._client.get("/v1/stats")
155
+ resp.raise_for_status()
156
+ return resp.json()
157
+
158
+ def get_my_stats(self) -> dict[str, Any]:
159
+ """Get personal usage statistics (tier, limits, usage)."""
160
+ resp = self._client.get("/v1/stats/me")
161
+ resp.raise_for_status()
162
+ return resp.json()
163
+
164
+ # -- Webhooks --------------------------------------------------------------
165
+
166
+ def create_webhook(
167
+ self,
168
+ url: str,
169
+ threshold: int = 5,
170
+ tool_identifier: Optional[str] = None,
171
+ event: str = "score.change",
172
+ ) -> dict[str, Any]:
173
+ """Register a webhook for score change alerts.
174
+
175
+ Returns the webhook details including the HMAC signing secret
176
+ (only shown once — store it securely).
177
+ """
178
+ body: dict[str, Any] = {"url": url, "event": event, "threshold": threshold}
179
+ if tool_identifier is not None:
180
+ body["tool_identifier"] = tool_identifier
181
+ resp = self._client.post("/v1/webhooks", json=body)
182
+ resp.raise_for_status()
183
+ return resp.json()
184
+
185
+ def list_webhooks(self) -> dict[str, Any]:
186
+ """List all your registered webhooks."""
187
+ resp = self._client.get("/v1/webhooks")
188
+ resp.raise_for_status()
189
+ return resp.json()
190
+
191
+ def delete_webhook(self, webhook_id: str) -> dict[str, Any]:
192
+ """Delete a webhook by ID."""
193
+ resp = self._client.delete(f"/v1/webhooks/{webhook_id}")
194
+ resp.raise_for_status()
195
+ return resp.json()
196
+
197
+ # -- Account ---------------------------------------------------------------
198
+
199
+ def rotate_key(self) -> dict[str, Any]:
200
+ """Rotate your API key. Returns a new key; the current key is deactivated.
201
+
202
+ Important: Update your client with the new key after calling this.
203
+ """
204
+ resp = self._client.post("/v1/auth/rotate-key")
205
+ resp.raise_for_status()
206
+ return resp.json()
207
+
208
+ def delete_account(self) -> dict[str, Any]:
209
+ """Permanently delete your account and all associated data.
210
+
211
+ This action cannot be undone. Your API key will be deactivated
212
+ and all webhooks removed.
213
+ """
214
+ resp = self._client.delete("/v1/account")
215
+ resp.raise_for_status()
216
+ return resp.json()
217
+
218
+ # -- Lifecycle -------------------------------------------------------------
219
+
220
+ def close(self) -> None:
221
+ self._client.close()
222
+
223
+ def __enter__(self) -> ToolRate:
224
+ return self
225
+
226
+ def __exit__(self, *exc: Any) -> None:
227
+ self.close()
228
+
229
+
230
+ class AsyncToolRate:
231
+ """Asynchronous client for the ToolRate API."""
232
+
233
+ def __init__(
234
+ self,
235
+ api_key: str,
236
+ base_url: str = _DEFAULT_BASE_URL,
237
+ timeout: float = _DEFAULT_TIMEOUT,
238
+ ) -> None:
239
+ self._api_key = api_key
240
+ self._base_url = base_url.rstrip("/")
241
+ self._client = httpx.AsyncClient(
242
+ base_url=self._base_url,
243
+ headers={"X-Api-Key": self._api_key},
244
+ timeout=timeout,
245
+ )
246
+
247
+ # -- Assessment ------------------------------------------------------------
248
+
249
+ async def assess(
250
+ self,
251
+ tool_identifier: str,
252
+ context: str = "",
253
+ sample_payload: Optional[dict[str, Any]] = None,
254
+ ) -> dict[str, Any]:
255
+ """Assess a tool's reliability and get recommendations."""
256
+ body: dict[str, Any] = {
257
+ "tool_identifier": tool_identifier,
258
+ "context": context,
259
+ }
260
+ if sample_payload is not None:
261
+ body["sample_payload"] = sample_payload
262
+
263
+ resp = await self._client.post("/v1/assess", json=body)
264
+ resp.raise_for_status()
265
+ return resp.json()
266
+
267
+ async def assess_batch(
268
+ self,
269
+ tools: list[dict[str, str]],
270
+ ) -> dict[str, Any]:
271
+ """Assess up to 20 tools in a single request."""
272
+ resp = await self._client.post("/v1/assess/batch", json={"tools": tools})
273
+ resp.raise_for_status()
274
+ return resp.json()
275
+
276
+ # -- Reporting -------------------------------------------------------------
277
+
278
+ async def report(
279
+ self,
280
+ tool_identifier: str,
281
+ success: bool,
282
+ error_category: Optional[str] = None,
283
+ latency_ms: Optional[int] = None,
284
+ context: str = "",
285
+ session_id: Optional[str] = None,
286
+ attempt_number: Optional[int] = None,
287
+ previous_tool: Optional[str] = None,
288
+ ) -> dict[str, Any]:
289
+ """Report a tool execution outcome."""
290
+ body: dict[str, Any] = {
291
+ "tool_identifier": tool_identifier,
292
+ "success": success,
293
+ "context": context,
294
+ }
295
+ if error_category is not None:
296
+ body["error_category"] = error_category
297
+ if latency_ms is not None:
298
+ body["latency_ms"] = latency_ms
299
+ if session_id is not None:
300
+ body["session_id"] = session_id
301
+ if attempt_number is not None:
302
+ body["attempt_number"] = attempt_number
303
+ if previous_tool is not None:
304
+ body["previous_tool"] = previous_tool
305
+
306
+ resp = await self._client.post("/v1/report", json=body)
307
+ resp.raise_for_status()
308
+ return resp.json()
309
+
310
+ # -- Discovery -------------------------------------------------------------
311
+
312
+ async def discover_hidden_gems(
313
+ self, category: Optional[str] = None, limit: int = 10
314
+ ) -> dict[str, Any]:
315
+ """Find hidden gem tools that shine as fallbacks."""
316
+ params: dict[str, Any] = {"limit": limit}
317
+ if category:
318
+ params["category"] = category
319
+ resp = await self._client.get("/v1/discover/hidden-gems", params=params)
320
+ resp.raise_for_status()
321
+ return resp.json()
322
+
323
+ async def discover_fallback_chain(
324
+ self, tool_identifier: str, limit: int = 5
325
+ ) -> dict[str, Any]:
326
+ """Get the best fallback tools when this tool fails."""
327
+ resp = await self._client.get(
328
+ "/v1/discover/fallback-chain",
329
+ params={"tool_identifier": tool_identifier, "limit": limit},
330
+ )
331
+ resp.raise_for_status()
332
+ return resp.json()
333
+
334
+ # -- Tools -----------------------------------------------------------------
335
+
336
+ async def search_tools(
337
+ self,
338
+ q: Optional[str] = None,
339
+ category: Optional[str] = None,
340
+ offset: int = 0,
341
+ limit: int = 50,
342
+ ) -> dict[str, Any]:
343
+ """Search and browse all rated tools."""
344
+ params: dict[str, Any] = {"offset": offset, "limit": limit}
345
+ if q:
346
+ params["q"] = q
347
+ if category:
348
+ params["category"] = category
349
+ resp = await self._client.get("/v1/tools", params=params)
350
+ resp.raise_for_status()
351
+ return resp.json()
352
+
353
+ async def list_categories(self) -> dict[str, Any]:
354
+ """List all tool categories with counts."""
355
+ resp = await self._client.get("/v1/tools/categories")
356
+ resp.raise_for_status()
357
+ return resp.json()
358
+
359
+ # -- Stats -----------------------------------------------------------------
360
+
361
+ async def get_stats(self) -> dict[str, Any]:
362
+ """Get platform-wide statistics."""
363
+ resp = await self._client.get("/v1/stats")
364
+ resp.raise_for_status()
365
+ return resp.json()
366
+
367
+ async def get_my_stats(self) -> dict[str, Any]:
368
+ """Get personal usage statistics (tier, limits, usage)."""
369
+ resp = await self._client.get("/v1/stats/me")
370
+ resp.raise_for_status()
371
+ return resp.json()
372
+
373
+ # -- Webhooks --------------------------------------------------------------
374
+
375
+ async def create_webhook(
376
+ self,
377
+ url: str,
378
+ threshold: int = 5,
379
+ tool_identifier: Optional[str] = None,
380
+ event: str = "score.change",
381
+ ) -> dict[str, Any]:
382
+ """Register a webhook for score change alerts."""
383
+ body: dict[str, Any] = {"url": url, "event": event, "threshold": threshold}
384
+ if tool_identifier is not None:
385
+ body["tool_identifier"] = tool_identifier
386
+ resp = await self._client.post("/v1/webhooks", json=body)
387
+ resp.raise_for_status()
388
+ return resp.json()
389
+
390
+ async def list_webhooks(self) -> dict[str, Any]:
391
+ """List all your registered webhooks."""
392
+ resp = await self._client.get("/v1/webhooks")
393
+ resp.raise_for_status()
394
+ return resp.json()
395
+
396
+ async def delete_webhook(self, webhook_id: str) -> dict[str, Any]:
397
+ """Delete a webhook by ID."""
398
+ resp = await self._client.delete(f"/v1/webhooks/{webhook_id}")
399
+ resp.raise_for_status()
400
+ return resp.json()
401
+
402
+ # -- Account ---------------------------------------------------------------
403
+
404
+ async def rotate_key(self) -> dict[str, Any]:
405
+ """Rotate your API key. Returns a new key; the current key is deactivated."""
406
+ resp = await self._client.post("/v1/auth/rotate-key")
407
+ resp.raise_for_status()
408
+ return resp.json()
409
+
410
+ async def delete_account(self) -> dict[str, Any]:
411
+ """Permanently delete your account and all associated data."""
412
+ resp = await self._client.delete("/v1/account")
413
+ resp.raise_for_status()
414
+ return resp.json()
415
+
416
+ # -- Lifecycle -------------------------------------------------------------
417
+
418
+ async def close(self) -> None:
419
+ await self._client.aclose()
420
+
421
+ async def __aenter__(self) -> AsyncToolRate:
422
+ return self
423
+
424
+ async def __aexit__(self, *exc: Any) -> None:
425
+ await self.close()
426
+
427
+
428
+ # Backwards-compatible aliases (deprecated names kept for existing imports)
429
+ NemoFlowClient = ToolRate
430
+ AsyncNemoFlowClient = AsyncToolRate
@@ -0,0 +1,268 @@
1
+ """ToolRate Guard — one-line reliability wrapper for tool calls.
2
+
3
+ Usage:
4
+ from toolrate import ToolRate, guard
5
+
6
+ client = ToolRate("nf_live_...")
7
+
8
+ # Wrap any tool call — assess before, report after
9
+ result = guard(client, "https://api.openai.com/v1/chat/completions",
10
+ lambda: openai.chat.completions.create(...))
11
+
12
+ # Explicit fallbacks — tries each in order on failure
13
+ result = guard(client, "https://api.openai.com/v1/chat/completions",
14
+ lambda: openai.chat.completions.create(...),
15
+ fallbacks=[
16
+ ("https://api.anthropic.com/v1/messages",
17
+ lambda: anthropic.messages.create(...)),
18
+ ])
19
+
20
+ # Dynamic (auto) fallbacks — ToolRate picks from real agent journey data
21
+ result = guard(client, "https://api.openai.com/v1/chat/completions",
22
+ lambda: openai.chat.completions.create(...),
23
+ fallbacks="auto",
24
+ resolvers={
25
+ "https://api.anthropic.com/v1/messages":
26
+ lambda: anthropic.messages.create(...),
27
+ "https://api.groq.com/openai/v1/chat/completions":
28
+ lambda: groq_client.chat.completions.create(...),
29
+ })
30
+
31
+ # As a decorator
32
+ @toolrate_guard(client, "https://api.stripe.com/v1/charges")
33
+ def charge_customer(amount, currency):
34
+ return stripe.Charge.create(amount=amount, currency=currency)
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import time
40
+ import uuid
41
+ from functools import wraps
42
+ from typing import Any, Callable, Literal, TypeVar, Union
43
+
44
+ from toolrate.client import ToolRate
45
+
46
+ T = TypeVar("T")
47
+
48
+ Fallbacks = Union[list[tuple[str, Callable[[], T]]], Literal["auto"], None]
49
+
50
+
51
+ def guard(
52
+ client: ToolRate,
53
+ tool_identifier: str,
54
+ fn: Callable[[], T],
55
+ *,
56
+ context: str = "",
57
+ min_score: float = 0.0,
58
+ fallbacks: Fallbacks = None,
59
+ resolvers: dict[str, Callable[[], T]] | None = None,
60
+ max_fallbacks: int = 3,
61
+ ) -> T:
62
+ """Execute a tool call with ToolRate reliability guard.
63
+
64
+ 1. Assesses the tool's reliability score
65
+ 2. If score < min_score and fallbacks exist, tries the best-scoring fallback
66
+ 3. Executes the tool call
67
+ 4. Reports success/failure back to ToolRate
68
+ 5. On failure with fallbacks, automatically tries the next option
69
+
70
+ Args:
71
+ client: ToolRate instance
72
+ tool_identifier: The tool's API identifier
73
+ fn: The actual tool call to execute (as a callable)
74
+ context: Workflow context for context-bucketed scoring
75
+ min_score: Minimum reliability score to proceed (0-100). Default 0 = always try.
76
+ fallbacks: Either a list of (tool_identifier, callable) pairs, or the string
77
+ "auto" to have ToolRate pick fallbacks dynamically from the primary tool's
78
+ top alternatives and real fallback-chain data. "auto" requires `resolvers`.
79
+ resolvers: Mapping of tool identifier → callable. When `fallbacks="auto"`,
80
+ ToolRate matches candidate alternatives against these keys and only tries
81
+ tools the caller has pre-registered a runner for.
82
+ max_fallbacks: Max number of auto fallbacks to include (default 3).
83
+
84
+ Returns:
85
+ The result of the successful tool call
86
+
87
+ Raises:
88
+ The exception from the last failed tool call if all options are exhausted
89
+ """
90
+ session_id = uuid.uuid4().hex[:16]
91
+
92
+ if fallbacks == "auto":
93
+ explicit_fallbacks: list[tuple[str, Callable[[], T]]] = []
94
+ auto_mode = True
95
+ else:
96
+ explicit_fallbacks = list(fallbacks or [])
97
+ auto_mode = False
98
+
99
+ all_tools: list[tuple[str, Callable[[], T]]] = [(tool_identifier, fn)] + explicit_fallbacks
100
+
101
+ last_error: Exception | None = None
102
+ i = 0
103
+ while i < len(all_tools):
104
+ attempt = i + 1
105
+ ident, call = all_tools[i]
106
+
107
+ # Assess
108
+ assessment: dict[str, Any] | None = None
109
+ try:
110
+ assessment = client.assess(ident, context=context)
111
+ score = assessment.get("reliability_score", 100)
112
+ except Exception:
113
+ score = 100 # If assess fails, don't block the tool call
114
+
115
+ # Resolve auto fallbacks once, using the primary tool's assessment (no extra API call if it has alternatives)
116
+ if auto_mode and i == 0:
117
+ auto_tools = _resolve_auto_fallbacks(
118
+ client, ident, assessment, resolvers or {}, max_fallbacks
119
+ )
120
+ all_tools.extend(auto_tools)
121
+ auto_mode = False
122
+
123
+ # Skip if score too low and we have more options
124
+ if score < min_score and attempt < len(all_tools):
125
+ _safe_report(
126
+ client, ident, success=False, error_category="skipped_low_score",
127
+ context=context, session_id=session_id, attempt_number=attempt,
128
+ previous_tool=all_tools[i - 1][0] if i > 0 else None,
129
+ )
130
+ i += 1
131
+ continue
132
+
133
+ # Execute
134
+ start = time.perf_counter()
135
+ try:
136
+ result = call()
137
+ latency_ms = int((time.perf_counter() - start) * 1000)
138
+
139
+ _safe_report(
140
+ client, ident, success=True, latency_ms=latency_ms,
141
+ context=context, session_id=session_id, attempt_number=attempt,
142
+ previous_tool=all_tools[i - 1][0] if i > 0 else None,
143
+ )
144
+ return result
145
+
146
+ except Exception as e:
147
+ latency_ms = int((time.perf_counter() - start) * 1000)
148
+ last_error = e
149
+
150
+ _safe_report(
151
+ client, ident, success=False, error_category=_classify_error(e),
152
+ latency_ms=latency_ms, context=context, session_id=session_id,
153
+ attempt_number=attempt,
154
+ previous_tool=all_tools[i - 1][0] if i > 0 else None,
155
+ )
156
+
157
+ if attempt >= len(all_tools):
158
+ raise
159
+ i += 1
160
+
161
+ raise last_error # type: ignore[misc]
162
+
163
+
164
+ def toolrate_guard(
165
+ client: ToolRate,
166
+ tool_identifier: str,
167
+ *,
168
+ context: str = "",
169
+ min_score: float = 0.0,
170
+ fallbacks: Fallbacks = None,
171
+ resolvers: dict[str, Callable[[], Any]] | None = None,
172
+ max_fallbacks: int = 3,
173
+ ):
174
+ """Decorator version of guard.
175
+
176
+ Usage:
177
+ @toolrate_guard(client, "https://api.stripe.com/v1/charges")
178
+ def charge(amount, currency):
179
+ return stripe.Charge.create(amount=amount, currency=currency)
180
+ """
181
+ def decorator(fn: Callable[..., T]) -> Callable[..., T]:
182
+ @wraps(fn)
183
+ def wrapper(*args: Any, **kwargs: Any) -> T:
184
+ return guard(
185
+ client, tool_identifier,
186
+ lambda: fn(*args, **kwargs),
187
+ context=context, min_score=min_score,
188
+ fallbacks=fallbacks, resolvers=resolvers,
189
+ max_fallbacks=max_fallbacks,
190
+ )
191
+ return wrapper
192
+ return decorator
193
+
194
+
195
+ def _resolve_auto_fallbacks(
196
+ client: ToolRate,
197
+ primary_identifier: str,
198
+ primary_assessment: dict[str, Any] | None,
199
+ resolvers: dict[str, Callable[[], Any]],
200
+ max_n: int,
201
+ ) -> list[tuple[str, Callable[[], Any]]]:
202
+ """Pick fallback callables by matching ToolRate's alternatives against user resolvers."""
203
+ if not resolvers or max_n <= 0:
204
+ return []
205
+
206
+ candidates: list[str] = []
207
+
208
+ # 1. Reuse top_alternatives from the assessment we already fetched (no extra API call)
209
+ if primary_assessment:
210
+ for alt in primary_assessment.get("top_alternatives") or []:
211
+ if isinstance(alt, dict) and alt.get("tool"):
212
+ candidates.append(alt["tool"])
213
+
214
+ # 2. If no alternatives in assess response, query fallback-chain endpoint
215
+ if not candidates:
216
+ try:
217
+ chain_resp = client.discover_fallback_chain(primary_identifier)
218
+ for item in chain_resp.get("fallback_chain") or []:
219
+ if isinstance(item, dict) and item.get("fallback_tool"):
220
+ candidates.append(item["fallback_tool"])
221
+ except Exception:
222
+ pass
223
+
224
+ out: list[tuple[str, Callable[[], Any]]] = []
225
+ seen: set[str] = {primary_identifier}
226
+ for ident in candidates:
227
+ if ident in seen:
228
+ continue
229
+ runner = resolvers.get(ident)
230
+ if runner is None:
231
+ continue
232
+ out.append((ident, runner))
233
+ seen.add(ident)
234
+ if len(out) >= max_n:
235
+ break
236
+
237
+ return out
238
+
239
+
240
+ def _safe_report(client: ToolRate, tool_identifier: str, **kwargs: Any) -> None:
241
+ """Fire-and-forget reporting. Never fail the user's tool call because reporting failed."""
242
+ try:
243
+ client.report(tool_identifier, **kwargs)
244
+ except Exception:
245
+ pass
246
+
247
+
248
+ def _classify_error(error: Exception) -> str:
249
+ """Best-effort classification of an exception into ToolRate error categories."""
250
+ name = type(error).__name__.lower()
251
+ message = str(error).lower()
252
+
253
+ if "timeout" in name or "timeout" in message or "timed out" in message:
254
+ return "timeout"
255
+ if "ratelimit" in name or "rate" in message and "limit" in message or "429" in message or "too many" in message:
256
+ return "rate_limit"
257
+ if "auth" in name or "unauthorized" in message or "403" in message or "401" in message:
258
+ return "auth_failure"
259
+ if "validation" in name or "invalid" in message or "422" in message:
260
+ return "validation_error"
261
+ if "notfound" in name or "not found" in message or "404" in message:
262
+ return "not_found"
263
+ if "permission" in name or "forbidden" in message:
264
+ return "permission_denied"
265
+ if "connect" in name or "connection" in message or "dns" in message:
266
+ return "connection_error"
267
+
268
+ return "server_error"