xache 5.0.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.
- xache/__init__.py +142 -0
- xache/client.py +331 -0
- xache/crypto/__init__.py +17 -0
- xache/crypto/signing.py +244 -0
- xache/crypto/wallet.py +240 -0
- xache/errors.py +184 -0
- xache/payment/__init__.py +5 -0
- xache/payment/handler.py +244 -0
- xache/services/__init__.py +29 -0
- xache/services/budget.py +285 -0
- xache/services/collective.py +174 -0
- xache/services/extraction.py +173 -0
- xache/services/facilitator.py +296 -0
- xache/services/identity.py +415 -0
- xache/services/memory.py +401 -0
- xache/services/owner.py +293 -0
- xache/services/receipts.py +202 -0
- xache/services/reputation.py +274 -0
- xache/services/royalty.py +290 -0
- xache/services/sessions.py +268 -0
- xache/services/workspaces.py +447 -0
- xache/types.py +399 -0
- xache/utils/__init__.py +5 -0
- xache/utils/cache.py +214 -0
- xache/utils/http.py +209 -0
- xache/utils/retry.py +101 -0
- xache-5.0.0.dist-info/METADATA +337 -0
- xache-5.0.0.dist-info/RECORD +30 -0
- xache-5.0.0.dist-info/WHEEL +5 -0
- xache-5.0.0.dist-info/top_level.txt +1 -0
xache/payment/handler.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Payment Handler for 402 Payment Flow per LLD §2.3
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Dict, Optional, Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PaymentHandler:
|
|
10
|
+
"""Payment handler for 402 payment flow"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
13
|
+
self.config = config or {}
|
|
14
|
+
self.pending_payments: Dict[str, Dict[str, Any]] = {}
|
|
15
|
+
|
|
16
|
+
async def handle_payment(
|
|
17
|
+
self,
|
|
18
|
+
challenge_id: str,
|
|
19
|
+
amount: str,
|
|
20
|
+
chain_hint: str,
|
|
21
|
+
pay_to: str,
|
|
22
|
+
description: str,
|
|
23
|
+
) -> Dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
Handle 402 payment challenge
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
challenge_id: Payment challenge ID
|
|
29
|
+
amount: Amount in USD
|
|
30
|
+
chain_hint: Blockchain hint (solana or base)
|
|
31
|
+
pay_to: Payment recipient address
|
|
32
|
+
description: Payment description
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Payment result with success status
|
|
36
|
+
"""
|
|
37
|
+
# Check if payment already processed
|
|
38
|
+
if challenge_id in self.pending_payments:
|
|
39
|
+
return self.pending_payments[challenge_id]
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# Handle payment based on provider type
|
|
43
|
+
provider_type = self.config.get("type", "manual")
|
|
44
|
+
|
|
45
|
+
if provider_type == "manual":
|
|
46
|
+
result = await self._handle_manual_payment(
|
|
47
|
+
challenge_id, amount, chain_hint, pay_to, description
|
|
48
|
+
)
|
|
49
|
+
elif provider_type == "coinbase-commerce":
|
|
50
|
+
result = await self._handle_coinbase_payment(
|
|
51
|
+
challenge_id, amount, chain_hint, pay_to, description
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
result = {
|
|
55
|
+
"success": False,
|
|
56
|
+
"error": f"Unknown payment provider: {provider_type}",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Cache result
|
|
60
|
+
if result["success"]:
|
|
61
|
+
self.pending_payments[challenge_id] = result
|
|
62
|
+
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return {"success": False, "error": str(e)}
|
|
67
|
+
|
|
68
|
+
async def _handle_manual_payment(
|
|
69
|
+
self,
|
|
70
|
+
challenge_id: str,
|
|
71
|
+
amount: str,
|
|
72
|
+
chain_hint: str,
|
|
73
|
+
pay_to: str,
|
|
74
|
+
description: str,
|
|
75
|
+
) -> Dict[str, Any]:
|
|
76
|
+
"""Handle manual payment"""
|
|
77
|
+
print("\n╔════════════════════════════════════════════════════════════╗")
|
|
78
|
+
print("║ PAYMENT REQUIRED ║")
|
|
79
|
+
print("╚════════════════════════════════════════════════════════════╝")
|
|
80
|
+
print("")
|
|
81
|
+
print(f"Description: {description}")
|
|
82
|
+
print(f"Amount: {amount} USD")
|
|
83
|
+
print(f"Chain: {chain_hint}")
|
|
84
|
+
print(f"Pay To: {pay_to}")
|
|
85
|
+
print(f"Challenge: {challenge_id}")
|
|
86
|
+
print("")
|
|
87
|
+
print("Please complete the payment manually and then press Enter to continue...")
|
|
88
|
+
print("(Or press Ctrl+C to cancel)")
|
|
89
|
+
print("")
|
|
90
|
+
|
|
91
|
+
# Wait for user confirmation
|
|
92
|
+
await asyncio.sleep(2)
|
|
93
|
+
|
|
94
|
+
# Return success assuming user completed payment
|
|
95
|
+
# The server will verify the actual blockchain transaction
|
|
96
|
+
return {
|
|
97
|
+
"success": True,
|
|
98
|
+
"transaction_hash": f"manual-{challenge_id}",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async def _handle_coinbase_payment(
|
|
102
|
+
self,
|
|
103
|
+
challenge_id: str,
|
|
104
|
+
amount: str,
|
|
105
|
+
chain_hint: str,
|
|
106
|
+
pay_to: str,
|
|
107
|
+
description: str,
|
|
108
|
+
) -> Dict[str, Any]:
|
|
109
|
+
"""Handle Coinbase Commerce payment"""
|
|
110
|
+
import aiohttp
|
|
111
|
+
|
|
112
|
+
api_key = self.config.get("api_key")
|
|
113
|
+
if not api_key:
|
|
114
|
+
return {"success": False, "error": "Coinbase Commerce API key not configured"}
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
# Create charge via Coinbase Commerce API
|
|
118
|
+
charge = await self._create_coinbase_charge(
|
|
119
|
+
challenge_id, amount, description, chain_hint
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
print("\n╔════════════════════════════════════════════════════════════╗")
|
|
123
|
+
print("║ PAYMENT REQUIRED ║")
|
|
124
|
+
print("╚════════════════════════════════════════════════════════════╝")
|
|
125
|
+
print("")
|
|
126
|
+
print(f"Description: {description}")
|
|
127
|
+
print(f"Amount: {amount} USD")
|
|
128
|
+
print(f"Chain: {chain_hint}")
|
|
129
|
+
print("")
|
|
130
|
+
print(f"Payment URL: {charge['hosted_url']}")
|
|
131
|
+
print("")
|
|
132
|
+
print("Please complete the payment and wait for confirmation...")
|
|
133
|
+
print("")
|
|
134
|
+
|
|
135
|
+
# Poll for payment confirmation
|
|
136
|
+
confirmed = await self._poll_coinbase_payment(charge["id"])
|
|
137
|
+
|
|
138
|
+
if confirmed:
|
|
139
|
+
return {
|
|
140
|
+
"success": True,
|
|
141
|
+
"transaction_hash": charge["id"],
|
|
142
|
+
}
|
|
143
|
+
else:
|
|
144
|
+
return {
|
|
145
|
+
"success": False,
|
|
146
|
+
"error": "Payment not confirmed",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
except Exception as e:
|
|
150
|
+
return {
|
|
151
|
+
"success": False,
|
|
152
|
+
"error": f"Coinbase Commerce error: {str(e)}",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async def _create_coinbase_charge(
|
|
156
|
+
self, challenge_id: str, amount: str, description: str, chain_hint: str
|
|
157
|
+
) -> Dict[str, Any]:
|
|
158
|
+
"""Create Coinbase Commerce charge"""
|
|
159
|
+
import aiohttp
|
|
160
|
+
|
|
161
|
+
api_key = self.config.get("api_key")
|
|
162
|
+
|
|
163
|
+
async with aiohttp.ClientSession() as session:
|
|
164
|
+
async with session.post(
|
|
165
|
+
"https://api.commerce.coinbase.com/charges",
|
|
166
|
+
headers={
|
|
167
|
+
"Content-Type": "application/json",
|
|
168
|
+
"X-CC-Api-Key": api_key,
|
|
169
|
+
"X-CC-Version": "2018-03-22",
|
|
170
|
+
},
|
|
171
|
+
json={
|
|
172
|
+
"name": description,
|
|
173
|
+
"description": f"Xache Protocol payment - Challenge: {challenge_id}",
|
|
174
|
+
"pricing_type": "fixed_price",
|
|
175
|
+
"local_price": {
|
|
176
|
+
"amount": amount,
|
|
177
|
+
"currency": "USD",
|
|
178
|
+
},
|
|
179
|
+
"metadata": {
|
|
180
|
+
"challengeId": challenge_id,
|
|
181
|
+
"chainHint": chain_hint,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
) as response:
|
|
185
|
+
if response.status != 200 and response.status != 201:
|
|
186
|
+
error_text = await response.text()
|
|
187
|
+
raise Exception(f"Coinbase API error: {error_text}")
|
|
188
|
+
|
|
189
|
+
data = await response.json()
|
|
190
|
+
return data["data"]
|
|
191
|
+
|
|
192
|
+
async def _poll_coinbase_payment(
|
|
193
|
+
self, charge_id: str, max_attempts: int = 60
|
|
194
|
+
) -> bool:
|
|
195
|
+
"""Poll Coinbase Commerce for payment confirmation"""
|
|
196
|
+
import aiohttp
|
|
197
|
+
|
|
198
|
+
api_key = self.config.get("api_key")
|
|
199
|
+
|
|
200
|
+
for _ in range(max_attempts):
|
|
201
|
+
try:
|
|
202
|
+
async with aiohttp.ClientSession() as session:
|
|
203
|
+
async with session.get(
|
|
204
|
+
f"https://api.commerce.coinbase.com/charges/{charge_id}",
|
|
205
|
+
headers={
|
|
206
|
+
"X-CC-Api-Key": api_key,
|
|
207
|
+
"X-CC-Version": "2018-03-22",
|
|
208
|
+
},
|
|
209
|
+
) as response:
|
|
210
|
+
if response.status != 200:
|
|
211
|
+
await asyncio.sleep(5)
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
data = await response.json()
|
|
215
|
+
timeline = data["data"]["timeline"]
|
|
216
|
+
|
|
217
|
+
if timeline:
|
|
218
|
+
status = timeline[-1].get("status")
|
|
219
|
+
|
|
220
|
+
if status in ["COMPLETED", "RESOLVED"]:
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
if status in ["EXPIRED", "CANCELED"]:
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
# Wait 5 seconds before next poll
|
|
227
|
+
await asyncio.sleep(5)
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
print(f"Error polling payment status: {e}")
|
|
231
|
+
await asyncio.sleep(5)
|
|
232
|
+
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
def mark_payment_complete(self, challenge_id: str, transaction_hash: str):
|
|
236
|
+
"""Mark payment as completed (for testing)"""
|
|
237
|
+
self.pending_payments[challenge_id] = {
|
|
238
|
+
"success": True,
|
|
239
|
+
"transaction_hash": transaction_hash,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
def clear_cache(self):
|
|
243
|
+
"""Clear payment cache"""
|
|
244
|
+
self.pending_payments.clear()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Service modules"""
|
|
2
|
+
|
|
3
|
+
from .identity import IdentityService
|
|
4
|
+
from .memory import MemoryService
|
|
5
|
+
from .collective import CollectiveService
|
|
6
|
+
from .budget import BudgetService
|
|
7
|
+
from .receipts import ReceiptsService
|
|
8
|
+
from .reputation import ReputationService
|
|
9
|
+
from .extraction import ExtractionService
|
|
10
|
+
from .facilitator import FacilitatorService
|
|
11
|
+
from .sessions import SessionService
|
|
12
|
+
from .royalty import RoyaltyService
|
|
13
|
+
from .workspaces import WorkspaceService
|
|
14
|
+
from .owner import OwnerService
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"IdentityService",
|
|
18
|
+
"MemoryService",
|
|
19
|
+
"CollectiveService",
|
|
20
|
+
"BudgetService",
|
|
21
|
+
"ReceiptsService",
|
|
22
|
+
"ReputationService",
|
|
23
|
+
"ExtractionService",
|
|
24
|
+
"FacilitatorService",
|
|
25
|
+
"SessionService",
|
|
26
|
+
"RoyaltyService",
|
|
27
|
+
"WorkspaceService",
|
|
28
|
+
"OwnerService",
|
|
29
|
+
]
|
xache/services/budget.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Budget Service - Manage monthly spending budgets with alert support per HLD §2.2 Budget Guardian"""
|
|
2
|
+
|
|
3
|
+
from typing import List, Callable, Union
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from ..types import BudgetStatus, BudgetAlert, BudgetAlertLevel, BudgetAlertHandler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BudgetService:
|
|
9
|
+
"""Budget service for spending management with proactive alerts"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, client):
|
|
12
|
+
self.client = client
|
|
13
|
+
self._alert_handlers: List[BudgetAlertHandler] = []
|
|
14
|
+
self._last_alerted_threshold = 0
|
|
15
|
+
self.DEFAULT_THRESHOLDS = [50, 80, 100] # Per HLD §2.2
|
|
16
|
+
|
|
17
|
+
async def get_status(self) -> BudgetStatus:
|
|
18
|
+
"""
|
|
19
|
+
Get current budget status and check alert thresholds (free)
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
```python
|
|
23
|
+
budget = await client.budget.get_status()
|
|
24
|
+
print(f"Limit: ${budget.limit_cents / 100}")
|
|
25
|
+
print(f"Spent: ${budget.spent_cents / 100}")
|
|
26
|
+
print(f"Usage: {budget.percentage_used:.1f}%")
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
response = await self.client.request("GET", "/v1/budget")
|
|
30
|
+
|
|
31
|
+
if not response.success or not response.data:
|
|
32
|
+
raise Exception("Failed to get budget status")
|
|
33
|
+
|
|
34
|
+
data = response.data
|
|
35
|
+
status = BudgetStatus(
|
|
36
|
+
limit_cents=data["limitCents"],
|
|
37
|
+
spent_cents=data["spentCents"],
|
|
38
|
+
remaining_cents=data["remainingCents"],
|
|
39
|
+
percentage_used=data["percentageUsed"],
|
|
40
|
+
current_period=data["currentPeriod"],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Check for budget alerts per HLD §2.2
|
|
44
|
+
await self._check_and_trigger_alerts(status)
|
|
45
|
+
|
|
46
|
+
return status
|
|
47
|
+
|
|
48
|
+
async def update_limit(self, limit_cents: int) -> dict:
|
|
49
|
+
"""Update monthly budget limit (free)"""
|
|
50
|
+
self._validate_limit(limit_cents)
|
|
51
|
+
|
|
52
|
+
response = await self.client.request(
|
|
53
|
+
"PUT",
|
|
54
|
+
"/v1/budget",
|
|
55
|
+
{"limitCents": limit_cents},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if not response.success or not response.data:
|
|
59
|
+
raise Exception("Failed to update budget limit")
|
|
60
|
+
|
|
61
|
+
return response.data
|
|
62
|
+
|
|
63
|
+
async def can_afford(self, operation_cost_cents: int) -> bool:
|
|
64
|
+
"""Check if operation is within budget"""
|
|
65
|
+
status = await self.get_status()
|
|
66
|
+
return status.remaining_cents >= operation_cost_cents
|
|
67
|
+
|
|
68
|
+
def on_alert(self, handler: BudgetAlertHandler):
|
|
69
|
+
"""
|
|
70
|
+
Register an alert handler for budget threshold notifications
|
|
71
|
+
Per PRD FR-021: Usage Alerts at 50%, 80%, 100%
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
handler: Callback function to handle budget alerts
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
```python
|
|
78
|
+
def handle_alert(alert: BudgetAlert):
|
|
79
|
+
print(f"Budget Alert: {alert.level.value}")
|
|
80
|
+
print(f" Message: {alert.message}")
|
|
81
|
+
print(f" Usage: {alert.percentage_used:.1f}%")
|
|
82
|
+
print(f" Remaining: ${alert.remaining_cents / 100}")
|
|
83
|
+
|
|
84
|
+
if alert.level == BudgetAlertLevel.CRITICAL_100:
|
|
85
|
+
# Take action - pause operations, notify admin, etc.
|
|
86
|
+
print("CRITICAL: Budget limit reached!")
|
|
87
|
+
|
|
88
|
+
client.budget.on_alert(handle_alert)
|
|
89
|
+
```
|
|
90
|
+
"""
|
|
91
|
+
self._alert_handlers.append(handler)
|
|
92
|
+
|
|
93
|
+
async def get_active_alerts(self) -> List[BudgetAlert]:
|
|
94
|
+
"""
|
|
95
|
+
Get all currently active budget alerts
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
List of active alerts based on current budget status
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
```python
|
|
102
|
+
active_alerts = await client.budget.get_active_alerts()
|
|
103
|
+
if active_alerts:
|
|
104
|
+
print(f"{len(active_alerts)} active budget alerts")
|
|
105
|
+
for alert in active_alerts:
|
|
106
|
+
print(f"- {alert.level.value}: {alert.message}")
|
|
107
|
+
```
|
|
108
|
+
"""
|
|
109
|
+
status = await self.get_status()
|
|
110
|
+
return self._check_thresholds(status)
|
|
111
|
+
|
|
112
|
+
async def is_threshold_crossed(self, threshold: float) -> bool:
|
|
113
|
+
"""
|
|
114
|
+
Check if a specific threshold has been crossed
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
threshold: Threshold percentage to check (50, 80, or 100)
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if threshold has been crossed
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
```python
|
|
124
|
+
if await client.budget.is_threshold_crossed(80):
|
|
125
|
+
print("80% budget threshold crossed!")
|
|
126
|
+
```
|
|
127
|
+
"""
|
|
128
|
+
status = await self.get_status()
|
|
129
|
+
return status.percentage_used >= threshold
|
|
130
|
+
|
|
131
|
+
def _check_thresholds(self, status: BudgetStatus) -> List[BudgetAlert]:
|
|
132
|
+
"""Check budget thresholds and return active alerts"""
|
|
133
|
+
alerts = []
|
|
134
|
+
|
|
135
|
+
for threshold in self.DEFAULT_THRESHOLDS:
|
|
136
|
+
if status.percentage_used >= threshold:
|
|
137
|
+
alerts.append(self._create_alert(status, threshold))
|
|
138
|
+
|
|
139
|
+
return alerts
|
|
140
|
+
|
|
141
|
+
async def _check_and_trigger_alerts(self, status: BudgetStatus):
|
|
142
|
+
"""Check thresholds and trigger alert handlers"""
|
|
143
|
+
# Find the highest crossed threshold
|
|
144
|
+
highest_crossed_threshold = 0
|
|
145
|
+
for threshold in self.DEFAULT_THRESHOLDS:
|
|
146
|
+
if status.percentage_used >= threshold:
|
|
147
|
+
highest_crossed_threshold = threshold
|
|
148
|
+
|
|
149
|
+
# Only trigger if we've crossed a new threshold
|
|
150
|
+
if highest_crossed_threshold > self._last_alerted_threshold:
|
|
151
|
+
alert = self._create_alert(status, highest_crossed_threshold)
|
|
152
|
+
|
|
153
|
+
# Trigger all registered handlers
|
|
154
|
+
for handler in self._alert_handlers:
|
|
155
|
+
try:
|
|
156
|
+
# Handle both sync and async handlers
|
|
157
|
+
import inspect
|
|
158
|
+
if inspect.iscoroutinefunction(handler):
|
|
159
|
+
await handler(alert)
|
|
160
|
+
else:
|
|
161
|
+
handler(alert)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
# Log error but don't throw - allow other handlers to execute
|
|
164
|
+
if self.client.debug:
|
|
165
|
+
print(f"Budget alert handler error: {e}")
|
|
166
|
+
|
|
167
|
+
# Update last alerted threshold
|
|
168
|
+
self._last_alerted_threshold = highest_crossed_threshold
|
|
169
|
+
|
|
170
|
+
def _create_alert(self, status: BudgetStatus, threshold: float) -> BudgetAlert:
|
|
171
|
+
"""Create a budget alert object"""
|
|
172
|
+
if threshold >= 100:
|
|
173
|
+
level = BudgetAlertLevel.CRITICAL_100
|
|
174
|
+
message = "CRITICAL: Monthly budget limit reached (100%). Operations may be throttled."
|
|
175
|
+
elif threshold >= 80:
|
|
176
|
+
level = BudgetAlertLevel.WARN_80
|
|
177
|
+
message = "WARNING: Approaching budget limit (80%). Consider reviewing spending or increasing limit."
|
|
178
|
+
else:
|
|
179
|
+
level = BudgetAlertLevel.WARN_50
|
|
180
|
+
message = "NOTICE: Half of monthly budget consumed (50%). Monitor spending closely."
|
|
181
|
+
|
|
182
|
+
return BudgetAlert(
|
|
183
|
+
level=level,
|
|
184
|
+
threshold=threshold,
|
|
185
|
+
percentage_used=status.percentage_used,
|
|
186
|
+
spent_cents=status.spent_cents,
|
|
187
|
+
limit_cents=status.limit_cents,
|
|
188
|
+
remaining_cents=status.remaining_cents,
|
|
189
|
+
message=message,
|
|
190
|
+
timestamp=datetime.utcnow().isoformat() + "Z",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _validate_limit(self, limit_cents: int):
|
|
194
|
+
"""Validate budget limit"""
|
|
195
|
+
if not isinstance(limit_cents, int):
|
|
196
|
+
raise ValueError("limit_cents must be an integer")
|
|
197
|
+
if limit_cents < 0:
|
|
198
|
+
raise ValueError("limit_cents must be non-negative")
|
|
199
|
+
if limit_cents > 1000000:
|
|
200
|
+
raise ValueError("limit_cents cannot exceed 1,000,000 ($10,000)")
|
|
201
|
+
|
|
202
|
+
# ========== Fleet Budget (Owner Only) ==========
|
|
203
|
+
|
|
204
|
+
async def get_fleet_status(self) -> dict:
|
|
205
|
+
"""
|
|
206
|
+
Get fleet budget status for authenticated owner.
|
|
207
|
+
Returns aggregated spending across all owned agents.
|
|
208
|
+
Requires owner authentication (did:owner:...)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Fleet budget status with per-agent breakdown
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
```python
|
|
215
|
+
fleet = await client.budget.get_fleet_status()
|
|
216
|
+
|
|
217
|
+
print(f"Fleet Cap: ${fleet['fleet_budget_cap_usd']}")
|
|
218
|
+
print(f"Total Spent: ${fleet['total_spent_usd']}")
|
|
219
|
+
print(f"Usage: {fleet['percentage_used']:.1f}%")
|
|
220
|
+
|
|
221
|
+
for agent in fleet['agents']:
|
|
222
|
+
print(f" {agent['name']}: ${agent['spent_cents'] / 100}")
|
|
223
|
+
```
|
|
224
|
+
"""
|
|
225
|
+
response = await self.client.request("GET", "/v1/budget/fleet")
|
|
226
|
+
|
|
227
|
+
if not response.success or not response.data:
|
|
228
|
+
raise Exception(
|
|
229
|
+
response.error.get("message", "Failed to get fleet budget status")
|
|
230
|
+
if response.error
|
|
231
|
+
else "Failed to get fleet budget status"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
data = response.data
|
|
235
|
+
return {
|
|
236
|
+
"owner_did": data["ownerDID"],
|
|
237
|
+
"fleet_budget_cap_cents": data.get("fleetBudgetCapCents"),
|
|
238
|
+
"fleet_budget_cap_usd": data.get("fleetBudgetCapUSD"),
|
|
239
|
+
"total_spent_cents": data["totalSpentCents"],
|
|
240
|
+
"total_spent_usd": data["totalSpentUSD"],
|
|
241
|
+
"remaining_cents": data.get("remainingCents"),
|
|
242
|
+
"remaining_usd": data.get("remainingUSD"),
|
|
243
|
+
"percentage_used": data.get("percentageUsed"),
|
|
244
|
+
"agent_count": data["agentCount"],
|
|
245
|
+
"total_agent_limits_cents": data["totalAgentLimitsCents"],
|
|
246
|
+
"agents": data.get("agents", []),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async def update_fleet_cap(self, fleet_cap_cents: int | None) -> dict:
|
|
250
|
+
"""
|
|
251
|
+
Update fleet budget cap for authenticated owner.
|
|
252
|
+
Set to None for unlimited.
|
|
253
|
+
Requires owner authentication (did:owner:...)
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
fleet_cap_cents: Budget cap in cents, or None for unlimited
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Update confirmation
|
|
260
|
+
|
|
261
|
+
Example:
|
|
262
|
+
```python
|
|
263
|
+
# Set fleet cap to $500
|
|
264
|
+
result = await client.budget.update_fleet_cap(50000)
|
|
265
|
+
|
|
266
|
+
# Remove fleet cap (unlimited)
|
|
267
|
+
result = await client.budget.update_fleet_cap(None)
|
|
268
|
+
```
|
|
269
|
+
"""
|
|
270
|
+
if fleet_cap_cents is not None:
|
|
271
|
+
if not isinstance(fleet_cap_cents, int) or fleet_cap_cents < 0:
|
|
272
|
+
raise ValueError("fleet_cap_cents must be a positive integer or None")
|
|
273
|
+
|
|
274
|
+
response = await self.client.request(
|
|
275
|
+
"PUT", "/v1/budget/fleet", {"fleetCapCents": fleet_cap_cents}
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if not response.success or not response.data:
|
|
279
|
+
raise Exception(
|
|
280
|
+
response.error.get("message", "Failed to update fleet budget cap")
|
|
281
|
+
if response.error
|
|
282
|
+
else "Failed to update fleet budget cap"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return response.data
|