agentscore-py 2.0.2__tar.gz → 2.1.0__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.
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.claude/CLAUDE.md +8 -1
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/PKG-INFO +48 -6
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/README.md +47 -5
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/__init__.py +21 -1
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/client.py +178 -35
- agentscore_py-2.1.0/agentscore/errors.py +97 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/types.py +75 -2
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/pyproject.toml +1 -1
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_client.py +490 -4
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/uv.lock +19 -19
- agentscore_py-2.0.2/agentscore/errors.py +0 -28
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/dependabot.yml +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/workflows/ci.yml +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/workflows/publish.yml +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/workflows/security.yml +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.gitignore +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/CODE_OF_CONDUCT.md +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/CONTRIBUTING.md +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/LICENSE +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/SECURITY.md +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/py.typed +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/test_mode.py +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/lefthook.yml +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/ruff.toml +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/__init__.py +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/conftest.py +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_errors.py +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_integration.py +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_test_mode.py +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_types.py +0 -0
- {agentscore_py-2.0.2 → agentscore_py-2.1.0}/vulture_whitelist.py +0 -0
|
@@ -9,13 +9,20 @@ Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (cr
|
|
|
9
9
|
## Methods (sync + async)
|
|
10
10
|
|
|
11
11
|
- `get_reputation` / `aget_reputation` — cached reputation lookup (free)
|
|
12
|
-
- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`.
|
|
12
|
+
- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. Optional `resolve_signer: { address, network }` opts into server-side wallet-signer-match — the response then carries a `signer_match` block describing whether the supplied signer wallet resolves to the same operator as the claimed `address`.
|
|
13
13
|
- `create_session` / `acreate_session` — create verification session. Returns `agent_memory` + `next_steps`.
|
|
14
14
|
- `poll_session` / `apoll_session` — poll session status, returns credential when verified, plus `next_steps.action`.
|
|
15
15
|
- `create_credential` / `acreate_credential` — create operator credential (24h TTL default). Response includes `agent_memory`.
|
|
16
16
|
- `list_credentials` / `alist_credentials` — list active credentials
|
|
17
17
|
- `revoke_credential` / `arevoke_credential` — revoke a credential
|
|
18
18
|
- `associate_wallet` / `aassociate_wallet` — report a signer wallet seen paying under a credential. Accepts optional `idempotency_key` (payment intent id / tx hash) so retries don't inflate transaction_count.
|
|
19
|
+
- `telemetry_signer_match` / `atelemetry_signer_match` — fire-and-forget POST to `/v1/telemetry/signer-match`; commerce gate uses this to report `pass` / `wallet_signer_mismatch` / `wallet_auth_requires_wallet_signing` verdicts.
|
|
20
|
+
|
|
21
|
+
## Errors + observability
|
|
22
|
+
|
|
23
|
+
Typed error subclasses of `AgentScoreError` so callers can `except` on the specific class without parsing `err.code`: `PaymentRequiredError` (402), `TokenExpiredError` (401 token_expired — exposes parsed `verify_url` / `session_id` / `poll_secret` / `poll_url` / `next_steps` / `agent_memory` instance attributes), `InvalidCredentialError` (401 invalid_credential), `QuotaExceededError` (429 quota_exceeded — don't retry), `RateLimitedError` (429 rate_limited — retry after Retry-After), `TimeoutError` (httpx.TimeoutException — note: subclasses `AgentScoreError`, not the builtin; import explicitly from `agentscore.errors` to disambiguate). All non-timeout `httpx.HTTPError` (ConnectError, ProtocolError, NetworkError, etc.) wrap to `AgentScoreError(code="network_error", status_code=0)` for parity with node-sdk.
|
|
24
|
+
|
|
25
|
+
`assess()` / `aassess()` responses include an optional `quota` field captured from `X-Quota-Limit` / `X-Quota-Used` / `X-Quota-Reset` response headers, so callers can monitor approach-to-cap proactively before hitting 429.
|
|
19
26
|
|
|
20
27
|
## Architecture
|
|
21
28
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentscore-py
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Python client for the AgentScore APIs
|
|
5
5
|
Project-URL: Homepage, https://agentscore.sh
|
|
6
6
|
Project-URL: Repository, https://github.com/agentscore/python-sdk
|
|
@@ -170,18 +170,60 @@ except AgentScoreError as e:
|
|
|
170
170
|
print(e.code, e.status_code, str(e))
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
-
`AgentScoreError.details` carries the rest of the response body — `verify_url`, `linked_wallets`, `claimed_operator`, `actual_signer`, `expected_signer`, `reasons`, `agent_memory` — so callers can branch on granular denial codes without re-parsing
|
|
173
|
+
`AgentScoreError.details` carries the rest of the response body — `verify_url`, `linked_wallets`, `claimed_operator`, `actual_signer`, `expected_signer`, `reasons`, `agent_memory` — so callers can branch on granular denial codes without re-parsing.
|
|
174
|
+
|
|
175
|
+
### Typed error classes
|
|
176
|
+
|
|
177
|
+
For status-code-specific recovery, the SDK raises typed subclasses of `AgentScoreError`. All inherit from `AgentScoreError` so existing `except AgentScoreError` still catches them.
|
|
178
|
+
|
|
179
|
+
| Class | Triggered by | What it adds |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `PaymentRequiredError` | HTTP 402 | The endpoint is not enabled for this account |
|
|
182
|
+
| `TokenExpiredError` | HTTP 401 with `error.code = "token_expired"` | Parsed body fields on the instance: `verify_url`, `session_id`, `poll_secret`, `poll_url`, `next_steps`, `agent_memory` — recover without re-parsing `details` |
|
|
183
|
+
| `InvalidCredentialError` | HTTP 401 with `error.code = "invalid_credential"` | Permanent — switch tokens or restart |
|
|
184
|
+
| `QuotaExceededError` | HTTP 429 with `error.code = "quota_exceeded"` | Account-level cap reached; don't retry |
|
|
185
|
+
| `RateLimitedError` | HTTP 429 with `error.code = "rate_limited"` | Per-second sliding-window cap; retry after `Retry-After` |
|
|
186
|
+
| `TimeoutError` | `httpx.TimeoutException` (connect/read/write/pool timeout) | Distinct from generic network errors. Note: subclasses `AgentScoreError`, **not** the builtin `TimeoutError` — import explicitly from `agentscore.errors` to disambiguate. |
|
|
187
|
+
|
|
188
|
+
All non-timeout `httpx.HTTPError` (ConnectError, ProtocolError, NetworkError, etc.) are wrapped as `AgentScoreError(code="network_error", status_code=0)`.
|
|
174
189
|
|
|
175
190
|
```python
|
|
191
|
+
from agentscore import (
|
|
192
|
+
AgentScore, AgentScoreError, TokenExpiredError, QuotaExceededError,
|
|
193
|
+
)
|
|
194
|
+
from agentscore.errors import TimeoutError as AgentScoreTimeoutError
|
|
195
|
+
|
|
176
196
|
try:
|
|
177
197
|
client.assess("0xabc...", policy={"require_kyc": True})
|
|
198
|
+
except TokenExpiredError as e:
|
|
199
|
+
print("Verify at:", e.verify_url, "poll with:", e.poll_secret)
|
|
200
|
+
except QuotaExceededError as e:
|
|
201
|
+
print("Account quota reached — surface to user; don't retry.")
|
|
202
|
+
except AgentScoreTimeoutError:
|
|
203
|
+
print("Network timeout — retry with backoff.")
|
|
178
204
|
except AgentScoreError as e:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
205
|
+
print(e.code, e.message)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Quota observability
|
|
209
|
+
|
|
210
|
+
`assess()` (and `aassess()`) responses include an optional `quota` field captured from `X-Quota-Limit` / `X-Quota-Used` / `X-Quota-Reset` response headers. Use it to monitor approach-to-cap proactively (warn at 80%, alert at 95%) before a 429:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
result = client.assess("0xabc...", policy={"require_kyc": True})
|
|
214
|
+
quota = result.get("quota")
|
|
215
|
+
if quota and quota["limit"] and quota["used"]:
|
|
216
|
+
pct = (quota["used"] / quota["limit"]) * 100
|
|
217
|
+
if pct > 80:
|
|
218
|
+
print(f"AgentScore quota at {pct:.1f}% — resets {quota['reset']}")
|
|
183
219
|
```
|
|
184
220
|
|
|
221
|
+
`quota` is absent when the API doesn't emit the headers (Enterprise / unlimited tiers).
|
|
222
|
+
|
|
223
|
+
## Telemetry
|
|
224
|
+
|
|
225
|
+
`telemetry_signer_match(payload)` and `atelemetry_signer_match(payload)` are fire-and-forget POSTs to `/v1/telemetry/signer-match` so AgentScore can track aggregate signer-binding behavior across merchants. Used internally by `agentscore-commerce`'s gate; available directly for custom integrations that perform their own wallet-signer-match checks.
|
|
226
|
+
|
|
185
227
|
## Documentation
|
|
186
228
|
|
|
187
229
|
- [API Reference](https://docs.agentscore.sh)
|
|
@@ -147,18 +147,60 @@ except AgentScoreError as e:
|
|
|
147
147
|
print(e.code, e.status_code, str(e))
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
`AgentScoreError.details` carries the rest of the response body — `verify_url`, `linked_wallets`, `claimed_operator`, `actual_signer`, `expected_signer`, `reasons`, `agent_memory` — so callers can branch on granular denial codes without re-parsing
|
|
150
|
+
`AgentScoreError.details` carries the rest of the response body — `verify_url`, `linked_wallets`, `claimed_operator`, `actual_signer`, `expected_signer`, `reasons`, `agent_memory` — so callers can branch on granular denial codes without re-parsing.
|
|
151
|
+
|
|
152
|
+
### Typed error classes
|
|
153
|
+
|
|
154
|
+
For status-code-specific recovery, the SDK raises typed subclasses of `AgentScoreError`. All inherit from `AgentScoreError` so existing `except AgentScoreError` still catches them.
|
|
155
|
+
|
|
156
|
+
| Class | Triggered by | What it adds |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `PaymentRequiredError` | HTTP 402 | The endpoint is not enabled for this account |
|
|
159
|
+
| `TokenExpiredError` | HTTP 401 with `error.code = "token_expired"` | Parsed body fields on the instance: `verify_url`, `session_id`, `poll_secret`, `poll_url`, `next_steps`, `agent_memory` — recover without re-parsing `details` |
|
|
160
|
+
| `InvalidCredentialError` | HTTP 401 with `error.code = "invalid_credential"` | Permanent — switch tokens or restart |
|
|
161
|
+
| `QuotaExceededError` | HTTP 429 with `error.code = "quota_exceeded"` | Account-level cap reached; don't retry |
|
|
162
|
+
| `RateLimitedError` | HTTP 429 with `error.code = "rate_limited"` | Per-second sliding-window cap; retry after `Retry-After` |
|
|
163
|
+
| `TimeoutError` | `httpx.TimeoutException` (connect/read/write/pool timeout) | Distinct from generic network errors. Note: subclasses `AgentScoreError`, **not** the builtin `TimeoutError` — import explicitly from `agentscore.errors` to disambiguate. |
|
|
164
|
+
|
|
165
|
+
All non-timeout `httpx.HTTPError` (ConnectError, ProtocolError, NetworkError, etc.) are wrapped as `AgentScoreError(code="network_error", status_code=0)`.
|
|
151
166
|
|
|
152
167
|
```python
|
|
168
|
+
from agentscore import (
|
|
169
|
+
AgentScore, AgentScoreError, TokenExpiredError, QuotaExceededError,
|
|
170
|
+
)
|
|
171
|
+
from agentscore.errors import TimeoutError as AgentScoreTimeoutError
|
|
172
|
+
|
|
153
173
|
try:
|
|
154
174
|
client.assess("0xabc...", policy={"require_kyc": True})
|
|
175
|
+
except TokenExpiredError as e:
|
|
176
|
+
print("Verify at:", e.verify_url, "poll with:", e.poll_secret)
|
|
177
|
+
except QuotaExceededError as e:
|
|
178
|
+
print("Account quota reached — surface to user; don't retry.")
|
|
179
|
+
except AgentScoreTimeoutError:
|
|
180
|
+
print("Network timeout — retry with backoff.")
|
|
155
181
|
except AgentScoreError as e:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
182
|
+
print(e.code, e.message)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Quota observability
|
|
186
|
+
|
|
187
|
+
`assess()` (and `aassess()`) responses include an optional `quota` field captured from `X-Quota-Limit` / `X-Quota-Used` / `X-Quota-Reset` response headers. Use it to monitor approach-to-cap proactively (warn at 80%, alert at 95%) before a 429:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
result = client.assess("0xabc...", policy={"require_kyc": True})
|
|
191
|
+
quota = result.get("quota")
|
|
192
|
+
if quota and quota["limit"] and quota["used"]:
|
|
193
|
+
pct = (quota["used"] / quota["limit"]) * 100
|
|
194
|
+
if pct > 80:
|
|
195
|
+
print(f"AgentScore quota at {pct:.1f}% — resets {quota['reset']}")
|
|
160
196
|
```
|
|
161
197
|
|
|
198
|
+
`quota` is absent when the API doesn't emit the headers (Enterprise / unlimited tiers).
|
|
199
|
+
|
|
200
|
+
## Telemetry
|
|
201
|
+
|
|
202
|
+
`telemetry_signer_match(payload)` and `atelemetry_signer_match(payload)` are fire-and-forget POSTs to `/v1/telemetry/signer-match` so AgentScore can track aggregate signer-binding behavior across merchants. Used internally by `agentscore-commerce`'s gate; available directly for custom integrations that perform their own wallet-signer-match checks.
|
|
203
|
+
|
|
162
204
|
## Documentation
|
|
163
205
|
|
|
164
206
|
- [API Reference](https://docs.agentscore.sh)
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
from importlib.metadata import version as _pkg_version
|
|
2
2
|
|
|
3
3
|
from agentscore.client import AgentScore
|
|
4
|
-
from agentscore.errors import
|
|
4
|
+
from agentscore.errors import (
|
|
5
|
+
AgentScoreError,
|
|
6
|
+
InvalidCredentialError,
|
|
7
|
+
PaymentRequiredError,
|
|
8
|
+
QuotaExceededError,
|
|
9
|
+
RateLimitedError,
|
|
10
|
+
TimeoutError,
|
|
11
|
+
TokenExpiredError,
|
|
12
|
+
)
|
|
5
13
|
from agentscore.test_mode import AGENTSCORE_TEST_ADDRESSES, is_agentscore_test_address
|
|
6
14
|
from agentscore.types import (
|
|
7
15
|
AccountVerification,
|
|
@@ -21,12 +29,15 @@ from agentscore.types import (
|
|
|
21
29
|
Network,
|
|
22
30
|
NextStepsAction,
|
|
23
31
|
OperatorVerification,
|
|
32
|
+
QuotaInfo,
|
|
24
33
|
Reputation,
|
|
25
34
|
ReputationResponse,
|
|
26
35
|
ReputationStatus,
|
|
36
|
+
ResolveSigner,
|
|
27
37
|
SessionCreateRequest,
|
|
28
38
|
SessionCreateResponse,
|
|
29
39
|
SessionPollResponse,
|
|
40
|
+
SignerMatch,
|
|
30
41
|
VerificationLevel,
|
|
31
42
|
WalletAuthRequiresSigningBody,
|
|
32
43
|
WalletSignerMismatchBody,
|
|
@@ -52,15 +63,24 @@ __all__ = [
|
|
|
52
63
|
"DenialCode",
|
|
53
64
|
"EntityType",
|
|
54
65
|
"Grade",
|
|
66
|
+
"InvalidCredentialError",
|
|
55
67
|
"Network",
|
|
56
68
|
"NextStepsAction",
|
|
57
69
|
"OperatorVerification",
|
|
70
|
+
"PaymentRequiredError",
|
|
71
|
+
"QuotaExceededError",
|
|
72
|
+
"QuotaInfo",
|
|
73
|
+
"RateLimitedError",
|
|
58
74
|
"Reputation",
|
|
59
75
|
"ReputationResponse",
|
|
60
76
|
"ReputationStatus",
|
|
77
|
+
"ResolveSigner",
|
|
61
78
|
"SessionCreateRequest",
|
|
62
79
|
"SessionCreateResponse",
|
|
63
80
|
"SessionPollResponse",
|
|
81
|
+
"SignerMatch",
|
|
82
|
+
"TimeoutError",
|
|
83
|
+
"TokenExpiredError",
|
|
64
84
|
"VerificationLevel",
|
|
65
85
|
"WalletAuthRequiresSigningBody",
|
|
66
86
|
"WalletSignerMismatchBody",
|
|
@@ -8,7 +8,15 @@ from typing import TYPE_CHECKING, Any
|
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
10
|
|
|
11
|
-
from agentscore.errors import
|
|
11
|
+
from agentscore.errors import (
|
|
12
|
+
AgentScoreError,
|
|
13
|
+
InvalidCredentialError,
|
|
14
|
+
PaymentRequiredError,
|
|
15
|
+
QuotaExceededError,
|
|
16
|
+
RateLimitedError,
|
|
17
|
+
TimeoutError,
|
|
18
|
+
TokenExpiredError,
|
|
19
|
+
)
|
|
12
20
|
|
|
13
21
|
logger = logging.getLogger("agentscore")
|
|
14
22
|
|
|
@@ -24,6 +32,99 @@ def _retry_after_seconds(response: httpx.Response) -> float:
|
|
|
24
32
|
return 1.0
|
|
25
33
|
|
|
26
34
|
|
|
35
|
+
def _extract_quota(response: httpx.Response) -> dict[str, Any] | None:
|
|
36
|
+
"""Parse ``X-Quota-Limit``, ``X-Quota-Used``, ``X-Quota-Reset`` from response headers.
|
|
37
|
+
|
|
38
|
+
Returns ``None`` when none of the three are present (Enterprise / unlimited tiers).
|
|
39
|
+
Numeric fields fall back to ``None`` if the header is malformed; reset stays as a
|
|
40
|
+
string ('never' or ISO-8601 timestamp).
|
|
41
|
+
"""
|
|
42
|
+
headers = response.headers
|
|
43
|
+
if not hasattr(headers, "get"):
|
|
44
|
+
return None
|
|
45
|
+
limit = headers.get("x-quota-limit")
|
|
46
|
+
used = headers.get("x-quota-used")
|
|
47
|
+
reset = headers.get("x-quota-reset")
|
|
48
|
+
if limit is None and used is None and reset is None:
|
|
49
|
+
return None
|
|
50
|
+
return {"limit": _parse_quota_number(limit), "used": _parse_quota_number(used), "reset": reset}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_quota_number(raw: str | None) -> int | None:
|
|
54
|
+
if raw is None:
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
return int(raw)
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _do_sync(send_fn: Callable[[], httpx.Response]) -> httpx.Response:
|
|
63
|
+
"""Execute the sync request, wrapping every httpx-layer failure in a typed AgentScoreError.
|
|
64
|
+
|
|
65
|
+
``httpx.TimeoutException`` (and subclasses: ConnectTimeout / ReadTimeout / WriteTimeout /
|
|
66
|
+
PoolTimeout) becomes our :class:`TimeoutError`. Every other ``httpx.HTTPError`` (ConnectError,
|
|
67
|
+
NetworkError, ProtocolError, etc.) becomes :class:`AgentScoreError` with ``code='network_error'``
|
|
68
|
+
and ``status_code=0`` — parity with the node-sdk catch-all.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
return send_fn()
|
|
72
|
+
except httpx.TimeoutException as exc:
|
|
73
|
+
raise TimeoutError(str(exc)) from exc
|
|
74
|
+
except httpx.HTTPError as exc:
|
|
75
|
+
raise AgentScoreError("network_error", str(exc), 0) from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _do_async(send_fn: Callable[[], Awaitable[httpx.Response]]) -> httpx.Response:
|
|
79
|
+
"""Async variant of :func:`_do_sync`."""
|
|
80
|
+
try:
|
|
81
|
+
return await send_fn()
|
|
82
|
+
except httpx.TimeoutException as exc:
|
|
83
|
+
raise TimeoutError(str(exc)) from exc
|
|
84
|
+
except httpx.HTTPError as exc:
|
|
85
|
+
raise AgentScoreError("network_error", str(exc), 0) from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_error_from_response(response: httpx.Response) -> AgentScoreError:
|
|
89
|
+
"""Map a non-2xx ``httpx.Response`` to the right typed :class:`AgentScoreError` subclass.
|
|
90
|
+
|
|
91
|
+
Reads the body to extract ``error.code`` for discrimination + the rest for ``details``.
|
|
92
|
+
Falls through to a generic :class:`AgentScoreError` for codes the SDK doesn't have a
|
|
93
|
+
dedicated subclass for.
|
|
94
|
+
"""
|
|
95
|
+
code = "unknown_error"
|
|
96
|
+
message = response.text
|
|
97
|
+
details: dict[str, Any] = {}
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
body = response.json()
|
|
101
|
+
if isinstance(body, dict):
|
|
102
|
+
error = body.get("error", {})
|
|
103
|
+
if isinstance(error, dict):
|
|
104
|
+
code = error.get("code", code)
|
|
105
|
+
message = error.get("message", message)
|
|
106
|
+
# Preserve everything except the parsed `error` block so consumers can read
|
|
107
|
+
# verify_url, linked_wallets, reasons, etc. for granular denial recovery.
|
|
108
|
+
details = {k: v for k, v in body.items() if k != "error"}
|
|
109
|
+
except ValueError:
|
|
110
|
+
# Body wasn't JSON or didn't have the expected shape — keep defaults.
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
if response.status_code == 402:
|
|
114
|
+
return PaymentRequiredError(message, details)
|
|
115
|
+
if response.status_code == 401:
|
|
116
|
+
if code == "token_expired":
|
|
117
|
+
return TokenExpiredError(message, details)
|
|
118
|
+
if code == "invalid_credential":
|
|
119
|
+
return InvalidCredentialError(message, details)
|
|
120
|
+
if response.status_code == 429:
|
|
121
|
+
if code == "quota_exceeded":
|
|
122
|
+
return QuotaExceededError(message, details)
|
|
123
|
+
if code == "rate_limited":
|
|
124
|
+
return RateLimitedError(message, details)
|
|
125
|
+
return AgentScoreError(code, message, response.status_code, details)
|
|
126
|
+
|
|
127
|
+
|
|
27
128
|
if TYPE_CHECKING:
|
|
28
129
|
from collections.abc import Awaitable, Callable
|
|
29
130
|
|
|
@@ -36,6 +137,7 @@ if TYPE_CHECKING:
|
|
|
36
137
|
DecisionPolicy,
|
|
37
138
|
Network,
|
|
38
139
|
ReputationResponse,
|
|
140
|
+
ResolveSigner,
|
|
39
141
|
SessionCreateResponse,
|
|
40
142
|
SessionPollResponse,
|
|
41
143
|
)
|
|
@@ -88,48 +190,45 @@ class AgentScore:
|
|
|
88
190
|
|
|
89
191
|
def _send_sync(self, send_fn: Callable[[], httpx.Response]) -> Any:
|
|
90
192
|
"""Issue a request, retry once on 429 honoring retry-after, then parse."""
|
|
91
|
-
response = send_fn
|
|
193
|
+
response = _do_sync(send_fn)
|
|
92
194
|
if response.status_code == 429:
|
|
93
195
|
time.sleep(_retry_after_seconds(response))
|
|
94
|
-
response = send_fn
|
|
196
|
+
response = _do_sync(send_fn)
|
|
95
197
|
return self._handle_response(response)
|
|
96
198
|
|
|
199
|
+
def _send_sync_with_response(self, send_fn: Callable[[], httpx.Response]) -> tuple[Any, httpx.Response]:
|
|
200
|
+
"""Issue a request, retry once on 429, then return ``(parsed_body, response)``.
|
|
201
|
+
|
|
202
|
+
Variant of :meth:`_send_sync` that exposes the raw ``httpx.Response`` so callers
|
|
203
|
+
(e.g. :meth:`assess`) can read response headers like ``X-Quota-*``.
|
|
204
|
+
"""
|
|
205
|
+
response = _do_sync(send_fn)
|
|
206
|
+
if response.status_code == 429:
|
|
207
|
+
time.sleep(_retry_after_seconds(response))
|
|
208
|
+
response = _do_sync(send_fn)
|
|
209
|
+
return self._handle_response(response), response
|
|
210
|
+
|
|
97
211
|
async def _send_async(self, send_fn: Callable[[], Awaitable[httpx.Response]]) -> Any:
|
|
98
212
|
"""Async variant of :meth:`_send_sync`."""
|
|
99
|
-
response = await send_fn
|
|
213
|
+
response = await _do_async(send_fn)
|
|
100
214
|
if response.status_code == 429:
|
|
101
215
|
await asyncio.sleep(_retry_after_seconds(response))
|
|
102
|
-
response = await send_fn
|
|
216
|
+
response = await _do_async(send_fn)
|
|
103
217
|
return self._handle_response(response)
|
|
104
218
|
|
|
105
|
-
def
|
|
219
|
+
async def _send_async_with_response(
|
|
220
|
+
self, send_fn: Callable[[], Awaitable[httpx.Response]]
|
|
221
|
+
) -> tuple[Any, httpx.Response]:
|
|
222
|
+
"""Async variant of :meth:`_send_sync_with_response`."""
|
|
223
|
+
response = await _do_async(send_fn)
|
|
106
224
|
if response.status_code == 429:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
225
|
+
await asyncio.sleep(_retry_after_seconds(response))
|
|
226
|
+
response = await _do_async(send_fn)
|
|
227
|
+
return self._handle_response(response), response
|
|
228
|
+
|
|
229
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
113
230
|
if response.status_code >= 400:
|
|
114
|
-
|
|
115
|
-
body = response.json()
|
|
116
|
-
error = body.get("error", {}) if isinstance(body, dict) else {}
|
|
117
|
-
# Preserve everything except the parsed `error` block so consumers
|
|
118
|
-
# can read verify_url, linked_wallets, reasons, etc. for granular
|
|
119
|
-
# denial recovery — exposed via AgentScoreError.details.
|
|
120
|
-
details = {k: v for k, v in body.items() if k != "error"} if isinstance(body, dict) else {}
|
|
121
|
-
raise AgentScoreError(
|
|
122
|
-
code=error.get("code", "unknown_error"),
|
|
123
|
-
message=error.get("message", response.text),
|
|
124
|
-
status_code=response.status_code,
|
|
125
|
-
details=details,
|
|
126
|
-
)
|
|
127
|
-
except ValueError as err:
|
|
128
|
-
raise AgentScoreError(
|
|
129
|
-
code="unknown_error",
|
|
130
|
-
message=response.text,
|
|
131
|
-
status_code=response.status_code,
|
|
132
|
-
) from err
|
|
231
|
+
raise _build_error_from_response(response)
|
|
133
232
|
try:
|
|
134
233
|
return response.json()
|
|
135
234
|
except ValueError as err:
|
|
@@ -156,8 +255,14 @@ class AgentScore:
|
|
|
156
255
|
refresh: bool | None = None,
|
|
157
256
|
policy: DecisionPolicy | None = None,
|
|
158
257
|
operator_token: str | None = None,
|
|
258
|
+
resolve_signer: ResolveSigner | None = None,
|
|
159
259
|
) -> AssessResponse:
|
|
160
|
-
"""Assess a wallet or operator (paid, writes score on-the-fly).
|
|
260
|
+
"""Assess a wallet or operator (paid, writes score on-the-fly).
|
|
261
|
+
|
|
262
|
+
``resolve_signer`` opts into server-side wallet-signer-match: when supplied,
|
|
263
|
+
the API resolves the signer wallet against the claimed ``address`` and emits
|
|
264
|
+
a ``signer_match`` block on the response. See :class:`ResolveSigner`.
|
|
265
|
+
"""
|
|
161
266
|
body: dict[str, Any] = {}
|
|
162
267
|
if address:
|
|
163
268
|
body["address"] = address
|
|
@@ -169,8 +274,14 @@ class AgentScore:
|
|
|
169
274
|
body["refresh"] = refresh
|
|
170
275
|
if policy is not None:
|
|
171
276
|
body["policy"] = dict(policy)
|
|
277
|
+
if resolve_signer is not None:
|
|
278
|
+
body["resolve_signer"] = dict(resolve_signer)
|
|
172
279
|
client = self._get_sync_client()
|
|
173
|
-
|
|
280
|
+
data, response = self._send_sync_with_response(lambda: client.post("/v1/assess", json=body))
|
|
281
|
+
quota = _extract_quota(response)
|
|
282
|
+
if quota is not None:
|
|
283
|
+
data["quota"] = quota
|
|
284
|
+
return data
|
|
174
285
|
|
|
175
286
|
def create_session(
|
|
176
287
|
self,
|
|
@@ -282,8 +393,13 @@ class AgentScore:
|
|
|
282
393
|
refresh: bool | None = None,
|
|
283
394
|
policy: DecisionPolicy | None = None,
|
|
284
395
|
operator_token: str | None = None,
|
|
396
|
+
resolve_signer: ResolveSigner | None = None,
|
|
285
397
|
) -> AssessResponse:
|
|
286
|
-
"""Assess a wallet or operator (paid, writes score on-the-fly).
|
|
398
|
+
"""Assess a wallet or operator (paid, writes score on-the-fly).
|
|
399
|
+
|
|
400
|
+
``resolve_signer`` opts into server-side wallet-signer-match — async mirror of
|
|
401
|
+
:meth:`assess`.
|
|
402
|
+
"""
|
|
287
403
|
body: dict[str, Any] = {}
|
|
288
404
|
if address:
|
|
289
405
|
body["address"] = address
|
|
@@ -295,8 +411,14 @@ class AgentScore:
|
|
|
295
411
|
body["refresh"] = refresh
|
|
296
412
|
if policy is not None:
|
|
297
413
|
body["policy"] = dict(policy)
|
|
414
|
+
if resolve_signer is not None:
|
|
415
|
+
body["resolve_signer"] = dict(resolve_signer)
|
|
298
416
|
client = self._get_async_client()
|
|
299
|
-
|
|
417
|
+
data, response = await self._send_async_with_response(lambda: client.post("/v1/assess", json=body))
|
|
418
|
+
quota = _extract_quota(response)
|
|
419
|
+
if quota is not None:
|
|
420
|
+
data["quota"] = quota
|
|
421
|
+
return data
|
|
300
422
|
|
|
301
423
|
async def acreate_session(
|
|
302
424
|
self,
|
|
@@ -382,6 +504,27 @@ class AgentScore:
|
|
|
382
504
|
client = self._get_async_client()
|
|
383
505
|
return await self._send_async(lambda: client.post("/v1/credentials/wallets", json=body))
|
|
384
506
|
|
|
507
|
+
def telemetry_signer_match(self, payload: dict[str, Any]) -> None:
|
|
508
|
+
"""Fire-and-forget telemetry — report a wallet-signer-match verdict.
|
|
509
|
+
|
|
510
|
+
Used internally by the commerce gate's ``verify_wallet_signer_match`` helper to track
|
|
511
|
+
aggregate signer-binding behavior across merchants. Does not raise; failures are
|
|
512
|
+
logged at warning level so persistent telemetry outages are visible in ops logs.
|
|
513
|
+
"""
|
|
514
|
+
try:
|
|
515
|
+
client = self._get_sync_client()
|
|
516
|
+
client.post("/v1/telemetry/signer-match", json=payload)
|
|
517
|
+
except Exception as err:
|
|
518
|
+
logger.warning("telemetry_signer_match failed: %s", err)
|
|
519
|
+
|
|
520
|
+
async def atelemetry_signer_match(self, payload: dict[str, Any]) -> None:
|
|
521
|
+
"""Async variant of :meth:`telemetry_signer_match`."""
|
|
522
|
+
try:
|
|
523
|
+
client = self._get_async_client()
|
|
524
|
+
await client.post("/v1/telemetry/signer-match", json=payload)
|
|
525
|
+
except Exception as err:
|
|
526
|
+
logger.warning("atelemetry_signer_match failed: %s", err)
|
|
527
|
+
|
|
385
528
|
def close(self):
|
|
386
529
|
if self._sync_client:
|
|
387
530
|
self._sync_client.close()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgentScoreError(Exception):
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
code: str,
|
|
8
|
+
message: str,
|
|
9
|
+
status_code: int,
|
|
10
|
+
details: dict[str, Any] | None = None,
|
|
11
|
+
):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.code = code
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
# Response-body fields beyond `error.{code,message}` — e.g. verify_url,
|
|
16
|
+
# linked_wallets, claimed_operator, actual_signer, reasons. Consumers
|
|
17
|
+
# branch on these for granular recovery (see the mcp denial-code rendering
|
|
18
|
+
# in the node sibling for the canonical use). Defaults to {} so callers
|
|
19
|
+
# constructing this error by hand without a body can omit it.
|
|
20
|
+
self.details: dict[str, Any] = details or {}
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def status(self) -> int:
|
|
24
|
+
"""Alias for ``status_code`` — parity with node-sdk's attribute name.
|
|
25
|
+
|
|
26
|
+
Polyglot codebases can use ``err.status`` regardless of which SDK raised the error.
|
|
27
|
+
"""
|
|
28
|
+
return self.status_code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PaymentRequiredError(AgentScoreError):
|
|
32
|
+
"""HTTP 402 — the endpoint is not enabled for this account."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
35
|
+
super().__init__("payment_required", message, 402, details)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TokenExpiredError(AgentScoreError):
|
|
39
|
+
"""HTTP 401 with ``error.code = 'token_expired'`` — credential is no longer valid.
|
|
40
|
+
|
|
41
|
+
Covers both revoked and TTL-expired credentials; the API deliberately doesn't disclose
|
|
42
|
+
which. Body carries an auto-minted verification session — exposed here so callers recover
|
|
43
|
+
without re-parsing ``details``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
47
|
+
super().__init__("token_expired", message, 401, details)
|
|
48
|
+
d = details or {}
|
|
49
|
+
self.verify_url: str | None = d.get("verify_url") if isinstance(d.get("verify_url"), str) else None
|
|
50
|
+
self.session_id: str | None = d.get("session_id") if isinstance(d.get("session_id"), str) else None
|
|
51
|
+
self.poll_secret: str | None = d.get("poll_secret") if isinstance(d.get("poll_secret"), str) else None
|
|
52
|
+
self.poll_url: str | None = d.get("poll_url") if isinstance(d.get("poll_url"), str) else None
|
|
53
|
+
self.next_steps: Any = d.get("next_steps")
|
|
54
|
+
self.agent_memory: Any = d.get("agent_memory")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class InvalidCredentialError(AgentScoreError):
|
|
58
|
+
"""HTTP 401 with ``error.code = 'invalid_credential'`` — operator_token doesn't exist.
|
|
59
|
+
|
|
60
|
+
Permanent: no auto-session is issued. Caller should switch tokens or restart.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
64
|
+
super().__init__("invalid_credential", message, 401, details)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class QuotaExceededError(AgentScoreError):
|
|
68
|
+
"""HTTP 429 with ``error.code = 'quota_exceeded'`` — account-level cap reached.
|
|
69
|
+
|
|
70
|
+
Don't retry; the cap won't lift through retry alone. Distinct from per-second
|
|
71
|
+
:class:`RateLimitedError`.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
75
|
+
super().__init__("quota_exceeded", message, 429, details)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RateLimitedError(AgentScoreError):
|
|
79
|
+
"""HTTP 429 with ``error.code = 'rate_limited'`` — per-second sliding-window cap hit.
|
|
80
|
+
|
|
81
|
+
Retry after the interval indicated by the ``Retry-After`` header (typically <= 1s).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
85
|
+
super().__init__("rate_limited", message, 429, details)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TimeoutError(AgentScoreError):
|
|
89
|
+
"""Request timed out at the network layer (``httpx.TimeoutException``).
|
|
90
|
+
|
|
91
|
+
Distinct from generic network errors so callers branch on retry vs surface-to-user
|
|
92
|
+
without parsing message strings. Subclasses :class:`AgentScoreError` (not the builtin
|
|
93
|
+
``TimeoutError``) so existing ``except AgentScoreError`` blocks still catch it.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, message: str) -> None:
|
|
97
|
+
super().__init__("timeout", message, 0)
|