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.
Files changed (34) hide show
  1. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.claude/CLAUDE.md +8 -1
  2. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/PKG-INFO +48 -6
  3. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/README.md +47 -5
  4. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/__init__.py +21 -1
  5. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/client.py +178 -35
  6. agentscore_py-2.1.0/agentscore/errors.py +97 -0
  7. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/types.py +75 -2
  8. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/pyproject.toml +1 -1
  9. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_client.py +490 -4
  10. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/uv.lock +19 -19
  11. agentscore_py-2.0.2/agentscore/errors.py +0 -28
  12. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  13. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  14. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/dependabot.yml +0 -0
  16. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/workflows/ci.yml +0 -0
  17. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/workflows/publish.yml +0 -0
  18. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.github/workflows/security.yml +0 -0
  19. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/.gitignore +0 -0
  20. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/CODE_OF_CONDUCT.md +0 -0
  21. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/CONTRIBUTING.md +0 -0
  22. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/LICENSE +0 -0
  23. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/SECURITY.md +0 -0
  24. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/py.typed +0 -0
  25. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/agentscore/test_mode.py +0 -0
  26. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/lefthook.yml +0 -0
  27. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/ruff.toml +0 -0
  28. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/__init__.py +0 -0
  29. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/conftest.py +0 -0
  30. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_errors.py +0 -0
  31. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_integration.py +0 -0
  32. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_test_mode.py +0 -0
  33. {agentscore_py-2.0.2 → agentscore_py-2.1.0}/tests/test_types.py +0 -0
  34. {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.2
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
- if e.code == "wallet_signer_mismatch":
180
- print("Re-sign from one of:", e.details.get("linked_wallets"))
181
- elif e.code == "token_expired":
182
- print("Verify at:", e.details.get("verify_url"))
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
- if e.code == "wallet_signer_mismatch":
157
- print("Re-sign from one of:", e.details.get("linked_wallets"))
158
- elif e.code == "token_expired":
159
- print("Verify at:", e.details.get("verify_url"))
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 AgentScoreError
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 AgentScoreError
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 _handle_response(self, response: httpx.Response) -> Any:
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
- retry_after = response.headers.get("retry-after", "1")
108
- raise AgentScoreError(
109
- code="rate_limited",
110
- message=f"Rate limit exceeded. Retry after {retry_after}s",
111
- status_code=429,
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
- try:
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
- return self._send_sync(lambda: client.post("/v1/assess", json=body))
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
- return await self._send_async(lambda: client.post("/v1/assess", json=body))
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)