atp-protocol 1.2.0__py3-none-any.whl → 1.4.0__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.
- atp/__init__.py +9 -1
- atp/client.py +618 -0
- atp/config.py +26 -0
- atp/encryption.py +155 -0
- atp/middleware.py +444 -107
- atp/schemas.py +186 -0
- atp/settlement_client.py +608 -52
- atp_protocol-1.4.0.dist-info/METADATA +427 -0
- atp_protocol-1.4.0.dist-info/RECORD +11 -0
- atp_protocol-1.2.0.dist-info/METADATA +0 -401
- atp_protocol-1.2.0.dist-info/RECORD +0 -9
- {atp_protocol-1.2.0.dist-info → atp_protocol-1.4.0.dist-info}/LICENSE +0 -0
- {atp_protocol-1.2.0.dist-info → atp_protocol-1.4.0.dist-info}/WHEEL +0 -0
atp/middleware.py
CHANGED
|
@@ -23,8 +23,12 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
23
23
|
from starlette.types import ASGIApp
|
|
24
24
|
|
|
25
25
|
from atp import config
|
|
26
|
+
from atp.encryption import ResponseEncryptor
|
|
26
27
|
from atp.schemas import PaymentToken
|
|
27
|
-
from atp.settlement_client import
|
|
28
|
+
from atp.settlement_client import (
|
|
29
|
+
SettlementServiceClient,
|
|
30
|
+
SettlementServiceError,
|
|
31
|
+
)
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
@@ -32,17 +36,96 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
32
36
|
FastAPI middleware that automatically deducts payment from Solana wallets
|
|
33
37
|
based on token usage for configured endpoints.
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
This middleware intercepts responses from specified endpoints, extracts usage
|
|
40
|
+
information (input/output tokens), calculates payment amounts, and executes
|
|
41
|
+
Solana blockchain transactions to deduct payment before returning the response
|
|
42
|
+
to the client.
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
simple to use. Users can add their own API key handling layer if needed.
|
|
44
|
+
**Architecture & Design:**
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
-
|
|
46
|
+
The middleware delegates all settlement logic to the ATP Settlement Service,
|
|
47
|
+
ensuring immutable and centralized settlement operations. This design provides:
|
|
48
|
+
- Centralized parsing logic for multiple API formats (OpenAI, Anthropic, Google, etc.)
|
|
49
|
+
- Consistent payment calculation across all services
|
|
50
|
+
- Immutable settlement logic that cannot be modified by individual services
|
|
51
|
+
- Automatic handling of nested usage structures
|
|
52
|
+
|
|
53
|
+
**Request Flow:**
|
|
54
|
+
|
|
55
|
+
1. Request arrives at a configured endpoint
|
|
56
|
+
2. Middleware extracts wallet private key from request headers
|
|
57
|
+
3. Request is forwarded to the endpoint handler
|
|
58
|
+
4. Response is intercepted and parsed for usage data
|
|
59
|
+
5. Response is encrypted to prevent unauthorized access
|
|
60
|
+
6. Usage data is sent to settlement service for parsing and payment calculation
|
|
61
|
+
7. Payment transaction is executed on Solana blockchain
|
|
62
|
+
8. Response is decrypted only after payment confirmation
|
|
63
|
+
9. Response is returned to client with settlement details
|
|
64
|
+
|
|
65
|
+
**Security Features:**
|
|
66
|
+
|
|
67
|
+
- **Response Encryption**: Agent responses are encrypted before payment verification,
|
|
68
|
+
ensuring users cannot see output until payment is confirmed on-chain.
|
|
69
|
+
- **Payment Verification**: Responses are only decrypted after successful blockchain
|
|
70
|
+
transaction confirmation (status="paid" with valid transaction signature).
|
|
71
|
+
- **Error Handling**: Failed payments result in encrypted responses with error details,
|
|
72
|
+
preventing unauthorized access to agent output.
|
|
73
|
+
|
|
74
|
+
**Payment Splitting:**
|
|
75
|
+
|
|
76
|
+
Payments are automatically split between:
|
|
77
|
+
- **Treasury**: Receives the processing fee (configured via SWARMS_TREASURY_PUBKEY
|
|
78
|
+
on settlement service). Default fee percentage is 5%.
|
|
79
|
+
- **Recipient**: Receives the remainder (95% by default). This is the endpoint host's
|
|
80
|
+
wallet specified via `recipient_pubkey`.
|
|
81
|
+
|
|
82
|
+
**Usage Parsing:**
|
|
83
|
+
|
|
84
|
+
The middleware sends the entire response body to the settlement service's
|
|
85
|
+
`/v1/settlement/parse-usage` endpoint, which automatically handles:
|
|
86
|
+
- Multiple API formats (OpenAI, Anthropic, Google/Gemini, Cohere, etc.)
|
|
87
|
+
- Nested structures (usage.usage, meta.usage, statistics, etc.)
|
|
88
|
+
- Recursive parsing for deeply nested usage objects
|
|
89
|
+
- Normalization to standard format (input_tokens, output_tokens, total_tokens)
|
|
90
|
+
|
|
91
|
+
**Error Handling:**
|
|
92
|
+
|
|
93
|
+
The middleware provides two error handling modes:
|
|
94
|
+
- **fail_on_settlement_error=False** (default): Returns encrypted response with
|
|
95
|
+
settlement error details. Useful for debugging and graceful degradation.
|
|
96
|
+
- **fail_on_settlement_error=True**: Raises HTTPException when settlement fails.
|
|
97
|
+
Useful for strict payment requirements.
|
|
98
|
+
|
|
99
|
+
**Response Modifications:**
|
|
100
|
+
|
|
101
|
+
The middleware adds the following fields to responses:
|
|
102
|
+
- `atp_usage`: Normalized usage data (input_tokens, output_tokens, total_tokens)
|
|
103
|
+
- `atp_settlement`: Settlement details including transaction signature and payment breakdown
|
|
104
|
+
- `atp_settlement_status`: Status of settlement ("paid", "failed", etc.)
|
|
105
|
+
- `atp_message`: Informational message about response encryption status
|
|
106
|
+
|
|
107
|
+
**Attributes:**
|
|
108
|
+
|
|
109
|
+
allowed_endpoints (Set[str]): Set of endpoint paths to apply settlement to.
|
|
110
|
+
input_cost_per_million_usd (float): Cost per million input tokens in USD.
|
|
111
|
+
output_cost_per_million_usd (float): Cost per million output tokens in USD.
|
|
112
|
+
wallet_private_key_header (str): HTTP header name for wallet private key.
|
|
113
|
+
payment_token (PaymentToken): Token to use for payment (SOL or USDC).
|
|
114
|
+
skip_preflight (bool): Whether to skip preflight simulation for Solana transactions.
|
|
115
|
+
commitment (str): Solana commitment level (processed|confirmed|finalized).
|
|
116
|
+
fail_on_settlement_error (bool): Whether to raise exception on settlement failure.
|
|
117
|
+
settlement_service_client (SettlementServiceClient): Client for settlement service API.
|
|
118
|
+
encryptor (ResponseEncryptor): Encryptor for protecting agent responses.
|
|
119
|
+
|
|
120
|
+
**Example Usage:**
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from fastapi import FastAPI
|
|
124
|
+
from atp.middleware import ATPSettlementMiddleware
|
|
125
|
+
from atp.schemas import PaymentToken
|
|
126
|
+
|
|
127
|
+
app = FastAPI()
|
|
44
128
|
|
|
45
|
-
Usage:
|
|
46
129
|
app.add_middleware(
|
|
47
130
|
ATPSettlementMiddleware,
|
|
48
131
|
allowed_endpoints=["/v1/chat", "/v1/completions"],
|
|
@@ -50,9 +133,37 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
50
133
|
output_cost_per_million_usd=30.0,
|
|
51
134
|
wallet_private_key_header="x-wallet-private-key",
|
|
52
135
|
payment_token=PaymentToken.SOL,
|
|
53
|
-
recipient_pubkey="YourPublicKeyHere", # Required
|
|
54
|
-
#
|
|
136
|
+
recipient_pubkey="YourPublicKeyHere", # Required
|
|
137
|
+
settlement_service_url="https://facilitator.swarms.world", # Optional
|
|
138
|
+
settlement_timeout=300.0, # Optional, default 5 minutes
|
|
139
|
+
fail_on_settlement_error=False, # Optional, default False
|
|
55
140
|
)
|
|
141
|
+
|
|
142
|
+
@app.post("/v1/chat")
|
|
143
|
+
async def chat(request: dict):
|
|
144
|
+
# Your endpoint logic here
|
|
145
|
+
# Response should include usage data in any supported format
|
|
146
|
+
return {
|
|
147
|
+
"response": "Hello!",
|
|
148
|
+
"usage": {
|
|
149
|
+
"prompt_tokens": 10,
|
|
150
|
+
"completion_tokens": 20,
|
|
151
|
+
"total_tokens": 30
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Notes:**
|
|
157
|
+
|
|
158
|
+
- The middleware only processes successful responses (status_code < 400).
|
|
159
|
+
- If usage data cannot be parsed (no input_tokens, output_tokens, or total_tokens
|
|
160
|
+
in the response), the middleware raises HTTP 422 with an error message.
|
|
161
|
+
- Settlement operations may take time due to blockchain confirmation. Increase
|
|
162
|
+
`settlement_timeout` if you experience timeout errors even when payments succeed.
|
|
163
|
+
- The treasury pubkey is configured on the settlement service and cannot be
|
|
164
|
+
overridden by the middleware.
|
|
165
|
+
- Wallet private keys are passed directly via headers. For production, consider
|
|
166
|
+
adding an API key layer or using secure key management.
|
|
56
167
|
"""
|
|
57
168
|
|
|
58
169
|
def __init__(
|
|
@@ -67,8 +178,9 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
67
178
|
recipient_pubkey: Optional[str] = None,
|
|
68
179
|
skip_preflight: bool = False,
|
|
69
180
|
commitment: str = "confirmed",
|
|
70
|
-
require_wallet: bool = True,
|
|
71
181
|
settlement_service_url: Optional[str] = None,
|
|
182
|
+
fail_on_settlement_error: bool = False,
|
|
183
|
+
settlement_timeout: Optional[float] = None,
|
|
72
184
|
):
|
|
73
185
|
"""
|
|
74
186
|
Initialize the ATP settlement middleware.
|
|
@@ -90,10 +202,15 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
90
202
|
This wallet receives the main payment (after processing fee). Required.
|
|
91
203
|
skip_preflight: Whether to skip preflight simulation for Solana transactions.
|
|
92
204
|
commitment: Solana commitment level (processed|confirmed|finalized).
|
|
93
|
-
require_wallet: Whether to require wallet private key (if False, skips settlement when missing).
|
|
94
205
|
settlement_service_url: Base URL of the settlement service. If not provided, uses
|
|
95
206
|
ATP_SETTLEMENT_URL environment variable (default: http://localhost:8001).
|
|
96
207
|
The middleware always uses the settlement service for all settlement operations.
|
|
208
|
+
fail_on_settlement_error: If True, raises HTTPException when settlement fails (default: False).
|
|
209
|
+
If False, returns the response with settlement error info instead of failing the request.
|
|
210
|
+
settlement_timeout: Timeout in seconds for settlement service requests. User-configurable parameter.
|
|
211
|
+
Default: from ATP_SETTLEMENT_TIMEOUT env var or 300.0 (5 minutes). Settlement operations may
|
|
212
|
+
take longer due to blockchain confirmation times. Increase this value if you experience timeout
|
|
213
|
+
errors even when payments are successfully sent.
|
|
97
214
|
"""
|
|
98
215
|
super().__init__(app)
|
|
99
216
|
self.allowed_endpoints: Set[str] = set(allowed_endpoints)
|
|
@@ -107,45 +224,69 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
107
224
|
self._recipient_pubkey = recipient_pubkey
|
|
108
225
|
if not self._recipient_pubkey:
|
|
109
226
|
raise ValueError("recipient_pubkey must be provided")
|
|
110
|
-
# Treasury pubkey
|
|
111
|
-
|
|
112
|
-
if not self._treasury_pubkey:
|
|
113
|
-
raise ValueError(
|
|
114
|
-
"SWARMS_TREASURY_PUBKEY must be set in configuration"
|
|
115
|
-
)
|
|
227
|
+
# Note: Treasury pubkey is automatically set from SWARMS_TREASURY_PUBKEY
|
|
228
|
+
# environment variable on the settlement service and cannot be overridden
|
|
116
229
|
self.skip_preflight = skip_preflight
|
|
117
230
|
self.commitment = commitment
|
|
118
|
-
self.
|
|
231
|
+
self.fail_on_settlement_error = fail_on_settlement_error
|
|
119
232
|
# Always use settlement service - initialize client with config value or provided URL
|
|
120
233
|
service_url = (
|
|
121
234
|
settlement_service_url or config.ATP_SETTLEMENT_URL
|
|
122
235
|
)
|
|
123
236
|
self.settlement_service_client = SettlementServiceClient(
|
|
124
|
-
base_url=service_url
|
|
237
|
+
base_url=service_url,
|
|
238
|
+
timeout=settlement_timeout,
|
|
125
239
|
)
|
|
240
|
+
# Initialize encryptor for protecting agent responses
|
|
241
|
+
self.encryptor = ResponseEncryptor()
|
|
126
242
|
|
|
127
243
|
def _should_process(self, path: str) -> bool:
|
|
128
|
-
"""
|
|
244
|
+
"""
|
|
245
|
+
Check if the request path should be processed by this middleware.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
path: The request URL path.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
True if the path is in the allowed endpoints set, False otherwise.
|
|
252
|
+
"""
|
|
129
253
|
return path in self.allowed_endpoints
|
|
130
254
|
|
|
131
255
|
def _extract_wallet_private_key(
|
|
132
256
|
self, request: Request
|
|
133
257
|
) -> Optional[str]:
|
|
134
|
-
"""
|
|
258
|
+
"""
|
|
259
|
+
Extract wallet private key from request headers.
|
|
260
|
+
|
|
261
|
+
The private key should be provided in the header specified by
|
|
262
|
+
`wallet_private_key_header` (default: "x-wallet-private-key").
|
|
263
|
+
The key can be in JSON array format (e.g., "[1,2,3,...]") or
|
|
264
|
+
base58 string format.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
request: The incoming HTTP request.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
The wallet private key string if found, None otherwise.
|
|
271
|
+
"""
|
|
135
272
|
return request.headers.get(self.wallet_private_key_header)
|
|
136
273
|
|
|
137
|
-
async def
|
|
274
|
+
async def _parse_usage_from_response(
|
|
138
275
|
self, response_body: bytes
|
|
139
276
|
) -> Optional[Dict[str, Any]]:
|
|
140
277
|
"""
|
|
141
|
-
|
|
278
|
+
Parse usage information from response body using the settlement service.
|
|
279
|
+
|
|
280
|
+
Delegates all usage parsing logic to the settlement service's parse-usage
|
|
281
|
+
endpoint, which handles multiple formats and nested structures automatically.
|
|
282
|
+
This centralizes all parsing logic in the immutable settlement service.
|
|
142
283
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
2. Try nested structures with common usage key names (usage, token_usage, etc.)
|
|
284
|
+
Args:
|
|
285
|
+
response_body: Raw response body bytes.
|
|
146
286
|
|
|
147
|
-
|
|
148
|
-
|
|
287
|
+
Returns:
|
|
288
|
+
Parsed usage dict with normalized keys (input_tokens, output_tokens, total_tokens),
|
|
289
|
+
or None if parsing fails or no usage data is found.
|
|
149
290
|
"""
|
|
150
291
|
try:
|
|
151
292
|
body_str = response_body.decode("utf-8")
|
|
@@ -153,48 +294,19 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
153
294
|
return None
|
|
154
295
|
data = json.loads(body_str)
|
|
155
296
|
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
]
|
|
170
|
-
if any(key in data for key in usage_keys):
|
|
171
|
-
return data
|
|
172
|
-
|
|
173
|
-
# Strategy 2: Try nested structures
|
|
174
|
-
# Check for usage nested in common locations
|
|
175
|
-
for nested_key in [
|
|
176
|
-
"usage",
|
|
177
|
-
"token_usage",
|
|
178
|
-
"tokens",
|
|
179
|
-
"statistics",
|
|
180
|
-
"meta",
|
|
181
|
-
]:
|
|
182
|
-
if nested_key in data and isinstance(
|
|
183
|
-
data[nested_key], dict
|
|
184
|
-
):
|
|
185
|
-
nested_usage = data[nested_key]
|
|
186
|
-
# Check if it looks like usage data
|
|
187
|
-
if any(
|
|
188
|
-
key in nested_usage
|
|
189
|
-
for key in [
|
|
190
|
-
"input_tokens",
|
|
191
|
-
"output_tokens",
|
|
192
|
-
"prompt_tokens",
|
|
193
|
-
"completion_tokens",
|
|
194
|
-
"tokens",
|
|
195
|
-
]
|
|
196
|
-
):
|
|
197
|
-
return nested_usage
|
|
297
|
+
# Send entire response body to settlement service for parsing
|
|
298
|
+
# The service handles all format detection and nested structure traversal
|
|
299
|
+
parsed_usage = await self.settlement_service_client.parse_usage(
|
|
300
|
+
usage_data=data
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Check if we got valid token counts
|
|
304
|
+
if (
|
|
305
|
+
parsed_usage.get("input_tokens") is not None
|
|
306
|
+
or parsed_usage.get("output_tokens") is not None
|
|
307
|
+
or parsed_usage.get("total_tokens") is not None
|
|
308
|
+
):
|
|
309
|
+
return parsed_usage
|
|
198
310
|
|
|
199
311
|
return None
|
|
200
312
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
@@ -202,11 +314,79 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
202
314
|
f"Failed to parse response body for usage: {e}"
|
|
203
315
|
)
|
|
204
316
|
return None
|
|
317
|
+
except SettlementServiceError as e:
|
|
318
|
+
# If settlement service can't parse usage, log and return None
|
|
319
|
+
logger.debug(
|
|
320
|
+
f"Settlement service could not parse usage from response: {e}"
|
|
321
|
+
)
|
|
322
|
+
return None
|
|
323
|
+
except Exception as e:
|
|
324
|
+
logger.debug(
|
|
325
|
+
f"Unexpected error parsing usage: {e}"
|
|
326
|
+
)
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def log_to_marketplace(self):
|
|
330
|
+
"""
|
|
331
|
+
Log the request to the marketplace and make it discoverable.
|
|
332
|
+
|
|
333
|
+
This is a placeholder method for future marketplace integration.
|
|
334
|
+
Currently does nothing but can be extended to log requests to a
|
|
335
|
+
marketplace service for discovery and analytics.
|
|
336
|
+
|
|
337
|
+
Note: This method is not currently called by the middleware.
|
|
338
|
+
"""
|
|
339
|
+
pass
|
|
205
340
|
|
|
206
341
|
async def dispatch(
|
|
207
342
|
self, request: Request, call_next: Callable
|
|
208
343
|
) -> Response:
|
|
209
|
-
"""
|
|
344
|
+
"""
|
|
345
|
+
Process the request and apply settlement if applicable.
|
|
346
|
+
|
|
347
|
+
This is the main middleware entry point that intercepts requests and responses.
|
|
348
|
+
It handles the complete settlement flow including usage parsing, payment execution,
|
|
349
|
+
and response encryption/decryption.
|
|
350
|
+
|
|
351
|
+
**Flow:**
|
|
352
|
+
|
|
353
|
+
1. Check if request path is in allowed endpoints
|
|
354
|
+
2. Extract wallet private key from headers (if required)
|
|
355
|
+
3. Forward request to endpoint handler
|
|
356
|
+
4. Intercept response and parse usage data via settlement service
|
|
357
|
+
5. Encrypt response to prevent unauthorized access
|
|
358
|
+
6. Execute payment via settlement service
|
|
359
|
+
7. Decrypt response only after payment confirmation
|
|
360
|
+
8. Return response with settlement metadata
|
|
361
|
+
|
|
362
|
+
**Args:**
|
|
363
|
+
request: The incoming HTTP request.
|
|
364
|
+
call_next: Callable to invoke the next middleware/endpoint handler.
|
|
365
|
+
|
|
366
|
+
**Returns:**
|
|
367
|
+
Response with settlement metadata added. Response body is encrypted until
|
|
368
|
+
payment is confirmed. If payment fails, response remains encrypted with
|
|
369
|
+
error details.
|
|
370
|
+
|
|
371
|
+
**Raises:**
|
|
372
|
+
HTTPException: If wallet is required but missing (402 Payment Required), or if
|
|
373
|
+
`fail_on_settlement_error=True` and settlement fails.
|
|
374
|
+
|
|
375
|
+
**Response Modifications:**
|
|
376
|
+
- Adds `atp_usage` field with normalized token counts
|
|
377
|
+
- Adds `atp_settlement` field with payment details
|
|
378
|
+
- Adds `atp_settlement_status` field with payment status
|
|
379
|
+
- Adds `atp_message` field with encryption status message
|
|
380
|
+
- Removes `Content-Length` and `Content-Encoding` headers (recalculated)
|
|
381
|
+
|
|
382
|
+
**Error Scenarios:**
|
|
383
|
+
- Missing wallet (if required): Returns 402 Payment Required
|
|
384
|
+
- No usage data: Raises 422 with message that endpoint must output
|
|
385
|
+
input_tokens, output_tokens, or total_tokens (or equivalent usage fields)
|
|
386
|
+
- Encryption failure: Returns 500 with error (response not exposed)
|
|
387
|
+
- Settlement failure: Returns encrypted response with error details
|
|
388
|
+
(or raises exception if `fail_on_settlement_error=True`)
|
|
389
|
+
"""
|
|
210
390
|
path = request.url.path
|
|
211
391
|
|
|
212
392
|
# Skip if not in allowed endpoints
|
|
@@ -215,14 +395,12 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
215
395
|
|
|
216
396
|
# Extract wallet private key
|
|
217
397
|
private_key = self._extract_wallet_private_key(request)
|
|
398
|
+
|
|
218
399
|
if not private_key:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
)
|
|
224
|
-
# If wallet not required, skip settlement
|
|
225
|
-
return await call_next(request)
|
|
400
|
+
raise HTTPException(
|
|
401
|
+
status_code=402,
|
|
402
|
+
detail="Payment required. Missing wallet private key in header. Please provide a valid wallet private key and ensure payment succeeds. The header should be x-wallet-private-key.",
|
|
403
|
+
)
|
|
226
404
|
|
|
227
405
|
# Execute the endpoint
|
|
228
406
|
response = await call_next(request)
|
|
@@ -231,26 +409,67 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
231
409
|
if response.status_code >= 400:
|
|
232
410
|
return response
|
|
233
411
|
|
|
234
|
-
#
|
|
412
|
+
# Parse usage from response using settlement service
|
|
235
413
|
response_body = b""
|
|
236
414
|
async for chunk in response.body_iterator:
|
|
237
415
|
response_body += chunk
|
|
238
416
|
|
|
239
|
-
usage = await self.
|
|
417
|
+
usage = await self._parse_usage_from_response(response_body)
|
|
240
418
|
|
|
241
419
|
if not usage:
|
|
242
420
|
logger.warning(
|
|
243
|
-
f"No usage data found in response for {path}.
|
|
421
|
+
f"No usage data found in response for {path}. "
|
|
422
|
+
"Settlement service could not parse usage from response body."
|
|
423
|
+
)
|
|
424
|
+
raise HTTPException(
|
|
425
|
+
status_code=422,
|
|
426
|
+
detail=(
|
|
427
|
+
"Endpoint must include token usage in the response. "
|
|
428
|
+
"Response must contain at least one of: input_tokens, output_tokens, or total_tokens "
|
|
429
|
+
"(or equivalent fields such as prompt_tokens/completion_tokens in a usage object). "
|
|
430
|
+
f"No parseable usage data found for {path}."
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Encrypt the agent response before payment verification
|
|
435
|
+
# This ensures users cannot see the output until payment is confirmed
|
|
436
|
+
try:
|
|
437
|
+
response_data = json.loads(response_body.decode("utf-8"))
|
|
438
|
+
# Encrypt sensitive output fields (output, response, result, message)
|
|
439
|
+
encrypted_response_data = self.encryptor.encrypt_response_data(
|
|
440
|
+
response_data
|
|
441
|
+
)
|
|
442
|
+
# Store original encrypted data for later decryption
|
|
443
|
+
original_encrypted_data = encrypted_response_data.copy()
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.error(
|
|
446
|
+
f"Failed to encrypt response: {e}. "
|
|
447
|
+
"This is a security issue - cannot proceed without encryption.",
|
|
448
|
+
exc_info=True,
|
|
244
449
|
)
|
|
245
|
-
#
|
|
450
|
+
# If encryption fails, we cannot securely proceed
|
|
451
|
+
# Return error without exposing agent output
|
|
452
|
+
error_response = {
|
|
453
|
+
"error": "Internal server error",
|
|
454
|
+
"message": "Failed to encrypt response. Please contact support.",
|
|
455
|
+
"atp_usage": usage,
|
|
456
|
+
}
|
|
457
|
+
new_headers = dict(response.headers)
|
|
458
|
+
new_headers.pop("content-length", None)
|
|
459
|
+
new_headers.pop("Content-Length", None)
|
|
460
|
+
new_headers.pop("content-encoding", None)
|
|
461
|
+
new_headers.pop("Content-Encoding", None)
|
|
246
462
|
return Response(
|
|
247
|
-
content=
|
|
248
|
-
status_code=
|
|
249
|
-
headers=
|
|
250
|
-
media_type=
|
|
463
|
+
content=json.dumps(error_response).encode("utf-8"),
|
|
464
|
+
status_code=500,
|
|
465
|
+
headers=new_headers,
|
|
466
|
+
media_type="application/json",
|
|
251
467
|
)
|
|
252
468
|
|
|
253
469
|
# Calculate and deduct payment via settlement service
|
|
470
|
+
payment_result = None
|
|
471
|
+
settlement_error = None
|
|
472
|
+
|
|
254
473
|
try:
|
|
255
474
|
payment_result = await self.settlement_service_client.settle(
|
|
256
475
|
private_key=private_key,
|
|
@@ -259,38 +478,156 @@ class ATPSettlementMiddleware(BaseHTTPMiddleware):
|
|
|
259
478
|
output_cost_per_million_usd=self.output_cost_per_million_usd,
|
|
260
479
|
recipient_pubkey=self._recipient_pubkey,
|
|
261
480
|
payment_token=self.payment_token.value,
|
|
262
|
-
treasury_pubkey=self._treasury_pubkey,
|
|
263
481
|
skip_preflight=self.skip_preflight,
|
|
264
482
|
commitment=self.commitment,
|
|
265
483
|
)
|
|
484
|
+
except SettlementServiceError as e:
|
|
485
|
+
# Handle settlement service errors with detailed information
|
|
486
|
+
# The error already contains extracted details from the service response
|
|
487
|
+
error_dict = e.to_dict()
|
|
488
|
+
|
|
489
|
+
# Determine if this is a client error (4xx) or server error (5xx)
|
|
490
|
+
is_client_error = e.status_code and 400 <= e.status_code < 500
|
|
491
|
+
|
|
492
|
+
if self.fail_on_settlement_error:
|
|
493
|
+
# Raise HTTPException with appropriate status code
|
|
494
|
+
status_code = e.status_code or 500
|
|
495
|
+
detail = e.error_detail or str(e)
|
|
496
|
+
raise HTTPException(
|
|
497
|
+
status_code=status_code,
|
|
498
|
+
detail=detail,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# Store error info to include in response
|
|
502
|
+
settlement_error = error_dict.copy()
|
|
503
|
+
settlement_error["type"] = e.error_type or error_dict.get("type", "Settlement error")
|
|
504
|
+
|
|
505
|
+
# Log with appropriate level based on error type
|
|
506
|
+
if is_client_error:
|
|
507
|
+
logger.warning(
|
|
508
|
+
f"Settlement failed (client error {e.status_code}): {e.error_detail or str(e)}"
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
logger.error(
|
|
512
|
+
f"Settlement failed (server error {e.status_code or 'unknown'}): {e.error_detail or str(e)}"
|
|
513
|
+
)
|
|
266
514
|
except HTTPException:
|
|
267
|
-
raise
|
|
515
|
+
# Re-raise HTTPExceptions (these are intentional errors like 401, 403, etc.)
|
|
516
|
+
if self.fail_on_settlement_error:
|
|
517
|
+
raise
|
|
518
|
+
settlement_error = {
|
|
519
|
+
"error": "Settlement failed",
|
|
520
|
+
"status_code": 500,
|
|
521
|
+
"detail": "Settlement service returned an error",
|
|
522
|
+
}
|
|
523
|
+
logger.warning("Settlement failed with HTTPException, but continuing with response")
|
|
268
524
|
except Exception as e:
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
525
|
+
# Handle unexpected errors
|
|
526
|
+
logger.error(f"Unexpected settlement error: {e}", exc_info=True)
|
|
527
|
+
if self.fail_on_settlement_error:
|
|
528
|
+
raise HTTPException(
|
|
529
|
+
status_code=500,
|
|
530
|
+
detail=f"Settlement failed: {str(e)}",
|
|
531
|
+
)
|
|
532
|
+
# Store error info to include in response
|
|
533
|
+
settlement_error = {
|
|
534
|
+
"error": "Settlement failed",
|
|
535
|
+
"message": str(e),
|
|
536
|
+
"type": type(e).__name__,
|
|
537
|
+
}
|
|
538
|
+
logger.warning(
|
|
539
|
+
f"Settlement failed but continuing with response: {e}"
|
|
273
540
|
)
|
|
274
541
|
|
|
275
|
-
#
|
|
542
|
+
# Process payment result and decrypt response only if payment succeeded
|
|
276
543
|
try:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
544
|
+
# Start with the encrypted response data
|
|
545
|
+
final_response_data = original_encrypted_data.copy()
|
|
546
|
+
final_response_data["atp_usage"] = usage
|
|
547
|
+
|
|
548
|
+
# Check if payment was successful
|
|
549
|
+
payment_succeeded = False
|
|
550
|
+
if payment_result:
|
|
551
|
+
# Check if payment status is "paid"
|
|
552
|
+
payment_status = payment_result.get("status", "").lower()
|
|
553
|
+
# Also check for transaction signature as additional confirmation
|
|
554
|
+
has_transaction = bool(
|
|
555
|
+
payment_result.get("transaction_signature")
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if payment_status == "paid" and has_transaction:
|
|
559
|
+
payment_succeeded = True
|
|
560
|
+
# Decrypt the response now that payment is confirmed
|
|
561
|
+
final_response_data = self.encryptor.decrypt_response_data(
|
|
562
|
+
final_response_data
|
|
563
|
+
)
|
|
564
|
+
logger.info(
|
|
565
|
+
f"Payment confirmed (tx: {payment_result.get('transaction_signature', 'N/A')[:16]}...), "
|
|
566
|
+
"response decrypted"
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
logger.warning(
|
|
570
|
+
f"Payment not confirmed. Status: '{payment_status}', "
|
|
571
|
+
f"Has transaction: {has_transaction}. "
|
|
572
|
+
"Response will remain encrypted."
|
|
573
|
+
)
|
|
574
|
+
final_response_data["atp_settlement"] = payment_result
|
|
575
|
+
elif settlement_error:
|
|
576
|
+
# Payment failed - keep response encrypted
|
|
577
|
+
final_response_data["atp_settlement"] = settlement_error
|
|
578
|
+
final_response_data["atp_settlement_status"] = "failed"
|
|
579
|
+
logger.warning(
|
|
580
|
+
"Payment failed, response remains encrypted. "
|
|
581
|
+
"User cannot see agent output."
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# If payment didn't succeed, add a message indicating the response is encrypted
|
|
585
|
+
if not payment_succeeded:
|
|
586
|
+
final_response_data["atp_message"] = (
|
|
587
|
+
"Agent response is encrypted. Payment required to decrypt. "
|
|
588
|
+
"Please provide a valid wallet private key and ensure payment succeeds."
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
response_body = json.dumps(final_response_data).encode("utf-8")
|
|
285
592
|
except Exception as e:
|
|
286
|
-
logger.
|
|
287
|
-
f"Failed to
|
|
593
|
+
logger.error(
|
|
594
|
+
f"Failed to process payment and decrypt response: {e}",
|
|
595
|
+
exc_info=True,
|
|
288
596
|
)
|
|
597
|
+
# On error, return encrypted response with error info
|
|
598
|
+
try:
|
|
599
|
+
error_response = original_encrypted_data.copy()
|
|
600
|
+
error_response["atp_usage"] = usage
|
|
601
|
+
error_response["atp_settlement_error"] = {
|
|
602
|
+
"error": "Failed to process payment",
|
|
603
|
+
"message": str(e),
|
|
604
|
+
}
|
|
605
|
+
error_response["atp_message"] = (
|
|
606
|
+
"Agent response is encrypted. Payment processing failed."
|
|
607
|
+
)
|
|
608
|
+
response_body = json.dumps(error_response).encode("utf-8")
|
|
609
|
+
except Exception as e2:
|
|
610
|
+
logger.error(
|
|
611
|
+
f"Failed to create error response: {e2}", exc_info=True
|
|
612
|
+
)
|
|
613
|
+
# Last resort: return original encrypted response
|
|
614
|
+
response_body = json.dumps(original_encrypted_data).encode(
|
|
615
|
+
"utf-8"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Create new headers without Content-Length since we modified the body
|
|
619
|
+
# Starlette/FastAPI will recalculate it automatically
|
|
620
|
+
new_headers = dict(response.headers)
|
|
621
|
+
# Remove Content-Length and Content-Encoding headers as they're no longer valid
|
|
622
|
+
new_headers.pop("content-length", None)
|
|
623
|
+
new_headers.pop("Content-Length", None)
|
|
624
|
+
new_headers.pop("content-encoding", None)
|
|
625
|
+
new_headers.pop("Content-Encoding", None)
|
|
289
626
|
|
|
290
627
|
return Response(
|
|
291
628
|
content=response_body,
|
|
292
629
|
status_code=response.status_code,
|
|
293
|
-
headers=
|
|
630
|
+
headers=new_headers,
|
|
294
631
|
media_type=response.media_type,
|
|
295
632
|
)
|
|
296
633
|
|
|
@@ -310,7 +647,7 @@ def create_settlement_middleware(
|
|
|
310
647
|
input_cost_per_million_usd=10.0,
|
|
311
648
|
output_cost_per_million_usd=30.0,
|
|
312
649
|
wallet_private_key_header="x-wallet-private-key",
|
|
313
|
-
recipient_pubkey="YourPublicKeyHere", #
|
|
650
|
+
recipient_pubkey="YourPublicKeyHere", # Required: recipient wallet public key
|
|
314
651
|
)
|
|
315
652
|
app.add_middleware(middleware)
|
|
316
653
|
"""
|