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