agentspend 0.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.
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ dist/
3
+ *.tgz
4
+ bun.lock
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentspend
3
+ Version: 0.1.0
4
+ Summary: Python SDK for AgentSpend — card & crypto paywalls for AI agents
5
+ Project-URL: Homepage, https://agentspend.co
6
+ Project-URL: Repository, https://github.com/agentspend/agentspend
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx>=0.25
10
+ Provides-Extra: django
11
+ Requires-Dist: django>=4.0; extra == 'django'
12
+ Provides-Extra: fastapi
13
+ Requires-Dist: fastapi>=0.100; extra == 'fastapi'
14
+ Requires-Dist: uvicorn>=0.20; extra == 'fastapi'
15
+ Provides-Extra: flask
16
+ Requires-Dist: flask>=2.0; extra == 'flask'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # agentspend
20
+
21
+ Python SDK for AgentSpend — card & crypto paywalls for AI agents.
@@ -0,0 +1,3 @@
1
+ # agentspend
2
+
3
+ Python SDK for AgentSpend — card & crypto paywalls for AI agents.
@@ -0,0 +1,23 @@
1
+ from agentspend.core import AgentSpendClient
2
+ from agentspend.types import (
3
+ AgentSpendOptions,
4
+ ChargeOptions,
5
+ ChargeResponse,
6
+ PaywallOptions,
7
+ PaywallPaymentContext,
8
+ PaywallRequest,
9
+ PaywallResult,
10
+ AgentSpendChargeError,
11
+ )
12
+
13
+ __all__ = [
14
+ "AgentSpendClient",
15
+ "AgentSpendOptions",
16
+ "ChargeOptions",
17
+ "ChargeResponse",
18
+ "PaywallOptions",
19
+ "PaywallPaymentContext",
20
+ "PaywallRequest",
21
+ "PaywallResult",
22
+ "AgentSpendChargeError",
23
+ ]
@@ -0,0 +1,416 @@
1
+ """Core AgentSpend client — framework-agnostic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import uuid
9
+ import time
10
+ import random
11
+ from typing import Any, Optional
12
+
13
+ import httpx
14
+
15
+ from agentspend.types import (
16
+ AgentSpendChargeError,
17
+ AgentSpendOptions,
18
+ ChargeOptions,
19
+ ChargeResponse,
20
+ PaywallOptions,
21
+ PaywallPaymentContext,
22
+ PaywallRequest,
23
+ PaywallResult,
24
+ )
25
+
26
+ DEFAULT_PLATFORM_API_BASE_URL = "https://api.agentspend.co"
27
+
28
+ USDC_BASE_ASSET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
29
+
30
+
31
+ def _resolve_platform_url(explicit: Optional[str]) -> str:
32
+ if explicit and explicit.strip():
33
+ return explicit.strip()
34
+ env = os.environ.get("AGENTSPEND_API_URL", "")
35
+ if env.strip():
36
+ return env.strip()
37
+ return DEFAULT_PLATFORM_API_BASE_URL
38
+
39
+
40
+ def _join_url(base: str, path: str) -> str:
41
+ base = base.rstrip("/")
42
+ path = path if path.startswith("/") else f"/{path}"
43
+ return f"{base}{path}"
44
+
45
+
46
+ def _best_effort_idempotency_key() -> str:
47
+ try:
48
+ return str(uuid.uuid4())
49
+ except Exception:
50
+ return f"auto_{int(time.time())}_{random.randint(0, 0xFFFFFFFF):08x}"
51
+
52
+
53
+ def _to_card_id(value: Any) -> Optional[str]:
54
+ if not isinstance(value, str):
55
+ return None
56
+ trimmed = value.strip()
57
+ return trimmed if trimmed.startswith("card_") else None
58
+
59
+
60
+ def _to_string_metadata(data: Any) -> dict[str, str]:
61
+ if not isinstance(data, dict):
62
+ return {}
63
+ result: dict[str, str] = {}
64
+ for key, value in data.items():
65
+ if isinstance(value, str):
66
+ result[key] = value
67
+ elif isinstance(value, (int, float)):
68
+ result[key] = str(value)
69
+ elif isinstance(value, bool):
70
+ result[key] = "true" if value else "false"
71
+ return result
72
+
73
+
74
+ def _resolve_amount(amount: int | str | Any, body: Any) -> int:
75
+ if isinstance(amount, int):
76
+ return amount
77
+ if isinstance(amount, str):
78
+ if isinstance(body, dict):
79
+ raw = body.get(amount)
80
+ return raw if isinstance(raw, int) else 0
81
+ return 0
82
+ # callable
83
+ return amount(body)
84
+
85
+
86
+ def _extract_card_id(headers: dict[str, Optional[str]], body: Any) -> Optional[str]:
87
+ card_id = _to_card_id(headers.get("x-card-id"))
88
+ if not card_id and isinstance(body, dict):
89
+ card_id = _to_card_id(body.get("card_id"))
90
+ return card_id
91
+
92
+
93
+ def _extract_payment_header(headers: dict[str, Optional[str]]) -> Optional[str]:
94
+ return headers.get("payment-signature") or headers.get("x-payment") or None
95
+
96
+
97
+ class AgentSpendClient:
98
+ """Framework-agnostic AgentSpend client.
99
+
100
+ Usage::
101
+
102
+ client = AgentSpendClient(AgentSpendOptions(service_api_key="sk_..."))
103
+ result = client.charge("card_...", ChargeOptions(amount_cents=500))
104
+ """
105
+
106
+ def __init__(self, options: AgentSpendOptions):
107
+ if not options.service_api_key and not options.crypto:
108
+ raise AgentSpendChargeError(
109
+ "At least one of service_api_key or crypto config must be provided",
110
+ 500,
111
+ )
112
+ self._options = options
113
+ self._base_url = _resolve_platform_url(options.platform_api_base_url)
114
+ self._cached_service_id: Optional[str] = None
115
+ self._network = options.crypto.network if options.crypto else "eip155:8453"
116
+ self._http = httpx.Client(timeout=30)
117
+
118
+ # -----------------------------------------------------------------
119
+ # charge()
120
+ # -----------------------------------------------------------------
121
+
122
+ def charge(self, card_id_input: str, opts: ChargeOptions) -> ChargeResponse:
123
+ if not self._options.service_api_key:
124
+ raise AgentSpendChargeError("charge() requires service_api_key", 500)
125
+
126
+ card_id = _to_card_id(card_id_input)
127
+ if not card_id:
128
+ raise AgentSpendChargeError("card_id must start with card_", 400)
129
+ if not isinstance(opts.amount_cents, int) or opts.amount_cents <= 0:
130
+ raise AgentSpendChargeError("amount_cents must be a positive integer", 400)
131
+
132
+ payload: dict[str, Any] = {
133
+ "card_id": card_id,
134
+ "amount_cents": opts.amount_cents,
135
+ "currency": opts.currency,
136
+ "idempotency_key": opts.idempotency_key or _best_effort_idempotency_key(),
137
+ }
138
+ if opts.description:
139
+ payload["description"] = opts.description
140
+ if opts.metadata:
141
+ payload["metadata"] = opts.metadata
142
+
143
+ resp = self._http.post(
144
+ _join_url(self._base_url, "/v1/charge"),
145
+ json=payload,
146
+ headers={
147
+ "authorization": f"Bearer {self._options.service_api_key}",
148
+ "content-type": "application/json",
149
+ },
150
+ )
151
+
152
+ body = resp.json()
153
+ if resp.status_code >= 400:
154
+ msg = body.get("error", "AgentSpend charge failed") if isinstance(body, dict) else "AgentSpend charge failed"
155
+ raise AgentSpendChargeError(msg, resp.status_code, body)
156
+
157
+ return ChargeResponse(
158
+ charged=body.get("charged", True),
159
+ card_id=body.get("card_id", card_id),
160
+ amount_cents=body.get("amount_cents", opts.amount_cents),
161
+ currency=body.get("currency", opts.currency),
162
+ remaining_limit_cents=body.get("remaining_limit_cents", 0),
163
+ stripe_payment_intent_id=body.get("stripe_payment_intent_id", ""),
164
+ stripe_charge_id=body.get("stripe_charge_id", ""),
165
+ charge_attempt_id=body.get("charge_attempt_id", ""),
166
+ )
167
+
168
+ # -----------------------------------------------------------------
169
+ # process_paywall()
170
+ # -----------------------------------------------------------------
171
+
172
+ def process_paywall(self, opts: PaywallOptions, request: PaywallRequest) -> PaywallResult:
173
+ if isinstance(opts.amount, int):
174
+ if opts.amount <= 0:
175
+ raise AgentSpendChargeError("amount must be a positive integer", 500)
176
+
177
+ body = request.body
178
+ effective_amount = _resolve_amount(opts.amount, body)
179
+
180
+ if not isinstance(effective_amount, int) or effective_amount <= 0:
181
+ return PaywallResult(
182
+ outcome="error",
183
+ status_code=400,
184
+ body={"error": "Could not determine payment amount from request"},
185
+ )
186
+
187
+ currency = opts.currency
188
+
189
+ # Crypto payment
190
+ payment_header = _extract_payment_header(request.headers)
191
+ if payment_header:
192
+ return self._handle_crypto_payment(payment_header, effective_amount, currency)
193
+
194
+ # Card payment
195
+ card_id = _extract_card_id(request.headers, body)
196
+ if card_id:
197
+ return self._handle_card_payment(request, card_id, effective_amount, currency, body, opts)
198
+
199
+ # 402
200
+ return self._build_402_result(request.url, effective_amount, currency)
201
+
202
+ # -----------------------------------------------------------------
203
+ # Internal
204
+ # -----------------------------------------------------------------
205
+
206
+ def _get_service_id(self) -> Optional[str]:
207
+ if self._cached_service_id:
208
+ return self._cached_service_id
209
+ if not self._options.service_api_key:
210
+ return None
211
+ try:
212
+ resp = self._http.get(
213
+ _join_url(self._base_url, "/v1/service/me"),
214
+ headers={"authorization": f"Bearer {self._options.service_api_key}"},
215
+ )
216
+ if resp.status_code < 400:
217
+ data = resp.json()
218
+ self._cached_service_id = data.get("id")
219
+ except Exception:
220
+ pass
221
+ return self._cached_service_id
222
+
223
+ def _resolve_pay_to_address(self) -> str:
224
+ if self._options.crypto and self._options.crypto.receiver_address:
225
+ return self._options.crypto.receiver_address
226
+
227
+ if self._options.service_api_key:
228
+ resp = self._http.post(
229
+ _join_url(self._base_url, "/v1/crypto/deposit-address"),
230
+ json={"amount_cents": 0, "currency": "usd"},
231
+ headers={
232
+ "authorization": f"Bearer {self._options.service_api_key}",
233
+ "content-type": "application/json",
234
+ },
235
+ )
236
+ if resp.status_code >= 400:
237
+ raise AgentSpendChargeError("Failed to resolve crypto deposit address", 502)
238
+ data = resp.json()
239
+ addr = data.get("deposit_address")
240
+ if not addr:
241
+ raise AgentSpendChargeError("No deposit address returned", 502)
242
+ return addr
243
+
244
+ raise AgentSpendChargeError("No crypto payTo address available", 500)
245
+
246
+ def _build_402_result(self, request_url: str, amount_cents: int, currency: str) -> PaywallResult:
247
+ service_id = self._get_service_id()
248
+
249
+ agentspend_block: dict[str, Any] = {}
250
+ if service_id:
251
+ agentspend_block = {"agentspend": {"service_id": service_id, "amount_cents": amount_cents}}
252
+
253
+ try:
254
+ pay_to = self._resolve_pay_to_address()
255
+ payment_requirements = {
256
+ "scheme": "exact",
257
+ "network": self._network,
258
+ "amount": str(amount_cents),
259
+ "asset": USDC_BASE_ASSET,
260
+ "payTo": pay_to,
261
+ "maxTimeoutSeconds": 300,
262
+ "extra": {"name": "USD Coin", "version": "2"},
263
+ }
264
+ payment_required = {
265
+ "x402Version": 2,
266
+ "error": "Payment required",
267
+ "resource": {
268
+ "url": request_url,
269
+ "description": f"Payment of {amount_cents} cents",
270
+ "mimeType": "application/json",
271
+ },
272
+ "accepts": [payment_requirements],
273
+ }
274
+ header_value = base64.b64encode(json.dumps(payment_required).encode()).decode()
275
+
276
+ return PaywallResult(
277
+ outcome="payment_required",
278
+ status_code=402,
279
+ body={"error": "Payment required", "amount_cents": amount_cents, "currency": currency, **agentspend_block},
280
+ headers={"Payment-Required": header_value},
281
+ )
282
+ except Exception as exc:
283
+ print(f"[agentspend] Failed to resolve crypto payTo address — returning card-only 402: {exc}")
284
+ return PaywallResult(
285
+ outcome="payment_required",
286
+ status_code=402,
287
+ body={"error": "Payment required", "amount_cents": amount_cents, "currency": currency, **agentspend_block},
288
+ headers={},
289
+ )
290
+
291
+ def _handle_card_payment(
292
+ self,
293
+ request: PaywallRequest,
294
+ card_id: str,
295
+ amount_cents: int,
296
+ currency: str,
297
+ body: Any,
298
+ opts: PaywallOptions,
299
+ ) -> PaywallResult:
300
+ if not self._options.service_api_key:
301
+ return PaywallResult(outcome="error", status_code=500, body={"error": "Card payments require service_api_key"})
302
+
303
+ try:
304
+ charge_result = self.charge(
305
+ card_id,
306
+ ChargeOptions(
307
+ amount_cents=amount_cents,
308
+ currency=currency,
309
+ description=opts.description,
310
+ metadata=_to_string_metadata(opts.metadata(body)) if opts.metadata else None,
311
+ idempotency_key=request.headers.get("x-request-id") or request.headers.get("idempotency-key"),
312
+ ),
313
+ )
314
+ return PaywallResult(
315
+ outcome="charged",
316
+ payment_context=PaywallPaymentContext(
317
+ method="card",
318
+ amount_cents=amount_cents,
319
+ currency=currency,
320
+ card_id=card_id,
321
+ remaining_limit_cents=charge_result.remaining_limit_cents,
322
+ ),
323
+ )
324
+ except AgentSpendChargeError as exc:
325
+ if exc.status_code == 403:
326
+ return self._build_402_result(request.url, amount_cents, currency)
327
+ if exc.status_code == 402:
328
+ return PaywallResult(outcome="error", status_code=402, body={"error": "Payment required", "details": exc.details})
329
+ return PaywallResult(outcome="error", status_code=exc.status_code, body={"error": str(exc), "details": exc.details})
330
+ except Exception:
331
+ return PaywallResult(outcome="error", status_code=500, body={"error": "Unexpected paywall failure"})
332
+
333
+ def _handle_crypto_payment(
334
+ self,
335
+ payment_header: str,
336
+ amount_cents: int,
337
+ currency: str,
338
+ ) -> PaywallResult:
339
+ try:
340
+ payload_json = base64.b64decode(payment_header).decode("utf-8")
341
+ payment_payload = json.loads(payload_json)
342
+ except Exception:
343
+ return PaywallResult(outcome="error", status_code=400, body={"error": "Invalid payment payload encoding"})
344
+
345
+ accepted_pay_to = None
346
+ if isinstance(payment_payload, dict):
347
+ accepted = payment_payload.get("accepted", {})
348
+ if isinstance(accepted, dict):
349
+ accepted_pay_to = accepted.get("payTo")
350
+
351
+ try:
352
+ pay_to = accepted_pay_to or self._resolve_pay_to_address()
353
+ except AgentSpendChargeError as exc:
354
+ return PaywallResult(outcome="error", status_code=exc.status_code, body={"error": exc.args[0], "details": exc.details})
355
+
356
+ # Note: Full x402 verification requires the @x402 libraries.
357
+ # For Python, crypto verification should be implemented via HTTP calls
358
+ # to the facilitator endpoint. This is a placeholder that demonstrates
359
+ # the protocol flow — production use requires facilitator integration.
360
+ payment_requirements = {
361
+ "scheme": "exact",
362
+ "network": self._network,
363
+ "amount": str(amount_cents),
364
+ "asset": USDC_BASE_ASSET,
365
+ "payTo": pay_to,
366
+ "maxTimeoutSeconds": 300,
367
+ "extra": {"name": "USD Coin", "version": "2"},
368
+ }
369
+
370
+ facilitator_url = "https://facilitator.openx402.ai"
371
+ if self._options.crypto and self._options.crypto.facilitator_url:
372
+ facilitator_url = self._options.crypto.facilitator_url
373
+
374
+ # Verify via facilitator
375
+ try:
376
+ verify_resp = self._http.post(
377
+ f"{facilitator_url.rstrip('/')}/verify",
378
+ json={"payload": payment_payload, "requirements": payment_requirements},
379
+ )
380
+ verify_data = verify_resp.json() if verify_resp.status_code < 500 else {}
381
+ if not verify_data.get("isValid"):
382
+ return PaywallResult(
383
+ outcome="error",
384
+ status_code=402,
385
+ body={"error": "Payment verification failed", "details": verify_data.get("invalidReason")},
386
+ )
387
+ except Exception as exc:
388
+ return PaywallResult(outcome="error", status_code=500, body={"error": "Crypto payment processing failed", "details": str(exc)})
389
+
390
+ # Settle via facilitator
391
+ try:
392
+ settle_resp = self._http.post(
393
+ f"{facilitator_url.rstrip('/')}/settle",
394
+ json={"payload": payment_payload, "requirements": payment_requirements},
395
+ )
396
+ settle_data = settle_resp.json() if settle_resp.status_code < 500 else {}
397
+ if not settle_data.get("success"):
398
+ return PaywallResult(
399
+ outcome="error",
400
+ status_code=402,
401
+ body={"error": "Payment settlement failed", "details": settle_data.get("errorReason")},
402
+ )
403
+ except Exception as exc:
404
+ return PaywallResult(outcome="error", status_code=500, body={"error": "Crypto payment processing failed", "details": str(exc)})
405
+
406
+ return PaywallResult(
407
+ outcome="crypto_paid",
408
+ payment_context=PaywallPaymentContext(
409
+ method="crypto",
410
+ amount_cents=amount_cents,
411
+ currency=currency,
412
+ transaction_hash=settle_data.get("transaction"),
413
+ payer_address=verify_data.get("payer"),
414
+ network=self._network,
415
+ ),
416
+ )
@@ -0,0 +1,153 @@
1
+ """Django integration for AgentSpend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Callable, Optional
7
+
8
+ from django.http import HttpRequest, JsonResponse
9
+
10
+ from agentspend.core import AgentSpendClient
11
+ from agentspend.types import (
12
+ AgentSpendOptions,
13
+ ChargeOptions,
14
+ ChargeResponse,
15
+ PaywallOptions,
16
+ PaywallPaymentContext,
17
+ PaywallRequest,
18
+ )
19
+
20
+ _PAYMENT_CONTEXT_ATTR = "_agentspend_payment_context"
21
+
22
+
23
+ def get_payment_context(request: HttpRequest) -> PaywallPaymentContext | None:
24
+ """Retrieve the payment context set by the paywall middleware."""
25
+ return getattr(request, _PAYMENT_CONTEXT_ATTR, None)
26
+
27
+
28
+ class AgentSpendMiddleware:
29
+ """Django middleware for AgentSpend paywalls.
30
+
31
+ Configure in settings.py::
32
+
33
+ AGENTSPEND_OPTIONS = AgentSpendOptions(service_api_key="sk_...")
34
+ AGENTSPEND_PAYWALL_ROUTES = {
35
+ "/paid/": PaywallOptions(amount=500),
36
+ }
37
+
38
+ Then use the ``agentspend_paywall`` decorator for per-view control,
39
+ or this middleware for route-based configuration.
40
+ """
41
+
42
+ def __init__(self, get_response: Callable[[HttpRequest], Any]):
43
+ self.get_response = get_response
44
+ # Import from Django settings
45
+ from django.conf import settings
46
+
47
+ options: AgentSpendOptions = getattr(settings, "AGENTSPEND_OPTIONS", None)
48
+ if options is None:
49
+ raise ValueError("AGENTSPEND_OPTIONS must be set in Django settings")
50
+
51
+ self._client = AgentSpendClient(options)
52
+ self._routes: dict[str, PaywallOptions] = getattr(settings, "AGENTSPEND_PAYWALL_ROUTES", {})
53
+
54
+ def __call__(self, request: HttpRequest) -> Any:
55
+ opts = self._routes.get(request.path)
56
+ if opts is None:
57
+ return self.get_response(request)
58
+
59
+ result = self._process(request, opts)
60
+ if result is not None:
61
+ return result
62
+
63
+ return self.get_response(request)
64
+
65
+ def _process(self, request: HttpRequest, opts: PaywallOptions) -> Optional[JsonResponse]:
66
+ try:
67
+ body = json.loads(request.body) if request.body else {}
68
+ except (json.JSONDecodeError, ValueError):
69
+ body = {}
70
+
71
+ headers = {
72
+ "x-card-id": request.headers.get("x-card-id"),
73
+ "payment-signature": request.headers.get("payment-signature"),
74
+ "x-payment": request.headers.get("x-payment"),
75
+ "x-request-id": request.headers.get("x-request-id"),
76
+ "idempotency-key": request.headers.get("idempotency-key"),
77
+ }
78
+
79
+ paywall_req = PaywallRequest(
80
+ url=request.build_absolute_uri(),
81
+ method=request.method,
82
+ headers=headers,
83
+ body=body,
84
+ )
85
+
86
+ result = self._client.process_paywall(opts, paywall_req)
87
+
88
+ if result.outcome in ("charged", "crypto_paid"):
89
+ setattr(request, _PAYMENT_CONTEXT_ATTR, result.payment_context)
90
+ return None
91
+
92
+ if result.outcome == "payment_required":
93
+ resp = JsonResponse(result.body, status=result.status_code or 402)
94
+ for key, value in result.headers.items():
95
+ resp[key] = value
96
+ return resp
97
+
98
+ return JsonResponse(result.body, status=result.status_code or 500)
99
+
100
+
101
+ def agentspend_paywall(options: AgentSpendOptions, paywall_opts: PaywallOptions) -> Callable[..., Any]:
102
+ """Decorator for individual Django views.
103
+
104
+ Usage::
105
+
106
+ from agentspend.django import agentspend_paywall, get_payment_context
107
+
108
+ @agentspend_paywall(AgentSpendOptions(service_api_key="sk_..."), PaywallOptions(amount=500))
109
+ def paid_view(request):
110
+ ctx = get_payment_context(request)
111
+ return JsonResponse({"paid": True, "method": ctx.method})
112
+ """
113
+ client = AgentSpendClient(options)
114
+
115
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
116
+ def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
117
+ try:
118
+ body = json.loads(request.body) if request.body else {}
119
+ except (json.JSONDecodeError, ValueError):
120
+ body = {}
121
+
122
+ headers = {
123
+ "x-card-id": request.headers.get("x-card-id"),
124
+ "payment-signature": request.headers.get("payment-signature"),
125
+ "x-payment": request.headers.get("x-payment"),
126
+ "x-request-id": request.headers.get("x-request-id"),
127
+ "idempotency-key": request.headers.get("idempotency-key"),
128
+ }
129
+
130
+ paywall_req = PaywallRequest(
131
+ url=request.build_absolute_uri(),
132
+ method=request.method,
133
+ headers=headers,
134
+ body=body,
135
+ )
136
+
137
+ result = client.process_paywall(paywall_opts, paywall_req)
138
+
139
+ if result.outcome in ("charged", "crypto_paid"):
140
+ setattr(request, _PAYMENT_CONTEXT_ATTR, result.payment_context)
141
+ return fn(request, *args, **kwargs)
142
+
143
+ if result.outcome == "payment_required":
144
+ resp = JsonResponse(result.body, status=result.status_code or 402)
145
+ for key, value in result.headers.items():
146
+ resp[key] = value
147
+ return resp
148
+
149
+ return JsonResponse(result.body, status=result.status_code or 500)
150
+
151
+ return wrapper
152
+
153
+ return decorator
@@ -0,0 +1,121 @@
1
+ """FastAPI integration for AgentSpend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from fastapi import Depends, Request
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from agentspend.core import AgentSpendClient
11
+ from agentspend.types import (
12
+ AgentSpendOptions,
13
+ ChargeOptions,
14
+ ChargeResponse,
15
+ PaywallOptions,
16
+ PaywallPaymentContext,
17
+ PaywallRequest,
18
+ PaywallResult,
19
+ )
20
+
21
+ _PAYMENT_CONTEXT_ATTR = "_agentspend_payment_context"
22
+
23
+
24
+ def get_payment_context(request: Request) -> PaywallPaymentContext | None:
25
+ """Retrieve the payment context set by the paywall dependency."""
26
+ return getattr(request.state, _PAYMENT_CONTEXT_ATTR, None)
27
+
28
+
29
+ class AgentSpend:
30
+ """FastAPI AgentSpend integration.
31
+
32
+ Usage::
33
+
34
+ from agentspend.fastapi import AgentSpend, get_payment_context
35
+
36
+ spend = AgentSpend(AgentSpendOptions(service_api_key="sk_..."))
37
+
38
+ @app.post("/paid", dependencies=[Depends(spend.paywall(PaywallOptions(amount=500)))])
39
+ async def paid_endpoint(request: Request):
40
+ ctx = get_payment_context(request)
41
+ return {"paid": True, "method": ctx.method}
42
+ """
43
+
44
+ def __init__(self, options: AgentSpendOptions):
45
+ self._client = AgentSpendClient(options)
46
+
47
+ def charge(self, card_id: str, opts: ChargeOptions) -> ChargeResponse:
48
+ return self._client.charge(card_id, opts)
49
+
50
+ def paywall(self, opts: PaywallOptions):
51
+ """Return a FastAPI dependency that gates a route behind a paywall."""
52
+ client = self._client
53
+
54
+ async def paywall_dependency(request: Request) -> Optional[PaywallPaymentContext]:
55
+ body: Any
56
+ try:
57
+ body = await request.json()
58
+ except Exception:
59
+ body = {}
60
+
61
+ headers = {
62
+ "x-card-id": request.headers.get("x-card-id"),
63
+ "payment-signature": request.headers.get("payment-signature"),
64
+ "x-payment": request.headers.get("x-payment"),
65
+ "x-request-id": request.headers.get("x-request-id"),
66
+ "idempotency-key": request.headers.get("idempotency-key"),
67
+ }
68
+
69
+ paywall_req = PaywallRequest(
70
+ url=str(request.url),
71
+ method=request.method,
72
+ headers=headers,
73
+ body=body,
74
+ )
75
+
76
+ result = client.process_paywall(opts, paywall_req)
77
+
78
+ if result.outcome in ("charged", "crypto_paid"):
79
+ setattr(request.state, _PAYMENT_CONTEXT_ATTR, result.payment_context)
80
+ return result.payment_context
81
+
82
+ if result.outcome == "payment_required":
83
+ raise _PaywallResponseException(
84
+ status_code=result.status_code or 402,
85
+ body=result.body or {},
86
+ headers=result.headers,
87
+ )
88
+
89
+ raise _PaywallResponseException(
90
+ status_code=result.status_code or 500,
91
+ body=result.body or {},
92
+ )
93
+
94
+ return paywall_dependency
95
+
96
+
97
+ class _PaywallResponseException(Exception):
98
+ """Internal exception to short-circuit FastAPI request processing."""
99
+
100
+ def __init__(self, status_code: int, body: dict[str, Any], headers: dict[str, str] | None = None):
101
+ self.status_code = status_code
102
+ self.body = body
103
+ self.headers = headers or {}
104
+
105
+
106
+ def install_exception_handler(app: Any) -> None:
107
+ """Install the AgentSpend exception handler on a FastAPI app.
108
+
109
+ Call this once during app startup::
110
+
111
+ from agentspend.fastapi import install_exception_handler
112
+ install_exception_handler(app)
113
+ """
114
+
115
+ @app.exception_handler(_PaywallResponseException)
116
+ async def _handle_paywall_exception(request: Request, exc: _PaywallResponseException) -> JSONResponse:
117
+ return JSONResponse(
118
+ status_code=exc.status_code,
119
+ content=exc.body,
120
+ headers=exc.headers,
121
+ )
@@ -0,0 +1,90 @@
1
+ """Flask integration for AgentSpend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import wraps
6
+ from typing import Any, Callable
7
+
8
+ from flask import Response as FlaskResponse, g, jsonify, request
9
+
10
+ from agentspend.core import AgentSpendClient
11
+ from agentspend.types import (
12
+ AgentSpendOptions,
13
+ ChargeOptions,
14
+ ChargeResponse,
15
+ PaywallOptions,
16
+ PaywallPaymentContext,
17
+ PaywallRequest,
18
+ PaywallResult,
19
+ )
20
+
21
+
22
+ def get_payment_context() -> PaywallPaymentContext | None:
23
+ """Retrieve the payment context set by the paywall decorator."""
24
+ return getattr(g, "payment_context", None)
25
+
26
+
27
+ class AgentSpend:
28
+ """Flask AgentSpend integration.
29
+
30
+ Usage::
31
+
32
+ from agentspend.flask import AgentSpend
33
+
34
+ spend = AgentSpend(AgentSpendOptions(service_api_key="sk_..."))
35
+
36
+ @app.route("/paid", methods=["POST"])
37
+ @spend.paywall(PaywallOptions(amount=500))
38
+ def paid_endpoint():
39
+ ctx = get_payment_context()
40
+ return jsonify({"paid": True, "method": ctx.method})
41
+ """
42
+
43
+ def __init__(self, options: AgentSpendOptions):
44
+ self._client = AgentSpendClient(options)
45
+
46
+ def charge(self, card_id: str, opts: ChargeOptions) -> ChargeResponse:
47
+ return self._client.charge(card_id, opts)
48
+
49
+ def paywall(self, opts: PaywallOptions) -> Callable[..., Any]:
50
+ """Decorator that gates a Flask route behind a paywall."""
51
+
52
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
53
+ @wraps(fn)
54
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
55
+ headers = {
56
+ "x-card-id": request.headers.get("x-card-id"),
57
+ "payment-signature": request.headers.get("payment-signature"),
58
+ "x-payment": request.headers.get("x-payment"),
59
+ "x-request-id": request.headers.get("x-request-id"),
60
+ "idempotency-key": request.headers.get("idempotency-key"),
61
+ }
62
+
63
+ body = request.get_json(silent=True) or {}
64
+
65
+ paywall_req = PaywallRequest(
66
+ url=request.url,
67
+ method=request.method,
68
+ headers=headers,
69
+ body=body,
70
+ )
71
+
72
+ result = self._client.process_paywall(opts, paywall_req)
73
+
74
+ if result.outcome in ("charged", "crypto_paid"):
75
+ g.payment_context = result.payment_context
76
+ return fn(*args, **kwargs)
77
+ elif result.outcome == "payment_required":
78
+ resp = jsonify(result.body)
79
+ resp.status_code = result.status_code or 402
80
+ for key, value in result.headers.items():
81
+ resp.headers[key] = value
82
+ return resp
83
+ else:
84
+ resp = jsonify(result.body)
85
+ resp.status_code = result.status_code or 500
86
+ return resp
87
+
88
+ return wrapper
89
+
90
+ return decorator
File without changes
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable, Optional, Union
5
+
6
+
7
+ class AgentSpendChargeError(Exception):
8
+ """Raised when a charge or paywall operation fails."""
9
+
10
+ def __init__(self, message: str, status_code: int, details: Any = None):
11
+ super().__init__(message)
12
+ self.status_code = status_code
13
+ self.details = details
14
+
15
+
16
+ @dataclass
17
+ class AgentSpendOptions:
18
+ """Configuration for the AgentSpend client."""
19
+
20
+ platform_api_base_url: Optional[str] = None
21
+ service_api_key: Optional[str] = None
22
+ crypto: Optional[CryptoConfig] = None
23
+
24
+
25
+ @dataclass
26
+ class CryptoConfig:
27
+ """Crypto / x402 configuration."""
28
+
29
+ receiver_address: Optional[str] = None
30
+ network: str = "eip155:8453"
31
+ facilitator_url: str = "https://facilitator.openx402.ai"
32
+
33
+
34
+ @dataclass
35
+ class ChargeOptions:
36
+ amount_cents: int
37
+ currency: str = "usd"
38
+ description: Optional[str] = None
39
+ metadata: Optional[dict[str, str]] = None
40
+ idempotency_key: Optional[str] = None
41
+
42
+
43
+ @dataclass
44
+ class ChargeResponse:
45
+ charged: bool
46
+ card_id: str
47
+ amount_cents: int
48
+ currency: str
49
+ remaining_limit_cents: int
50
+ stripe_payment_intent_id: str
51
+ stripe_charge_id: str
52
+ charge_attempt_id: str
53
+
54
+
55
+ @dataclass
56
+ class PaywallOptions:
57
+ """Paywall configuration.
58
+
59
+ amount can be:
60
+ - int: fixed price in cents (e.g. 500 = $5.00)
61
+ - str: body field name to read amount from (e.g. "amount_cents")
62
+ - callable: custom dynamic pricing ``(body) -> int``
63
+ """
64
+
65
+ amount: Union[int, str, Callable[[Any], int]]
66
+ currency: str = "usd"
67
+ description: Optional[str] = None
68
+ metadata: Optional[Callable[[Any], dict[str, Any]]] = None
69
+
70
+
71
+ @dataclass
72
+ class PaywallPaymentContext:
73
+ method: str # "card" | "crypto"
74
+ amount_cents: int
75
+ currency: str
76
+ card_id: Optional[str] = None
77
+ remaining_limit_cents: Optional[int] = None
78
+ transaction_hash: Optional[str] = None
79
+ payer_address: Optional[str] = None
80
+ network: Optional[str] = None
81
+
82
+
83
+ @dataclass
84
+ class PaywallRequest:
85
+ url: str
86
+ method: str
87
+ headers: dict[str, Optional[str]]
88
+ body: Any
89
+
90
+
91
+ @dataclass
92
+ class PaywallResult:
93
+ """Result from processPaywall.
94
+
95
+ outcome is one of: "charged", "crypto_paid", "payment_required", "error"
96
+ """
97
+
98
+ outcome: str
99
+ payment_context: Optional[PaywallPaymentContext] = None
100
+ status_code: Optional[int] = None
101
+ body: Optional[dict[str, Any]] = None
102
+ headers: dict[str, str] = field(default_factory=dict)
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentspend"
7
+ version = "0.1.0"
8
+ description = "Python SDK for AgentSpend — card & crypto paywalls for AI agents"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "httpx>=0.25",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ flask = ["flask>=2.0"]
18
+ fastapi = ["fastapi>=0.100", "uvicorn>=0.20"]
19
+ django = ["django>=4.0"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://agentspend.co"
23
+ Repository = "https://github.com/agentspend/agentspend"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["agentspend"]