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.
- toolrate-0.3.2/.gitignore +36 -0
- toolrate-0.3.2/PKG-INFO +149 -0
- toolrate-0.3.2/README.md +125 -0
- toolrate-0.3.2/pyproject.toml +35 -0
- toolrate-0.3.2/toolrate/__init__.py +28 -0
- toolrate-0.3.2/toolrate/client.py +430 -0
- toolrate-0.3.2/toolrate/guard.py +268 -0
|
@@ -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
|
toolrate-0.3.2/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
toolrate-0.3.2/README.md
ADDED
|
@@ -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"
|