From 9dcd97738028a6ee9f514c163290cb3eaa5b7f57 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 13 Apr 2026 17:35:14 -0700 Subject: [PATCH 01/10] Add session, credential, and operator_token support (v1.5.0) - assess/aassess accept operator_token kwarg - create_session/poll_session for verification bootstrapping - create_credential/list_credentials/revoke_credential - DecisionPolicy: scoring removed, allowed_jurisdictions added - 429 handling in _handle_response - Version bumped to 1.5.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/CLAUDE.md | 12 ++ README.md | 49 ++++- agentscore/__init__.py | 10 + agentscore/client.py | 135 +++++++++++- agentscore/types.py | 42 +++- pyproject.toml | 2 +- ruff.toml | 3 +- tests/test_client.py | 473 ++++++++++++++++++++++++++++++++++++++++- uv.lock | 2 +- 9 files changed, 709 insertions(+), 19 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 826d0f7..12c9228 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,6 +2,18 @@ Python client for the AgentScore trust and reputation API. +## Identity Model + +## Methods (sync + async) + +- `get_reputation` / `aget_reputation` — cached reputation lookup (free) +- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. +- `create_session` / `acreate_session` — create verification session +- `poll_session` / `apoll_session` — poll session status, returns credential when verified +- `create_credential` / `acreate_credential` — create operator credential (24h TTL default) +- `list_credentials` / `alist_credentials` — list active credentials +- `revoke_credential` / `arevoke_credential` — revoke a credential + ## Architecture Single-package Python library published to PyPI. diff --git a/README.md b/README.md index 52b2ab3..4c58d9b 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,7 @@ print(rep["score"]["value"], rep["score"]["grade"]) # Filter to a specific chain base_rep = client.get_reputation("0x1234...", chain="base") -# On-the-fly assessment with policy (paid) -result = client.assess("0x1234...", policy={"min_grade": "B", "min_score": 35}) -print(result["decision"], result["decision_reasons"]) - -# Compliance assessment with verification policy +# Identity gate with policy (paid) gated = client.assess("0x1234...", policy={ "require_kyc": True, "require_sanctions_clear": True, @@ -45,12 +41,53 @@ rep = client.get_reputation("0x1234...") print(rep.get("verification_level")) # "none" | "wallet_claimed" | "kyc_verified" ``` +### Credential-Based Identity + +Agents without wallets can use operator credentials for identity: + +```python +result = client.assess(operator_token="opc_...") +print(result["decision"]) # "allow" | "deny" +``` + +### Verification Sessions + +Bootstrap identity for first-time agents: + +```python +session = client.create_session() +print(session["verify_url"], session["poll_secret"]) + +status = client.poll_session(session["session_id"], session["poll_secret"]) +if status["status"] == "verified": + print(status["operator_token"]) # "opc_..." — use for future requests +``` + +### Credential Management + +```python +cred = client.create_credential(label="my-agent", ttl_days=7) +print(cred["credential"]) # shown once + +credentials = client.list_credentials() +client.revoke_credential(cred["id"]) +``` + ### Async +All methods have async variants prefixed with `a`: + ```python async with AgentScore(api_key="as_live_...") as client: rep = await client.aget_reputation("0x1234...") - result = await client.aassess("0x1234...", policy={"min_grade": "B"}) + result = await client.aassess("0x1234...", policy={"require_kyc": True}) + + # Identity model methods + session = await client.acreate_session() + status = await client.apoll_session(session["session_id"], session["poll_secret"]) + cred = await client.acreate_credential(label="my-agent") + await client.alist_credentials() + await client.arevoke_credential(cred["id"]) ``` ### Context Manager diff --git a/agentscore/__init__.py b/agentscore/__init__.py index 9a788f3..9c53305 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -4,6 +4,9 @@ from agentscore.errors import AgentScoreError from agentscore.types import ( AssessResponse, + CredentialCreateResponse, + CredentialItem, + CredentialListResponse, DecisionPolicy, EntityType, Grade, @@ -11,6 +14,8 @@ Reputation, ReputationResponse, ReputationStatus, + SessionCreateResponse, + SessionPollResponse, VerificationLevel, ) @@ -20,6 +25,9 @@ "AgentScore", "AgentScoreError", "AssessResponse", + "CredentialCreateResponse", + "CredentialItem", + "CredentialListResponse", "DecisionPolicy", "EntityType", "Grade", @@ -27,6 +35,8 @@ "Reputation", "ReputationResponse", "ReputationStatus", + "SessionCreateResponse", + "SessionPollResponse", "VerificationLevel", "__version__", ] diff --git a/agentscore/client.py b/agentscore/client.py index a092e09..f83881d 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -10,8 +10,12 @@ if TYPE_CHECKING: from agentscore.types import ( AssessResponse, + CredentialCreateResponse, + CredentialListResponse, DecisionPolicy, ReputationResponse, + SessionCreateResponse, + SessionPollResponse, ) @@ -58,6 +62,13 @@ def _get_async_client(self) -> httpx.AsyncClient: return self._async_client def _handle_response(self, response: httpx.Response) -> dict: + if response.status_code == 429: + retry_after = response.headers.get("retry-after", "1") + raise AgentScoreError( + code="rate_limited", + message=f"Rate limit exceeded. Retry after {retry_after}s", + status_code=429, + ) if response.status_code >= 400: try: body = response.json() @@ -95,13 +106,18 @@ def get_reputation(self, address: str, chain: str | None = None) -> ReputationRe def assess( self, - address: str, + address: str | None = None, chain: str | None = None, refresh: bool = False, policy: DecisionPolicy | None = None, + operator_token: str | None = None, ) -> AssessResponse: - """Assess a wallet (paid, writes score on-the-fly).""" - body: dict[str, Any] = {"address": address} + """Assess a wallet or operator (paid, writes score on-the-fly).""" + body: dict[str, Any] = {} + if address: + body["address"] = address + if operator_token: + body["operator_token"] = operator_token if chain: body["chain"] = chain if refresh: @@ -112,6 +128,57 @@ def assess( response = client.post("/v1/assess", json=body) return self._handle_response(response) + def create_session( + self, + context: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + ) -> SessionCreateResponse: + """Create an assessment session for deferred scoring.""" + body: dict[str, Any] = {} + if context is not None: + body["context"] = context + if metadata is not None: + body["metadata"] = metadata + client = self._get_sync_client() + response = client.post("/v1/sessions", json=body) + return self._handle_response(response) + + def poll_session(self, session_id: str, poll_secret: str) -> SessionPollResponse: + """Poll a session for its current status and result.""" + client = self._get_sync_client() + response = client.get( + f"/v1/sessions/{session_id}", + headers={"X-Poll-Secret": poll_secret}, + ) + return self._handle_response(response) + + def create_credential( + self, + label: str | None = None, + ttl_days: int | None = None, + ) -> CredentialCreateResponse: + """Create a new API credential.""" + body: dict[str, Any] = {} + if label is not None: + body["label"] = label + if ttl_days is not None: + body["ttl_days"] = ttl_days + client = self._get_sync_client() + response = client.post("/v1/credentials", json=body) + return self._handle_response(response) + + def list_credentials(self) -> CredentialListResponse: + """List all API credentials.""" + client = self._get_sync_client() + response = client.get("/v1/credentials") + return self._handle_response(response) + + def revoke_credential(self, id: str) -> dict: + """Revoke an API credential by ID.""" + client = self._get_sync_client() + response = client.delete(f"/v1/credentials/{id}") + return self._handle_response(response) + # --- Async methods --- async def aget_reputation(self, address: str, chain: str | None = None) -> ReputationResponse: @@ -125,13 +192,18 @@ async def aget_reputation(self, address: str, chain: str | None = None) -> Reput async def aassess( self, - address: str, + address: str | None = None, chain: str | None = None, refresh: bool = False, policy: DecisionPolicy | None = None, + operator_token: str | None = None, ) -> AssessResponse: - """Assess a wallet (paid, writes score on-the-fly).""" - body: dict[str, Any] = {"address": address} + """Assess a wallet or operator (paid, writes score on-the-fly).""" + body: dict[str, Any] = {} + if address: + body["address"] = address + if operator_token: + body["operator_token"] = operator_token if chain: body["chain"] = chain if refresh: @@ -142,6 +214,57 @@ async def aassess( response = await client.post("/v1/assess", json=body) return self._handle_response(response) + async def acreate_session( + self, + context: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + ) -> SessionCreateResponse: + """Create an assessment session for deferred scoring.""" + body: dict[str, Any] = {} + if context is not None: + body["context"] = context + if metadata is not None: + body["metadata"] = metadata + client = self._get_async_client() + response = await client.post("/v1/sessions", json=body) + return self._handle_response(response) + + async def apoll_session(self, session_id: str, poll_secret: str) -> SessionPollResponse: + """Poll a session for its current status and result.""" + client = self._get_async_client() + response = await client.get( + f"/v1/sessions/{session_id}", + headers={"X-Poll-Secret": poll_secret}, + ) + return self._handle_response(response) + + async def acreate_credential( + self, + label: str | None = None, + ttl_days: int | None = None, + ) -> CredentialCreateResponse: + """Create a new API credential.""" + body: dict[str, Any] = {} + if label is not None: + body["label"] = label + if ttl_days is not None: + body["ttl_days"] = ttl_days + client = self._get_async_client() + response = await client.post("/v1/credentials", json=body) + return self._handle_response(response) + + async def alist_credentials(self) -> CredentialListResponse: + """List all API credentials.""" + client = self._get_async_client() + response = await client.get("/v1/credentials") + return self._handle_response(response) + + async def arevoke_credential(self, id: str) -> dict: + """Revoke an API credential by ID.""" + client = self._get_async_client() + response = await client.delete(f"/v1/credentials/{id}") + return self._handle_response(response) + def close(self): if self._sync_client: self._sync_client.close() diff --git a/agentscore/types.py b/agentscore/types.py index dea6acb..2b84a65 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -159,13 +159,11 @@ class PolicyResult(TypedDict): class DecisionPolicy(TypedDict, total=False): - min_grade: Grade - min_score: int - require_verified_payment_activity: bool require_kyc: bool require_sanctions_clear: bool min_age: int blocked_jurisdictions: list[str] + allowed_jurisdictions: list[str] require_entity_type: str @@ -189,3 +187,41 @@ class AssessResponse(_AssessResponseRequired, total=False): resolved_operator: str verify_url: str policy_result: PolicyResult | None + + +class SessionCreateResponse(TypedDict): + session_id: str + poll_secret: str + poll_url: str + + +class _SessionPollResponseRequired(TypedDict): + session_id: str + status: str + + +class SessionPollResponse(_SessionPollResponseRequired, total=False): + operator_token: str + completed_at: str + + +class CredentialItem(TypedDict): + id: str + label: str | None + prefix: str + created_at: str + expires_at: str | None + last_used_at: str | None + + +class CredentialCreateResponse(TypedDict): + id: str + label: str | None + credential: str + prefix: str + created_at: str + expires_at: str | None + + +class CredentialListResponse(TypedDict): + credentials: list[CredentialItem] diff --git a/pyproject.toml b/pyproject.toml index f6ead0a..c27205d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-py" -version = "1.4.2" +version = "1.5.0" description = "Python client for the AgentScore trust and reputation API" readme = "README.md" license = "MIT" diff --git a/ruff.toml b/ruff.toml index 91a817b..639df13 100644 --- a/ruff.toml +++ b/ruff.toml @@ -31,7 +31,8 @@ ignore = [ ] [lint.per-file-ignores] -"tests/**" = ["D", "S101"] +"tests/**" = ["D", "S101", "S105", "S106"] +"agentscore/**" = ["S105", "S106"] "vulture_whitelist.py" = ["B018"] [lint.pydocstyle] diff --git a/tests/test_client.py b/tests/test_client.py index 95f7b45..1dc83c8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -268,7 +268,7 @@ def test_error_missing_error_key_falls_back(): def test_assess_forwards_policy_in_body(): route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) client = AgentScore(api_key=API_KEY) - policy = {"min_grade": "B", "min_score": 50, "require_verified_payment_activity": True} + policy = {"require_kyc": True, "min_age": 21} client.assess(ADDRESS, policy=policy) body = json.loads(route.calls.last.request.content) assert body["policy"] == policy @@ -648,3 +648,474 @@ def test_full_compliance_deny_flow(): body = json.loads(route.calls.last.request.content) assert body["policy"]["require_kyc"] is True assert body["policy"]["require_sanctions_clear"] is True + + +# --------------------------------------------------------------------------- +# Identity model: operator_token in assess/aassess +# --------------------------------------------------------------------------- + + +@respx.mock +def test_assess_sends_operator_token(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.assess(operator_token="opc_test_123") + body = json.loads(route.calls.last.request.content) + assert body["operator_token"] == "opc_test_123" + assert "address" not in body + + +@respx.mock +def test_assess_sends_both_address_and_operator_token(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.assess(ADDRESS, operator_token="opc_both_456") + body = json.loads(route.calls.last.request.content) + assert body["address"] == ADDRESS + assert body["operator_token"] == "opc_both_456" + + +@respx.mock +def test_assess_address_only_backwards_compat(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.assess(ADDRESS) + body = json.loads(route.calls.last.request.content) + assert body["address"] == ADDRESS + assert "operator_token" not in body + + +@respx.mock +def test_assess_operator_token_with_policy(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.assess(operator_token="opc_policy", policy={"require_kyc": True}) + body = json.loads(route.calls.last.request.content) + assert body["operator_token"] == "opc_policy" + assert "address" not in body + assert body["policy"]["require_kyc"] is True + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_sends_operator_token(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + await client.aassess(operator_token="opc_async_test") + body = json.loads(route.calls.last.request.content) + assert body["operator_token"] == "opc_async_test" + assert "address" not in body + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_sends_both_address_and_operator_token(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + await client.aassess(ADDRESS, operator_token="opc_async_both") + body = json.loads(route.calls.last.request.content) + assert body["address"] == ADDRESS + assert body["operator_token"] == "opc_async_both" + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_address_only_backwards_compat(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + await client.aassess(ADDRESS) + body = json.loads(route.calls.last.request.content) + assert body["address"] == ADDRESS + assert "operator_token" not in body + await client.aclose() + + +# --------------------------------------------------------------------------- +# create_session +# --------------------------------------------------------------------------- + +SESSION_CREATE_PAYLOAD = { + "session_id": "ses_abc123", + "poll_secret": "ps_secret456", + "poll_url": "https://api.agentscore.sh/v1/sessions/ses_abc123", +} + + +@respx.mock +def test_create_session_success(): + respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + result = client.create_session() + assert result["session_id"] == "ses_abc123" + assert result["poll_secret"] == "ps_secret456" + assert result["poll_url"] == "https://api.agentscore.sh/v1/sessions/ses_abc123" + + +@respx.mock +def test_create_session_with_context_and_metadata(): + route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.create_session(context={"address": ADDRESS}, metadata={"source": "test"}) + body = json.loads(route.calls.last.request.content) + assert body["context"] == {"address": ADDRESS} + assert body["metadata"] == {"source": "test"} + + +@respx.mock +def test_create_session_omits_none_fields(): + route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.create_session() + body = json.loads(route.calls.last.request.content) + assert "context" not in body + assert "metadata" not in body + + +@respx.mock +def test_create_session_raises_on_error(): + respx.post(f"{BASE_URL}/v1/sessions").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_request", "message": "Invalid body"}}) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.create_session() + assert exc_info.value.status_code == 400 + assert exc_info.value.code == "bad_request" + + +# --------------------------------------------------------------------------- +# poll_session +# --------------------------------------------------------------------------- + +SESSION_POLL_PENDING_PAYLOAD = { + "session_id": "ses_abc123", + "status": "pending", +} + +SESSION_POLL_COMPLETE_PAYLOAD = { + "session_id": "ses_abc123", + "status": "complete", + "score": { + "value": 80, + "grade": "B", + "scored_at": "2024-01-01T00:00:00Z", + "status": "scored", + "version": "1", + }, + "decision": "allow", + "decision_reasons": [], + "subject": {"chains": ["base"], "address": ADDRESS}, +} + + +@respx.mock +def test_poll_session_pending(): + respx.get(f"{BASE_URL}/v1/sessions/ses_abc123").mock( + return_value=httpx.Response(200, json=SESSION_POLL_PENDING_PAYLOAD) + ) + client = AgentScore(api_key=API_KEY) + result = client.poll_session("ses_abc123", "ps_secret456") + assert result["session_id"] == "ses_abc123" + assert result["status"] == "pending" + + +@respx.mock +def test_poll_session_complete(): + respx.get(f"{BASE_URL}/v1/sessions/ses_abc123").mock( + return_value=httpx.Response(200, json=SESSION_POLL_COMPLETE_PAYLOAD) + ) + client = AgentScore(api_key=API_KEY) + result = client.poll_session("ses_abc123", "ps_secret456") + assert result["status"] == "complete" + assert result["score"]["grade"] == "B" + assert result["decision"] == "allow" + + +@respx.mock +def test_poll_session_sends_poll_secret_header(): + route = respx.get(f"{BASE_URL}/v1/sessions/ses_abc123").mock( + return_value=httpx.Response(200, json=SESSION_POLL_PENDING_PAYLOAD) + ) + client = AgentScore(api_key=API_KEY) + client.poll_session("ses_abc123", "ps_secret456") + assert route.calls.last.request.headers["x-poll-secret"] == "ps_secret456" + + +@respx.mock +def test_poll_session_raises_on_404(): + respx.get(f"{BASE_URL}/v1/sessions/ses_bad").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found", "message": "Session not found"}}) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.poll_session("ses_bad", "ps_secret456") + assert exc_info.value.status_code == 404 + assert exc_info.value.code == "not_found" + + +# --------------------------------------------------------------------------- +# create_credential +# --------------------------------------------------------------------------- + +CREDENTIAL_CREATE_PAYLOAD = { + "id": "cred_abc123", + "label": "My credential", + "token": "ak_full_secret_token", + "prefix": "ak_full", + "created_at": "2024-01-01T00:00:00Z", + "expires_at": "2024-04-01T00:00:00Z", +} + + +@respx.mock +def test_create_credential_success(): + respx.post(f"{BASE_URL}/v1/credentials").mock(return_value=httpx.Response(200, json=CREDENTIAL_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + result = client.create_credential(label="My credential", ttl_days=90) + assert result["id"] == "cred_abc123" + assert result["token"] == "ak_full_secret_token" + assert result["label"] == "My credential" + + +@respx.mock +def test_create_credential_sends_body(): + route = respx.post(f"{BASE_URL}/v1/credentials").mock( + return_value=httpx.Response(200, json=CREDENTIAL_CREATE_PAYLOAD) + ) + client = AgentScore(api_key=API_KEY) + client.create_credential(label="My credential", ttl_days=90) + body = json.loads(route.calls.last.request.content) + assert body["label"] == "My credential" + assert body["ttl_days"] == 90 + + +@respx.mock +def test_create_credential_omits_none_fields(): + route = respx.post(f"{BASE_URL}/v1/credentials").mock( + return_value=httpx.Response(200, json=CREDENTIAL_CREATE_PAYLOAD) + ) + client = AgentScore(api_key=API_KEY) + client.create_credential() + body = json.loads(route.calls.last.request.content) + assert "label" not in body + assert "ttl_days" not in body + + +@respx.mock +def test_create_credential_raises_on_error(): + respx.post(f"{BASE_URL}/v1/credentials").mock( + return_value=httpx.Response(403, json={"error": {"code": "forbidden", "message": "Not allowed"}}) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.create_credential() + assert exc_info.value.status_code == 403 + assert exc_info.value.code == "forbidden" + + +# --------------------------------------------------------------------------- +# list_credentials +# --------------------------------------------------------------------------- + +CREDENTIAL_LIST_PAYLOAD = { + "credentials": [ + { + "id": "cred_abc123", + "label": "My credential", + "prefix": "ak_full", + "created_at": "2024-01-01T00:00:00Z", + "expires_at": "2024-04-01T00:00:00Z", + "last_used_at": None, + }, + { + "id": "cred_def456", + "label": None, + "prefix": "ak_other", + "created_at": "2024-02-01T00:00:00Z", + "expires_at": None, + "last_used_at": "2024-03-01T00:00:00Z", + }, + ], +} + + +@respx.mock +def test_list_credentials_success(): + respx.get(f"{BASE_URL}/v1/credentials").mock(return_value=httpx.Response(200, json=CREDENTIAL_LIST_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + result = client.list_credentials() + assert len(result["credentials"]) == 2 + assert result["credentials"][0]["id"] == "cred_abc123" + assert result["credentials"][1]["id"] == "cred_def456" + + +@respx.mock +def test_list_credentials_empty(): + respx.get(f"{BASE_URL}/v1/credentials").mock(return_value=httpx.Response(200, json={"credentials": []})) + client = AgentScore(api_key=API_KEY) + result = client.list_credentials() + assert result["credentials"] == [] + + +@respx.mock +def test_list_credentials_raises_on_error(): + respx.get(f"{BASE_URL}/v1/credentials").mock( + return_value=httpx.Response(401, json={"error": {"code": "unauthorized", "message": "Invalid API key"}}) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.list_credentials() + assert exc_info.value.status_code == 401 + assert exc_info.value.code == "unauthorized" + + +# --------------------------------------------------------------------------- +# revoke_credential +# --------------------------------------------------------------------------- + +CREDENTIAL_REVOKE_PAYLOAD = {"ok": True} + + +@respx.mock +def test_revoke_credential_success(): + respx.delete(f"{BASE_URL}/v1/credentials/cred_abc123").mock( + return_value=httpx.Response(200, json=CREDENTIAL_REVOKE_PAYLOAD) + ) + client = AgentScore(api_key=API_KEY) + result = client.revoke_credential("cred_abc123") + assert result["ok"] is True + + +@respx.mock +def test_revoke_credential_raises_on_404(): + respx.delete(f"{BASE_URL}/v1/credentials/cred_bad").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found", "message": "Credential not found"}}) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.revoke_credential("cred_bad") + assert exc_info.value.status_code == 404 + assert exc_info.value.code == "not_found" + + +# --------------------------------------------------------------------------- +# Async: acreate_session +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_acreate_session_success(): + respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + result = await client.acreate_session() + assert result["session_id"] == "ses_abc123" + assert result["poll_secret"] == "ps_secret456" + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_acreate_session_with_context_and_metadata(): + route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + await client.acreate_session(context={"address": ADDRESS}, metadata={"source": "test"}) + body = json.loads(route.calls.last.request.content) + assert body["context"] == {"address": ADDRESS} + assert body["metadata"] == {"source": "test"} + await client.aclose() + + +# --------------------------------------------------------------------------- +# Async: apoll_session +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_apoll_session_success(): + respx.get(f"{BASE_URL}/v1/sessions/ses_abc123").mock( + return_value=httpx.Response(200, json=SESSION_POLL_COMPLETE_PAYLOAD) + ) + client = AgentScore(api_key=API_KEY) + result = await client.apoll_session("ses_abc123", "ps_secret456") + assert result["status"] == "complete" + assert result["score"]["grade"] == "B" + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_apoll_session_sends_poll_secret_header(): + route = respx.get(f"{BASE_URL}/v1/sessions/ses_abc123").mock( + return_value=httpx.Response(200, json=SESSION_POLL_PENDING_PAYLOAD) + ) + client = AgentScore(api_key=API_KEY) + await client.apoll_session("ses_abc123", "ps_secret456") + assert route.calls.last.request.headers["x-poll-secret"] == "ps_secret456" + await client.aclose() + + +# --------------------------------------------------------------------------- +# Async: acreate_credential +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_acreate_credential_success(): + respx.post(f"{BASE_URL}/v1/credentials").mock(return_value=httpx.Response(200, json=CREDENTIAL_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + result = await client.acreate_credential(label="My credential", ttl_days=90) + assert result["id"] == "cred_abc123" + assert result["token"] == "ak_full_secret_token" + await client.aclose() + + +# --------------------------------------------------------------------------- +# Async: alist_credentials +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_alist_credentials_success(): + respx.get(f"{BASE_URL}/v1/credentials").mock(return_value=httpx.Response(200, json=CREDENTIAL_LIST_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + result = await client.alist_credentials() + assert len(result["credentials"]) == 2 + assert result["credentials"][0]["id"] == "cred_abc123" + await client.aclose() + + +# --------------------------------------------------------------------------- +# Async: arevoke_credential +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_arevoke_credential_success(): + respx.delete(f"{BASE_URL}/v1/credentials/cred_abc123").mock( + return_value=httpx.Response(200, json=CREDENTIAL_REVOKE_PAYLOAD) + ) + client = AgentScore(api_key=API_KEY) + result = await client.arevoke_credential("cred_abc123") + assert result["ok"] is True + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_arevoke_credential_raises_on_404(): + respx.delete(f"{BASE_URL}/v1/credentials/cred_bad").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found", "message": "Credential not found"}}) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + await client.arevoke_credential("cred_bad") + assert exc_info.value.status_code == 404 + assert exc_info.value.code == "not_found" + await client.aclose() diff --git a/uv.lock b/uv.lock index 039763f..837f5f0 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agentscore-py" -version = "1.4.2" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From e56efdfd2e45a94efdd5ef3737d340a563345a1b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 13 Apr 2026 18:34:04 -0700 Subject: [PATCH 02/10] =?UTF-8?q?Clean=20assess=20response=20types=20?= =?UTF-8?q?=E2=80=94=20remove=20subject,=20score,=20chains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assess is a compliance gate. Scoring data comes from /v1/reputation. Co-Authored-By: Claude Opus 4.6 (1M context) --- agentscore/types.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/agentscore/types.py b/agentscore/types.py index 2b84a65..2571c53 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -8,9 +8,13 @@ VerificationLevel = Literal["none", "wallet_claimed", "kyc_verified"] -class Subject(TypedDict): +class _SubjectRequired(TypedDict): chains: list[str] + + +class Subject(_SubjectRequired, total=False): address: str + credential_prefix: str class Classification(TypedDict): @@ -142,7 +146,6 @@ class _OperatorVerificationRequired(TypedDict): class OperatorVerification(_OperatorVerificationRequired, total=False): operator_type: str | None - claimed_at: str | None verified_at: str | None @@ -168,23 +171,16 @@ class DecisionPolicy(TypedDict, total=False): class _AssessResponseRequired(TypedDict): - subject: Subject - score: Score - chains: list[ChainEntry] decision: str | None decision_reasons: list[str] + identity_method: str on_the_fly: bool - data_semantics: str - caveats: list[str] updated_at: str | None class AssessResponse(_AssessResponseRequired, total=False): - operator_score: OperatorScore | None - reputation: Reputation | None - agents: list[AgentSummary] operator_verification: OperatorVerification - resolved_operator: str + resolved_operator: str | None verify_url: str policy_result: PolicyResult | None @@ -192,7 +188,9 @@ class AssessResponse(_AssessResponseRequired, total=False): class SessionCreateResponse(TypedDict): session_id: str poll_secret: str + verify_url: str poll_url: str + expires_at: str class _SessionPollResponseRequired(TypedDict): From 51559b30930142f087bd3d591f3d6cc0206e6358 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 14 Apr 2026 04:12:22 -0700 Subject: [PATCH 03/10] chore: upgrade to useblacksmith/checkout@v1 Blacksmith checkout uses cached git mirrors for faster clones. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/security.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc9b01d..95a5af3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: ci: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v6 + - uses: useblacksmith/checkout@v1 - uses: astral-sh/setup-uv@v7 - run: uv sync --frozen --all-extras - run: uv run ruff check . diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5bebaad..58a1b30 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -19,7 +19,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 5 steps: - - uses: actions/checkout@v6 + - uses: useblacksmith/checkout@v1 - name: Install osv-scanner run: | @@ -34,7 +34,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 5 steps: - - uses: actions/checkout@v6 + - uses: useblacksmith/checkout@v1 - uses: astral-sh/setup-uv@v7 - uses: actions/setup-python@v6 with: From 44e4596be610c67f9d36fdb96e0735b5215c37be Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 14 Apr 2026 05:43:29 -0700 Subject: [PATCH 04/10] update session types, clean stale tests - Session creation uses first-class fields (return_url, payment_methods, product_name) - Remove metadata from session creation - Clean stale test references Co-Authored-By: Claude Opus 4.6 (1M context) --- agentscore/__init__.py | 4 ++++ agentscore/client.py | 29 +++++++++++++++++++++-------- agentscore/types.py | 8 ++++++++ tests/test_client.py | 34 +++++++++++++++++++++++++--------- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/agentscore/__init__.py b/agentscore/__init__.py index 9c53305..6f41fdd 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -11,9 +11,11 @@ EntityType, Grade, OperatorVerification, + PaymentMethod, Reputation, ReputationResponse, ReputationStatus, + SessionCreateRequest, SessionCreateResponse, SessionPollResponse, VerificationLevel, @@ -32,9 +34,11 @@ "EntityType", "Grade", "OperatorVerification", + "PaymentMethod", "Reputation", "ReputationResponse", "ReputationStatus", + "SessionCreateRequest", "SessionCreateResponse", "SessionPollResponse", "VerificationLevel", diff --git a/agentscore/client.py b/agentscore/client.py index f83881d..8b92c5c 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -13,6 +13,7 @@ CredentialCreateResponse, CredentialListResponse, DecisionPolicy, + PaymentMethod, ReputationResponse, SessionCreateResponse, SessionPollResponse, @@ -130,15 +131,21 @@ def assess( def create_session( self, - context: dict[str, Any] | None = None, - metadata: dict[str, Any] | None = None, + context: str | None = None, + return_url: str | None = None, + payment_methods: list[PaymentMethod] | None = None, + product_name: str | None = None, ) -> SessionCreateResponse: """Create an assessment session for deferred scoring.""" body: dict[str, Any] = {} if context is not None: body["context"] = context - if metadata is not None: - body["metadata"] = metadata + if return_url is not None: + body["return_url"] = return_url + if payment_methods is not None: + body["payment_methods"] = payment_methods + if product_name is not None: + body["product_name"] = product_name client = self._get_sync_client() response = client.post("/v1/sessions", json=body) return self._handle_response(response) @@ -216,15 +223,21 @@ async def aassess( async def acreate_session( self, - context: dict[str, Any] | None = None, - metadata: dict[str, Any] | None = None, + context: str | None = None, + return_url: str | None = None, + payment_methods: list[PaymentMethod] | None = None, + product_name: str | None = None, ) -> SessionCreateResponse: """Create an assessment session for deferred scoring.""" body: dict[str, Any] = {} if context is not None: body["context"] = context - if metadata is not None: - body["metadata"] = metadata + if return_url is not None: + body["return_url"] = return_url + if payment_methods is not None: + body["payment_methods"] = payment_methods + if product_name is not None: + body["product_name"] = product_name client = self._get_async_client() response = await client.post("/v1/sessions", json=body) return self._handle_response(response) diff --git a/agentscore/types.py b/agentscore/types.py index 2571c53..722b02b 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -2,6 +2,7 @@ from typing import Literal, TypedDict +PaymentMethod = Literal["tempo", "stripe"] Grade = Literal["A", "B", "C", "D", "F"] EntityType = Literal["agent", "service", "hybrid", "wallet", "bot", "unknown", "individual", "entity"] ReputationStatus = Literal["scored", "stale", "known_unscored"] @@ -185,6 +186,13 @@ class AssessResponse(_AssessResponseRequired, total=False): policy_result: PolicyResult | None +class SessionCreateRequest(TypedDict, total=False): + context: str + return_url: str + payment_methods: list[PaymentMethod] + product_name: str + + class SessionCreateResponse(TypedDict): session_id: str poll_secret: str diff --git a/tests/test_client.py b/tests/test_client.py index 1dc83c8..e6f930b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -754,13 +754,20 @@ def test_create_session_success(): @respx.mock -def test_create_session_with_context_and_metadata(): +def test_create_session_with_first_class_fields(): route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) client = AgentScore(api_key=API_KEY) - client.create_session(context={"address": ADDRESS}, metadata={"source": "test"}) + client.create_session( + context="wine purchase verification", + return_url="https://example.com/callback", + payment_methods=["tempo", "stripe"], + product_name="Cabernet Reserve 2022", + ) body = json.loads(route.calls.last.request.content) - assert body["context"] == {"address": ADDRESS} - assert body["metadata"] == {"source": "test"} + assert body["context"] == "wine purchase verification" + assert body["return_url"] == "https://example.com/callback" + assert body["payment_methods"] == ["tempo", "stripe"] + assert body["product_name"] == "Cabernet Reserve 2022" @respx.mock @@ -770,7 +777,9 @@ def test_create_session_omits_none_fields(): client.create_session() body = json.loads(route.calls.last.request.content) assert "context" not in body - assert "metadata" not in body + assert "return_url" not in body + assert "payment_methods" not in body + assert "product_name" not in body @respx.mock @@ -1018,13 +1027,20 @@ async def test_acreate_session_success(): @pytest.mark.asyncio @respx.mock -async def test_acreate_session_with_context_and_metadata(): +async def test_acreate_session_with_first_class_fields(): route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) client = AgentScore(api_key=API_KEY) - await client.acreate_session(context={"address": ADDRESS}, metadata={"source": "test"}) + await client.acreate_session( + context="wine purchase verification", + return_url="https://example.com/callback", + payment_methods=["tempo", "stripe"], + product_name="Cabernet Reserve 2022", + ) body = json.loads(route.calls.last.request.content) - assert body["context"] == {"address": ADDRESS} - assert body["metadata"] == {"source": "test"} + assert body["context"] == "wine purchase verification" + assert body["return_url"] == "https://example.com/callback" + assert body["payment_methods"] == ["tempo", "stripe"] + assert body["product_name"] == "Cabernet Reserve 2022" await client.aclose() From b705cd9a4b3e2e6370c1e1a1809da194fcc4292e Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 15 Apr 2026 15:34:42 -0700 Subject: [PATCH 05/10] Update dependencies Co-Authored-By: Claude Opus 4.6 (1M context) --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 837f5f0..ef1ee0d 100644 --- a/uv.lock +++ b/uv.lock @@ -440,11 +440,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] [[package]] From fece0c6fb76e686a011de48ebaba97720908288d Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 16 Apr 2026 12:06:51 -0700 Subject: [PATCH 06/10] Update deps (ruff 0.15.11) Co-Authored-By: Claude Opus 4.6 (1M context) --- uv.lock | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/uv.lock b/uv.lock index ef1ee0d..df1a47b 100644 --- a/uv.lock +++ b/uv.lock @@ -595,27 +595,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" +version = "0.15.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]] From 80a21e12b9ea2dce5df8bfb11db00a25b7f27576 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 16 Apr 2026 14:41:36 -0700 Subject: [PATCH 07/10] Drop return_url / payment_methods from create_session SDK Paired with core removing these from POST /v1/sessions. - Remove return_url and payment_methods params from create_session / acreate_session - Remove the now-unused PaymentMethod type - Drop corresponding tests Co-Authored-By: Claude Opus 4.6 (1M context) --- agentscore/__init__.py | 2 -- agentscore/client.py | 13 ------------- agentscore/types.py | 3 --- tests/test_client.py | 10 ---------- 4 files changed, 28 deletions(-) diff --git a/agentscore/__init__.py b/agentscore/__init__.py index 6f41fdd..0391370 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -11,7 +11,6 @@ EntityType, Grade, OperatorVerification, - PaymentMethod, Reputation, ReputationResponse, ReputationStatus, @@ -34,7 +33,6 @@ "EntityType", "Grade", "OperatorVerification", - "PaymentMethod", "Reputation", "ReputationResponse", "ReputationStatus", diff --git a/agentscore/client.py b/agentscore/client.py index 8b92c5c..a0688f7 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -13,7 +13,6 @@ CredentialCreateResponse, CredentialListResponse, DecisionPolicy, - PaymentMethod, ReputationResponse, SessionCreateResponse, SessionPollResponse, @@ -132,18 +131,12 @@ def assess( def create_session( self, context: str | None = None, - return_url: str | None = None, - payment_methods: list[PaymentMethod] | None = None, product_name: str | None = None, ) -> SessionCreateResponse: """Create an assessment session for deferred scoring.""" body: dict[str, Any] = {} if context is not None: body["context"] = context - if return_url is not None: - body["return_url"] = return_url - if payment_methods is not None: - body["payment_methods"] = payment_methods if product_name is not None: body["product_name"] = product_name client = self._get_sync_client() @@ -224,18 +217,12 @@ async def aassess( async def acreate_session( self, context: str | None = None, - return_url: str | None = None, - payment_methods: list[PaymentMethod] | None = None, product_name: str | None = None, ) -> SessionCreateResponse: """Create an assessment session for deferred scoring.""" body: dict[str, Any] = {} if context is not None: body["context"] = context - if return_url is not None: - body["return_url"] = return_url - if payment_methods is not None: - body["payment_methods"] = payment_methods if product_name is not None: body["product_name"] = product_name client = self._get_async_client() diff --git a/agentscore/types.py b/agentscore/types.py index 722b02b..02d74a1 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -2,7 +2,6 @@ from typing import Literal, TypedDict -PaymentMethod = Literal["tempo", "stripe"] Grade = Literal["A", "B", "C", "D", "F"] EntityType = Literal["agent", "service", "hybrid", "wallet", "bot", "unknown", "individual", "entity"] ReputationStatus = Literal["scored", "stale", "known_unscored"] @@ -188,8 +187,6 @@ class AssessResponse(_AssessResponseRequired, total=False): class SessionCreateRequest(TypedDict, total=False): context: str - return_url: str - payment_methods: list[PaymentMethod] product_name: str diff --git a/tests/test_client.py b/tests/test_client.py index e6f930b..b0c2e2f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -759,14 +759,10 @@ def test_create_session_with_first_class_fields(): client = AgentScore(api_key=API_KEY) client.create_session( context="wine purchase verification", - return_url="https://example.com/callback", - payment_methods=["tempo", "stripe"], product_name="Cabernet Reserve 2022", ) body = json.loads(route.calls.last.request.content) assert body["context"] == "wine purchase verification" - assert body["return_url"] == "https://example.com/callback" - assert body["payment_methods"] == ["tempo", "stripe"] assert body["product_name"] == "Cabernet Reserve 2022" @@ -777,8 +773,6 @@ def test_create_session_omits_none_fields(): client.create_session() body = json.loads(route.calls.last.request.content) assert "context" not in body - assert "return_url" not in body - assert "payment_methods" not in body assert "product_name" not in body @@ -1032,14 +1026,10 @@ async def test_acreate_session_with_first_class_fields(): client = AgentScore(api_key=API_KEY) await client.acreate_session( context="wine purchase verification", - return_url="https://example.com/callback", - payment_methods=["tempo", "stripe"], product_name="Cabernet Reserve 2022", ) body = json.loads(route.calls.last.request.content) assert body["context"] == "wine purchase verification" - assert body["return_url"] == "https://example.com/callback" - assert body["payment_methods"] == ["tempo", "stripe"] assert body["product_name"] == "Cabernet Reserve 2022" await client.aclose() From 544d0a0aa443bab405faf664838d8be32db3db8c Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 16 Apr 2026 20:47:26 -0700 Subject: [PATCH 08/10] Add types for new API response fields (explanation, next_steps, account_verification) NotRequired fields on AssessResponse (explanation), SessionPollResponse (next_steps, retry_after_seconds, token_ttl_seconds), and CredentialListResponse (account_verification). All additive. Co-Authored-By: Claude Opus 4.6 (1M context) --- agentscore/types.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/agentscore/types.py b/agentscore/types.py index 02d74a1..b4ee0bd 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Literal, TypedDict +from typing import Literal, NotRequired, TypedDict Grade = Literal["A", "B", "C", "D", "F"] EntityType = Literal["agent", "service", "hybrid", "wallet", "bot", "unknown", "individual", "entity"] @@ -183,6 +183,7 @@ class AssessResponse(_AssessResponseRequired, total=False): resolved_operator: str | None verify_url: str policy_result: PolicyResult | None + explanation: NotRequired[list[dict]] class SessionCreateRequest(TypedDict, total=False): @@ -206,6 +207,9 @@ class _SessionPollResponseRequired(TypedDict): class SessionPollResponse(_SessionPollResponseRequired, total=False): operator_token: str completed_at: str + next_steps: NotRequired[dict] + retry_after_seconds: NotRequired[int] + token_ttl_seconds: NotRequired[int] class CredentialItem(TypedDict): @@ -228,3 +232,4 @@ class CredentialCreateResponse(TypedDict): class CredentialListResponse(TypedDict): credentials: list[CredentialItem] + account_verification: NotRequired[dict] From 918e8355b2f09465ca869b25368310a0c62611b6 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 17 Apr 2026 04:33:12 -0700 Subject: [PATCH 09/10] Add PolicyExplanation TypedDict for assess explanation[] Replaces loose list[dict] with structured type covering rule, passed, required, actual, message, how_to_remedy. Co-Authored-By: Claude Opus 4.6 (1M context) --- agentscore/types.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/agentscore/types.py b/agentscore/types.py index b4ee0bd..0052ecc 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -178,12 +178,21 @@ class _AssessResponseRequired(TypedDict): updated_at: str | None +class PolicyExplanation(TypedDict, total=False): + rule: str + passed: bool + required: object + actual: object + message: str + how_to_remedy: str | None + + class AssessResponse(_AssessResponseRequired, total=False): operator_verification: OperatorVerification resolved_operator: str | None verify_url: str policy_result: PolicyResult | None - explanation: NotRequired[list[dict]] + explanation: NotRequired[list[PolicyExplanation]] class SessionCreateRequest(TypedDict, total=False): From 7b15f889e4963b6d0f35c2cf3104d8783826ad61 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 17 Apr 2026 18:38:13 -0700 Subject: [PATCH 10/10] Fix integration tests to match current assess API response shape Assess endpoint returns a flat decision response (no subject/chains/score). Use require_kyc policy instead of min_score for deny testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_integration.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index d1afd9a..d0d633b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -73,23 +73,24 @@ def test_get_reputation_metadata_fields(): assert "updated_at" in rep -def test_assess_operator_level(): +def test_assess_flat_decision_shape(): client = AgentScore(api_key=API_KEY, base_url=BASE_URL) result = client.assess(TEST_ADDRESS) assert "decision" in result assert isinstance(result["decision_reasons"], list) - assert isinstance(result["chains"], list) - assert isinstance(result["agents"], list) - assert "classification" not in result + assert result["identity_method"] == "wallet" + assert "operator_verification" in result def test_assess_policy_deny(): client = AgentScore(api_key=API_KEY, base_url=BASE_URL) - result = client.assess(TEST_ADDRESS, policy={"min_score": 999}) + result = client.assess(TEST_ADDRESS, policy={"require_kyc": True}) assert result["decision"] == "deny" - assert len(result["decision_reasons"]) > 0 + assert "kyc_required" in result["decision_reasons"] + assert "verify_url" in result + assert "/verify" in result["verify_url"] def test_get_reputation_operator_score(): @@ -107,7 +108,7 @@ def test_get_reputation_operator_score(): def test_assess_then_get_reputation(): client = AgentScore(api_key=API_KEY, base_url=BASE_URL) assessed = client.assess(TEST_ADDRESS) - assert "value" in assessed["score"] + assert "decision" in assessed rep = client.get_reputation(TEST_ADDRESS) assert "value" in rep["score"]