solvapay 0.1.0.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ """One agent's paid call, start to finish.
2
+
3
+ SolvaPay decides everything: ``authorize`` before the upstream call finds the
4
+ customer, checks the spend policy, holds the estimate against the budget and
5
+ the balance, and waits for the owner when they must approve; ``complete``
6
+ after it settles at the cost the upstream reported. This module only drives
7
+ those two requests and turns their answers into ``Allowed`` or ``Refused``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import hashlib
14
+ import logging
15
+ import math
16
+ import time
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from decimal import ROUND_HALF_UP, Decimal, localcontext
20
+ from typing import Any, Literal
21
+
22
+ from ..client import AsyncApiClient
23
+ from ..errors import SolvaPayError
24
+ from .identity import VerifiedAgent, agent_refusal
25
+
26
+ logger = logging.getLogger("solvapay.agents")
27
+
28
+ AUTHORIZE_WAIT_MAX_SECONDS = 55
29
+ PROMPT_EXCERPT_MAX = 1500
30
+
31
+ RefusalReason = Literal[
32
+ "customer_not_linked",
33
+ "ask",
34
+ "deny",
35
+ "topup_required",
36
+ "approval_declined",
37
+ "approval_expired",
38
+ "agent_not_accepted",
39
+ "approval_timed_out",
40
+ "approval_client_closed",
41
+ "unavailable",
42
+ ]
43
+
44
+ # Refusals money or the owner can settle: HTTP 402.
45
+ _OWNER_OR_MONEY_REASONS = frozenset(
46
+ {
47
+ "customer_not_linked",
48
+ "topup_required",
49
+ "ask",
50
+ "approval_declined",
51
+ "approval_expired",
52
+ "approval_timed_out",
53
+ "approval_client_closed",
54
+ }
55
+ )
56
+
57
+ NOT_ACCEPTED = "SolvaPay did not accept this agent (revoked or not for this merchant)."
58
+ UNAVAILABLE = "SolvaPay could not be reached to authorize this call. Try again shortly."
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Money:
63
+ amount: str
64
+ """A decimal amount with up to 8 places."""
65
+ currency: str
66
+ """ISO 4217, such as USD."""
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class PromptFacts:
71
+ prompt_hash: str
72
+ tool_error: bool
73
+ excerpt: str
74
+
75
+
76
+ def prompt_facts(text: str, tool_error: bool = False) -> PromptFacts:
77
+ """A hash of the prompt (repeats show loops), and its end, sent only if the owner opted in.
78
+
79
+ The end is the last ``PROMPT_EXCERPT_MAX`` UTF-16 code units, as TypeScript's
80
+ ``slice`` counts and SolvaPay measures them: an emoji counts twice."""
81
+ units = text.encode("utf-16-le")
82
+ if len(units) <= 2 * PROMPT_EXCERPT_MAX:
83
+ excerpt = text
84
+ else:
85
+ end = units[-2 * PROMPT_EXCERPT_MAX :]
86
+ # The cut went through a character outside the BMP: drop its lone low half.
87
+ if 0xDC00 <= int.from_bytes(end[:2], "little") <= 0xDFFF:
88
+ end = end[2:]
89
+ excerpt = end.decode("utf-16-le")
90
+ return PromptFacts(
91
+ prompt_hash=f"sha256:{hashlib.sha256(text.encode('utf-8')).hexdigest()}",
92
+ tool_error=tool_error,
93
+ excerpt=excerpt,
94
+ )
95
+
96
+
97
+ def amount_string(amount: float | Decimal | str) -> str:
98
+ """An amount as a decimal string with up to 8 places, trailing zeros trimmed.
99
+
100
+ A float is rounded from its exact binary value, as TypeScript's ``toFixed`` does:
101
+ ``1.5e-8`` is just under the half, so it is ``0.00000001``."""
102
+ exact = Decimal(amount) if isinstance(amount, float) else Decimal(str(amount))
103
+ # Enough precision for every integer digit plus the 8 places (the default 28
104
+ # fails from 1e20 up).
105
+ with localcontext() as context:
106
+ context.prec = max(28, exact.adjusted() + 10)
107
+ fixed = exact.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
108
+ text = f"{fixed:f}".rstrip("0").rstrip(".")
109
+ return text or "0"
110
+
111
+
112
+ @dataclass
113
+ class AgentCall:
114
+ request_id: str
115
+ """The merchant's id for the call; SolvaPay keys the call on it."""
116
+ agent_token: str
117
+ """The token as presented: SolvaPay verifies it again."""
118
+ agent: VerifiedAgent
119
+ item: str
120
+ """What is used: a model id, a tool name."""
121
+ estimate: Money
122
+ prompt: PromptFacts | None = None
123
+ cancelled: Callable[[], bool] = lambda: False
124
+ """True once the caller has gone away, to stop waiting for the owner."""
125
+ log_fields: dict[str, Any] = field(default_factory=dict)
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class Refused:
130
+ reason: RefusalReason
131
+ message: str
132
+ """The sentence for the human, in every case."""
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class CallResult:
137
+ status: int
138
+ """The status the upstream answered with."""
139
+ cost: tuple[float | Decimal | str, str] | None
140
+ """The cost the upstream reported, (amount, currency), or None when none arrived."""
141
+ interrupted: str | None = None
142
+
143
+
144
+ class Allowed:
145
+ """The call may go ahead. Finish it once, when the upstream has answered."""
146
+
147
+ def __init__(self, api: AsyncApiClient, call: AgentCall, answer: Any) -> None:
148
+ self._api = api
149
+ self._call = call
150
+ self.call_ref: str = answer.call_ref
151
+ self.reservation = answer.reservation
152
+ self.item = call.item
153
+ self.estimate = call.estimate
154
+ self._completed = False
155
+
156
+ async def finish(self, result: CallResult) -> Any:
157
+ """Settles the call at how the upstream answered. ``None`` when SolvaPay could not
158
+ be told: logged, and the reservation lapses at the estimate."""
159
+ body: dict[str, Any] = {
160
+ "status": result.status,
161
+ "cost": None
162
+ if result.cost is None
163
+ else {"amount": amount_string(result.cost[0]), "currency": result.cost[1]},
164
+ }
165
+ if result.interrupted:
166
+ body["interrupted"] = result.interrupted
167
+ return await self._complete(body)
168
+
169
+ async def release(self, reason: str) -> None:
170
+ """The call never reached the upstream: completed as a failure, so nothing is charged."""
171
+ await self._complete({"status": 502, "cost": None, "interrupted": reason})
172
+
173
+ async def _complete(self, body: dict[str, Any]) -> Any:
174
+ if self._completed:
175
+ raise SolvaPayError(
176
+ f"Call {self.call_ref} was already completed", code="call_completed"
177
+ )
178
+ self._completed = True
179
+ try:
180
+ answer = await self._api.complete_agent_call(call_ref=self.call_ref, body=body)
181
+ # The call's books are settled on it: every field must be there.
182
+ _require_text(answer.call_ref, "callRef")
183
+ _require_text(answer.amount.amount, "amount.amount")
184
+ _require_text(answer.amount.currency, "amount.currency")
185
+ return answer
186
+ except SolvaPayError as error:
187
+ logger.error(
188
+ "call.complete_failed %s",
189
+ {**_fields(self._call), "callRef": self.call_ref, "status": error.status,
190
+ "error": error.message},
191
+ ) # fmt: skip
192
+ return None
193
+
194
+
195
+ def _require_text(value: str | None, label: str) -> None:
196
+ """A field TypeScript requires as a non-empty string; the generated model allows less."""
197
+ if not value:
198
+ raise SolvaPayError(f"{label} must be a non-empty string", code="invalid_response")
199
+
200
+
201
+ def _check_authorize(answer: Any) -> None:
202
+ """Checked field by field, as TypeScript does: a paid call goes ahead on it."""
203
+ _require_text(answer.call_ref, "callRef")
204
+ if answer.status == "allowed":
205
+ _require_text(answer.reservation.expires_at, "reservation.expiresAt")
206
+ return
207
+ _require_text(answer.message, "message")
208
+ if answer.status == "waiting":
209
+ _require_text(answer.approval.reference, "approval.reference")
210
+ _require_text(answer.approval.expires_at, "approval.expiresAt")
211
+
212
+
213
+ def _fields(call: AgentCall) -> dict[str, Any]:
214
+ return {
215
+ "requestId": call.request_id,
216
+ **call.log_fields,
217
+ "agentRef": call.agent.agent_ref,
218
+ "userRef": call.agent.principal_ref,
219
+ "item": call.item,
220
+ "estimate": f"{call.estimate.amount} {call.estimate.currency}",
221
+ }
222
+
223
+
224
+ class AgentCalls:
225
+ """Authorizes agent calls with SolvaPay, waiting up to ``max_wait_seconds`` for the owner."""
226
+
227
+ def __init__(
228
+ self,
229
+ api: AsyncApiClient,
230
+ *,
231
+ max_wait_seconds: int,
232
+ clock: Callable[[], float] = time.monotonic,
233
+ ) -> None:
234
+ self.api = api
235
+ self.max_wait_seconds = max_wait_seconds
236
+ self._clock = clock
237
+
238
+ def _refuse(
239
+ self, call: AgentCall, reason: RefusalReason, message: str, **fields: Any
240
+ ) -> Refused:
241
+ logger.info("call.refused %s", {**_fields(call), "reason": reason, **fields})
242
+ return Refused(reason, message)
243
+
244
+ async def begin(self, call: AgentCall) -> Allowed | Refused:
245
+ deadline = self._clock() + self.max_wait_seconds
246
+ while True:
247
+ wait = max(0, min(AUTHORIZE_WAIT_MAX_SECONDS, math.floor(deadline - self._clock())))
248
+ body: dict[str, Any] = {
249
+ "agentToken": call.agent_token,
250
+ "requestId": call.request_id,
251
+ "item": call.item,
252
+ "estimate": {"amount": call.estimate.amount, "currency": call.estimate.currency},
253
+ "wait": wait,
254
+ }
255
+ if call.prompt:
256
+ body["promptHash"] = call.prompt.prompt_hash
257
+ body["toolError"] = call.prompt.tool_error
258
+ # Only when the token SolvaPay minted says the owner opted in.
259
+ if call.agent.prompt_excerpt:
260
+ body["promptExcerpt"] = call.prompt.excerpt
261
+ try:
262
+ answer = (await self.api.authorize_agent_call(body=body)).root
263
+ _check_authorize(answer)
264
+ except SolvaPayError as error:
265
+ logger.error(
266
+ "call.authorize_failed %s",
267
+ {**_fields(call), "status": error.status, "error": error.message},
268
+ )
269
+ # A refusal of the merchant (agent payments off, a secret key for the
270
+ # other environment) is not the agent's: unavailable, logged above.
271
+ if agent_refusal(error):
272
+ return self._refuse(call, "agent_not_accepted", NOT_ACCEPTED)
273
+ return self._refuse(call, "unavailable", UNAVAILABLE)
274
+
275
+ if answer.status == "allowed":
276
+ logger.info("call.allowed %s", {**_fields(call), "callRef": answer.call_ref})
277
+ return Allowed(self.api, call, answer)
278
+ if answer.status == "refused":
279
+ return self._refuse(call, answer.reason, answer.message, callRef=answer.call_ref)
280
+ held = {"callRef": answer.call_ref, "approvalRef": answer.approval.reference}
281
+ if self.max_wait_seconds == 0:
282
+ return self._refuse(call, "ask", answer.message, **held)
283
+ if call.cancelled():
284
+ return self._refuse(call, "approval_client_closed", answer.message, **held)
285
+ if self._clock() >= deadline:
286
+ return self._refuse(call, "approval_timed_out", answer.message, **held)
287
+ logger.info("call.waiting %s", {**_fields(call), **held})
288
+ await asyncio.sleep(answer.retry_after_seconds)
289
+
290
+
291
+ def refusal_status(reason: RefusalReason) -> int:
292
+ """The HTTP status for a refusal: 402 for what money or the owner can settle, 422 for a
293
+ policy deny, 401 for an agent SolvaPay did not accept, 502 when it could not be reached."""
294
+ if reason == "agent_not_accepted":
295
+ return 401
296
+ if reason == "unavailable":
297
+ return 502
298
+ if reason == "deny":
299
+ return 422
300
+ if reason in _OWNER_OR_MONEY_REASONS:
301
+ return 402
302
+ raise SolvaPayError(f"Refusal reason {reason!r} has no status", code="unknown_refusal_reason")
@@ -0,0 +1,293 @@
1
+ """Who is calling: SolvaPay agent tokens and agent keys.
2
+
3
+ Agent tokens are ES256 JWTs signed by SolvaPay's agent service, with keys at
4
+ ``<issuer>/jwks.json``, issued for one merchant (``aud`` is its provider
5
+ reference, or this server's resource URL for OAuth tokens). Agent keys
6
+ (``agk_…``) are opaque: SolvaPay says whose they are.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import time
13
+ from collections.abc import Awaitable, Callable
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Literal
17
+
18
+ import jwt
19
+
20
+ from ..client import AsyncApiClient
21
+ from ..errors import SolvaPayError
22
+ from .oauth import canonical_resource
23
+
24
+ AgentEnvironment = Literal["sandbox", "live"]
25
+ AGENT_ENVIRONMENTS: tuple[AgentEnvironment, ...] = ("sandbox", "live")
26
+ AGENT_KEY_PREFIX = "agk_"
27
+ AGENT_TOKEN_EXCERPT_CLAIM = "px"
28
+ AGENT_TOKEN_CLIENT_CLAIM = "cid"
29
+ AGENT_TOKEN_ENV_CLAIM = "env"
30
+ CLOCK_TOLERANCE_SECONDS = 30
31
+ AGENT_KEY_CACHE_SECONDS = 30.0
32
+
33
+ RejectionReason = Literal["expired", "invalid", "environment_mismatch"]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class VerifiedAgent:
38
+ agent_ref: str
39
+ """The agent, ``agt_…`` (the token's ``sub``)."""
40
+ principal_ref: str
41
+ """Pairwise reference of the account at this merchant, ``ppl_…``."""
42
+ prompt_excerpt: bool
43
+ """The prompt's end may go to SolvaPay with this agent's calls (``px`` claim)."""
44
+ environment: AgentEnvironment
45
+ token_id: str | None = None
46
+ expires_at: datetime | None = None
47
+ client_id: str | None = None
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class AgentRejected:
52
+ reason: RejectionReason
53
+ detail: str
54
+
55
+
56
+ AgentResult = VerifiedAgent | AgentRejected
57
+
58
+
59
+ def _check_environment(agent: VerifiedAgent, merchant: AgentEnvironment | None) -> AgentResult:
60
+ if merchant is not None and agent.environment != merchant:
61
+ return AgentRejected(
62
+ "environment_mismatch",
63
+ f"This agent spends in {agent.environment}, and this secret key is for {merchant}",
64
+ )
65
+ return agent
66
+
67
+
68
+ def _is_number(value: Any) -> bool:
69
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
70
+
71
+
72
+ def _key_matches(jwk: dict[str, Any], kid: str | None) -> bool:
73
+ """Whether a JWKS member may verify an ES256 token with this ``kid``, by the rules
74
+ TypeScript's ``jose`` applies: a token without a ``kid`` may use any matching key."""
75
+ if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
76
+ return False
77
+ if kid is not None and jwk.get("kid") != kid:
78
+ return False
79
+ if isinstance(jwk.get("alg"), str) and jwk["alg"] != "ES256":
80
+ return False
81
+ if isinstance(jwk.get("use"), str) and jwk["use"] != "sig":
82
+ return False
83
+ key_ops = jwk.get("key_ops")
84
+ return not isinstance(key_ops, list) or "verify" in key_ops
85
+
86
+
87
+ class AgentTokenVerifier:
88
+ """Verifies agent tokens against SolvaPay's published keys."""
89
+
90
+ def __init__(
91
+ self,
92
+ *,
93
+ issuer: str,
94
+ provider_ref: str,
95
+ resource: str | None = None,
96
+ environment: AgentEnvironment | None = None,
97
+ jwks: jwt.PyJWKClient | None = None,
98
+ clock: Callable[[], float] = time.time,
99
+ ) -> None:
100
+ self.issuer = issuer
101
+ self.audiences = [provider_ref] + ([canonical_resource(resource)] if resource else [])
102
+ self.environment = environment
103
+ self._jwks = jwks or jwt.PyJWKClient(f"{issuer.rstrip('/')}/jwks.json", cache_keys=True)
104
+ self._clock = clock
105
+
106
+ def _key(self, kid: str | None) -> Any:
107
+ """The one key that may have signed the token. The set is fetched again once when
108
+ none matches, as after SolvaPay rotates its key; more than one is ambiguous."""
109
+ for refresh in (False, True):
110
+ keys = [
111
+ key.key
112
+ for key in self._jwks.get_jwk_set(refresh=refresh).keys
113
+ if _key_matches(getattr(key, "_jwk_data", {}), kid)
114
+ ]
115
+ if len(keys) > 1:
116
+ raise jwt.PyJWKClientError("More than one key in the JWKS matches the token")
117
+ if keys:
118
+ return keys[0]
119
+ raise jwt.PyJWKClientError("No key in the JWKS matches the token")
120
+
121
+ def _claims(self, token: str) -> dict[str, Any]:
122
+ header = jwt.get_unverified_header(token)
123
+ if header.get("alg") != "ES256":
124
+ raise jwt.InvalidAlgorithmError("The token is not signed with ES256")
125
+ kid = header.get("kid")
126
+ claims: dict[str, Any] = jwt.decode(
127
+ token,
128
+ self._key(kid if isinstance(kid, str) else None),
129
+ algorithms=["ES256"],
130
+ audience=self.audiences,
131
+ issuer=self.issuer,
132
+ # The times are checked below, as TypeScript's jose checks them: claims must
133
+ # be numbers, and an iat in the future is not refused.
134
+ options={
135
+ "require": ["sub", "exp", "iat", "jti"],
136
+ "verify_exp": False,
137
+ "verify_nbf": False,
138
+ "verify_iat": False,
139
+ "verify_sub": False,
140
+ "verify_jti": False,
141
+ },
142
+ )
143
+ now = int(self._clock())
144
+ if not _is_number(claims["iat"]):
145
+ raise jwt.InvalidIssuedAtError("iat must be a number")
146
+ nbf = claims.get("nbf")
147
+ if nbf is not None:
148
+ if not _is_number(nbf):
149
+ raise jwt.ImmatureSignatureError("nbf must be a number")
150
+ if nbf > now + CLOCK_TOLERANCE_SECONDS:
151
+ raise jwt.ImmatureSignatureError("The token is not yet valid (nbf)")
152
+ if not _is_number(claims["exp"]):
153
+ raise jwt.DecodeError("exp must be a number")
154
+ if claims["exp"] <= now - CLOCK_TOLERANCE_SECONDS:
155
+ raise jwt.ExpiredSignatureError("Signature has expired")
156
+ return claims
157
+
158
+ def verify(self, token: str) -> AgentResult:
159
+ try:
160
+ claims = self._claims(token)
161
+ except jwt.ExpiredSignatureError as error:
162
+ return AgentRejected("expired", str(error))
163
+ except jwt.PyJWTError as error:
164
+ return AgentRejected("invalid", str(error))
165
+
166
+ client_id = claims.get(AGENT_TOKEN_CLIENT_CLAIM)
167
+ if client_id is not None and not isinstance(client_id, str):
168
+ return AgentRejected("invalid", "cid is not a string")
169
+ sub, principal = claims.get("sub"), claims.get("principal")
170
+ if not isinstance(sub, str) or not sub.startswith("agt_"):
171
+ return AgentRejected("invalid", "sub is not an agent reference")
172
+ if not isinstance(principal, str) or not principal.startswith("ppl_"):
173
+ return AgentRejected("invalid", "principal is missing")
174
+ if claims.get("scope") != "usage":
175
+ return AgentRejected("invalid", "scope is not usage")
176
+ env = claims.get(AGENT_TOKEN_ENV_CLAIM)
177
+ if env not in AGENT_ENVIRONMENTS:
178
+ return AgentRejected("invalid", "env is not sandbox or live")
179
+ return _check_environment(
180
+ VerifiedAgent(
181
+ agent_ref=sub,
182
+ principal_ref=principal,
183
+ prompt_excerpt=claims.get(AGENT_TOKEN_EXCERPT_CLAIM) is True,
184
+ environment=env,
185
+ token_id=str(claims["jti"]),
186
+ expires_at=datetime.fromtimestamp(claims["exp"], tz=timezone.utc),
187
+ client_id=client_id or None,
188
+ ),
189
+ self.environment,
190
+ )
191
+
192
+
193
+ class AgentKeyVerifier:
194
+ """Asks SolvaPay whose an agent key is, reusing each answer for 30 seconds.
195
+
196
+ SolvaPay out of reach raises: every paid call would fail the same way.
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ api: AsyncApiClient,
202
+ *,
203
+ environment: AgentEnvironment | None = None,
204
+ clock: Callable[[], float] = time.monotonic,
205
+ ) -> None:
206
+ self.api = api
207
+ self.environment = environment
208
+ self._clock = clock
209
+ self._answers: dict[str, tuple[float, AgentResult]] = {}
210
+
211
+ async def verify(self, agent_key: str) -> AgentResult:
212
+ cached = self._answers.get(agent_key)
213
+ if cached and cached[0] > self._clock():
214
+ return cached[1]
215
+ try:
216
+ verified = await self.api.verify_agent_key(body={"agentKey": agent_key})
217
+ result: AgentResult = _check_environment(
218
+ VerifiedAgent(
219
+ agent_ref=verified.agent_ref,
220
+ principal_ref=verified.principal_ref,
221
+ prompt_excerpt=verified.prompt_excerpt,
222
+ environment=verified.environment,
223
+ ),
224
+ self.environment,
225
+ )
226
+ except SolvaPayError as error:
227
+ # Only a refusal of the key itself is the agent's. SolvaPay failing, or
228
+ # refusing the merchant (agent payments off, a secret key for the other
229
+ # environment), is raised for the merchant to fix.
230
+ reason = agent_refusal(error)
231
+ if reason is None:
232
+ raise
233
+ result = AgentRejected(reason, error.message)
234
+ self._answers[agent_key] = (self._clock() + AGENT_KEY_CACHE_SECONDS, result)
235
+ return result
236
+
237
+
238
+ def agent_refusal(error: SolvaPayError) -> Literal["invalid", "environment_mismatch"] | None:
239
+ """Whose a 401 or 403 from SolvaPay's agent endpoints is, as in TypeScript.
240
+
241
+ The agent's: the token or key is unknown, revoked or another merchant's (401,
242
+ or 403 with no code), or from the other environment (403
243
+ ``environment_mismatch``). Any other code, such as ``feature_not_enabled``
244
+ (agent payments are off for this merchant) or ``key_env_mismatch`` (the
245
+ secret key is for the other environment), is the merchant's: ``None``."""
246
+ if error.status == 401:
247
+ return "invalid"
248
+ if error.status != 403:
249
+ return None
250
+ if error.code == "http_403": # SolvaPay sent no code
251
+ return "invalid"
252
+ if error.code == "environment_mismatch":
253
+ return "environment_mismatch"
254
+ return None
255
+
256
+
257
+ _JWT = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
258
+
259
+
260
+ def looks_like_jwt(value: str) -> bool:
261
+ return bool(_JWT.fullmatch(value))
262
+
263
+
264
+ def is_agent_key(value: str) -> bool:
265
+ return value.startswith(AGENT_KEY_PREFIX) and len(value) > len(AGENT_KEY_PREFIX)
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class NotAgent:
270
+ """The credential is not an agent's: the merchant's own auth decides."""
271
+
272
+
273
+ Identification = VerifiedAgent | AgentRejected | NotAgent
274
+
275
+
276
+ class AgentLayer:
277
+ """Tells an agent's credential from the merchant's own, and verifies it."""
278
+
279
+ def __init__(
280
+ self,
281
+ *,
282
+ verify_token: Callable[[str], AgentResult],
283
+ verify_key: Callable[[str], Awaitable[AgentResult]] | None = None,
284
+ ) -> None:
285
+ self._verify_token = verify_token
286
+ self._verify_key = verify_key
287
+
288
+ async def identify(self, presented: str) -> Identification:
289
+ if is_agent_key(presented) and self._verify_key is not None:
290
+ return await self._verify_key(presented)
291
+ if not looks_like_jwt(presented):
292
+ return NotAgent()
293
+ return self._verify_token(presented)