webagents 0.1.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.
- webagents/__init__.py +18 -0
- webagents/__main__.py +55 -0
- webagents/agents/__init__.py +13 -0
- webagents/agents/core/__init__.py +19 -0
- webagents/agents/core/base_agent.py +1834 -0
- webagents/agents/core/handoffs.py +293 -0
- webagents/agents/handoffs/__init__.py +0 -0
- webagents/agents/interfaces/__init__.py +0 -0
- webagents/agents/lifecycle/__init__.py +0 -0
- webagents/agents/skills/__init__.py +109 -0
- webagents/agents/skills/base.py +136 -0
- webagents/agents/skills/core/__init__.py +8 -0
- webagents/agents/skills/core/guardrails/__init__.py +0 -0
- webagents/agents/skills/core/llm/__init__.py +0 -0
- webagents/agents/skills/core/llm/anthropic/__init__.py +1 -0
- webagents/agents/skills/core/llm/litellm/__init__.py +10 -0
- webagents/agents/skills/core/llm/litellm/skill.py +538 -0
- webagents/agents/skills/core/llm/openai/__init__.py +1 -0
- webagents/agents/skills/core/llm/xai/__init__.py +1 -0
- webagents/agents/skills/core/mcp/README.md +375 -0
- webagents/agents/skills/core/mcp/__init__.py +15 -0
- webagents/agents/skills/core/mcp/skill.py +731 -0
- webagents/agents/skills/core/memory/__init__.py +11 -0
- webagents/agents/skills/core/memory/long_term_memory/__init__.py +10 -0
- webagents/agents/skills/core/memory/long_term_memory/memory_skill.py +639 -0
- webagents/agents/skills/core/memory/short_term_memory/__init__.py +9 -0
- webagents/agents/skills/core/memory/short_term_memory/skill.py +341 -0
- webagents/agents/skills/core/memory/vector_memory/skill.py +447 -0
- webagents/agents/skills/core/planning/__init__.py +9 -0
- webagents/agents/skills/core/planning/planner.py +343 -0
- webagents/agents/skills/ecosystem/__init__.py +0 -0
- webagents/agents/skills/ecosystem/crewai/__init__.py +1 -0
- webagents/agents/skills/ecosystem/database/__init__.py +1 -0
- webagents/agents/skills/ecosystem/filesystem/__init__.py +0 -0
- webagents/agents/skills/ecosystem/google/__init__.py +0 -0
- webagents/agents/skills/ecosystem/google/calendar/__init__.py +6 -0
- webagents/agents/skills/ecosystem/google/calendar/skill.py +306 -0
- webagents/agents/skills/ecosystem/n8n/__init__.py +0 -0
- webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
- webagents/agents/skills/ecosystem/web/__init__.py +0 -0
- webagents/agents/skills/ecosystem/zapier/__init__.py +0 -0
- webagents/agents/skills/robutler/__init__.py +11 -0
- webagents/agents/skills/robutler/auth/README.md +63 -0
- webagents/agents/skills/robutler/auth/__init__.py +17 -0
- webagents/agents/skills/robutler/auth/skill.py +354 -0
- webagents/agents/skills/robutler/crm/__init__.py +18 -0
- webagents/agents/skills/robutler/crm/skill.py +368 -0
- webagents/agents/skills/robutler/discovery/README.md +281 -0
- webagents/agents/skills/robutler/discovery/__init__.py +16 -0
- webagents/agents/skills/robutler/discovery/skill.py +230 -0
- webagents/agents/skills/robutler/kv/__init__.py +6 -0
- webagents/agents/skills/robutler/kv/skill.py +80 -0
- webagents/agents/skills/robutler/message_history/__init__.py +9 -0
- webagents/agents/skills/robutler/message_history/skill.py +270 -0
- webagents/agents/skills/robutler/messages/__init__.py +0 -0
- webagents/agents/skills/robutler/nli/__init__.py +13 -0
- webagents/agents/skills/robutler/nli/skill.py +687 -0
- webagents/agents/skills/robutler/notifications/__init__.py +5 -0
- webagents/agents/skills/robutler/notifications/skill.py +141 -0
- webagents/agents/skills/robutler/payments/__init__.py +41 -0
- webagents/agents/skills/robutler/payments/exceptions.py +255 -0
- webagents/agents/skills/robutler/payments/skill.py +610 -0
- webagents/agents/skills/robutler/storage/__init__.py +10 -0
- webagents/agents/skills/robutler/storage/files/__init__.py +9 -0
- webagents/agents/skills/robutler/storage/files/skill.py +445 -0
- webagents/agents/skills/robutler/storage/json/__init__.py +9 -0
- webagents/agents/skills/robutler/storage/json/skill.py +336 -0
- webagents/agents/skills/robutler/storage/kv/skill.py +88 -0
- webagents/agents/skills/robutler/storage.py +389 -0
- webagents/agents/tools/__init__.py +0 -0
- webagents/agents/tools/decorators.py +426 -0
- webagents/agents/tracing/__init__.py +0 -0
- webagents/agents/workflows/__init__.py +0 -0
- webagents/scripts/__init__.py +0 -0
- webagents/server/__init__.py +28 -0
- webagents/server/context/__init__.py +0 -0
- webagents/server/context/context_vars.py +121 -0
- webagents/server/core/__init__.py +0 -0
- webagents/server/core/app.py +843 -0
- webagents/server/core/middleware.py +69 -0
- webagents/server/core/models.py +98 -0
- webagents/server/core/monitoring.py +59 -0
- webagents/server/endpoints/__init__.py +0 -0
- webagents/server/interfaces/__init__.py +0 -0
- webagents/server/middleware.py +330 -0
- webagents/server/models.py +92 -0
- webagents/server/monitoring.py +659 -0
- webagents/utils/__init__.py +0 -0
- webagents/utils/logging.py +359 -0
- webagents-0.1.0.dist-info/METADATA +230 -0
- webagents-0.1.0.dist-info/RECORD +94 -0
- webagents-0.1.0.dist-info/WHEEL +4 -0
- webagents-0.1.0.dist-info/entry_points.txt +2 -0
- webagents-0.1.0.dist-info/licenses/LICENSE +20 -0
@@ -0,0 +1,610 @@
|
|
1
|
+
"""
|
2
|
+
PaymentSkill - WebAgents V2.0 Platform Integration
|
3
|
+
|
4
|
+
Payment processing and billing skill for WebAgents platform.
|
5
|
+
Validates payment tokens, calculates costs using LiteLLM, and charges on connection finalization.
|
6
|
+
Based on webagents_v1 implementation patterns.
|
7
|
+
"""
|
8
|
+
|
9
|
+
import os
|
10
|
+
import inspect
|
11
|
+
import functools
|
12
|
+
import time
|
13
|
+
from typing import Dict, Any, List, Optional, Union, Callable
|
14
|
+
from dataclasses import dataclass, field
|
15
|
+
from decimal import Decimal
|
16
|
+
from enum import Enum
|
17
|
+
|
18
|
+
from webagents.agents.skills.base import Skill
|
19
|
+
from webagents.agents.tools.decorators import tool, hook, prompt
|
20
|
+
from robutler.api import RobutlerClient
|
21
|
+
from robutler.api.types import ApiResponse
|
22
|
+
from .exceptions import (
|
23
|
+
PaymentError,
|
24
|
+
create_token_required_error,
|
25
|
+
create_token_invalid_error,
|
26
|
+
create_insufficient_balance_error,
|
27
|
+
create_charging_error,
|
28
|
+
create_platform_unavailable_error
|
29
|
+
)
|
30
|
+
|
31
|
+
# Try to import LiteLLM for cost calculation
|
32
|
+
try:
|
33
|
+
from litellm import completion_cost, cost_per_token
|
34
|
+
LITELLM_AVAILABLE = True
|
35
|
+
except ImportError:
|
36
|
+
LITELLM_AVAILABLE = False
|
37
|
+
completion_cost = None
|
38
|
+
cost_per_token = None
|
39
|
+
|
40
|
+
|
41
|
+
@dataclass
|
42
|
+
class PricingInfo:
|
43
|
+
"""Pricing information returned by decorated functions for dynamic pricing"""
|
44
|
+
credits: float
|
45
|
+
reason: str
|
46
|
+
metadata: Optional[Dict[str, Any]] = None
|
47
|
+
on_success: Optional[Callable] = None
|
48
|
+
on_fail: Optional[Callable] = None
|
49
|
+
|
50
|
+
|
51
|
+
def pricing(credits_per_call: Optional[float] = None,
|
52
|
+
reason: Optional[str] = None,
|
53
|
+
on_success: Optional[Callable] = None,
|
54
|
+
on_fail: Optional[Callable] = None):
|
55
|
+
"""
|
56
|
+
Pricing decorator for tool functions - integrates with PaymentSkill
|
57
|
+
|
58
|
+
This decorator attaches pricing metadata to tool functions. The actual
|
59
|
+
cost calculation and billing is handled by the PaymentSkill when loaded.
|
60
|
+
|
61
|
+
Usage patterns:
|
62
|
+
1. Fixed pricing: @pricing(credits_per_call=0.05)
|
63
|
+
2. Dynamic pricing: @pricing() + return (result, PricingInfo(...))
|
64
|
+
3. Callback pricing: @pricing(credits_per_call=0.10, on_success=callback_func)
|
65
|
+
|
66
|
+
Args:
|
67
|
+
credits_per_call: Fixed credits to charge per call
|
68
|
+
reason: Custom reason for usage record
|
69
|
+
on_success: Callback after successful payment
|
70
|
+
on_fail: Callback after failed payment
|
71
|
+
|
72
|
+
Note: Pricing only applies when PaymentSkill is loaded and billing is enabled.
|
73
|
+
Without PaymentSkill, the decorator is inert but doesn't break functionality.
|
74
|
+
|
75
|
+
Example usage:
|
76
|
+
|
77
|
+
@tool
|
78
|
+
@pricing(credits_per_call=0.02, reason="Weather API lookup")
|
79
|
+
async def get_weather(location: str) -> str:
|
80
|
+
return f"Weather for {location}: Sunny, 72°F"
|
81
|
+
|
82
|
+
@tool
|
83
|
+
@pricing() # Dynamic pricing based on processing complexity
|
84
|
+
async def analyze_data(data: str) -> tuple:
|
85
|
+
complexity = len(data)
|
86
|
+
result = f"Analysis of {complexity} characters"
|
87
|
+
# Simple complexity-based pricing: 0.001 credits per character
|
88
|
+
credits = max(0.01, complexity * 0.001) # Minimum 0.01 credits
|
89
|
+
pricing_info = PricingInfo(
|
90
|
+
credits=credits,
|
91
|
+
reason=f"Data analysis of {complexity} chars",
|
92
|
+
metadata={"character_count": complexity, "rate_per_char": 0.001}
|
93
|
+
)
|
94
|
+
return result, pricing_info
|
95
|
+
"""
|
96
|
+
def decorator(func: Callable) -> Callable:
|
97
|
+
# Store pricing metadata on function for extraction by PaymentSkill
|
98
|
+
func._webagents_pricing = {
|
99
|
+
'credits_per_call': credits_per_call,
|
100
|
+
'reason': reason or f"Tool '{func.__name__}' execution",
|
101
|
+
'on_success': on_success,
|
102
|
+
'on_fail': on_fail,
|
103
|
+
'supports_dynamic': credits_per_call is None # If no fixed price, supports dynamic pricing
|
104
|
+
}
|
105
|
+
|
106
|
+
def _attach_usage_tuple(result):
|
107
|
+
# If dynamic pricing returns (result, PricingInfo) -> convert to (result, usage_dict)
|
108
|
+
if isinstance(result, tuple) and len(result) == 2:
|
109
|
+
res0, res1 = result
|
110
|
+
try:
|
111
|
+
if hasattr(res1, '__dict__'):
|
112
|
+
usage = {
|
113
|
+
'pricing': {
|
114
|
+
'credits': getattr(res1, 'credits', None),
|
115
|
+
'reason': getattr(res1, 'reason', None),
|
116
|
+
'metadata': getattr(res1, 'metadata', None),
|
117
|
+
}
|
118
|
+
}
|
119
|
+
return res0, usage
|
120
|
+
if isinstance(res1, dict):
|
121
|
+
return result
|
122
|
+
except Exception:
|
123
|
+
return result
|
124
|
+
return result
|
125
|
+
# If fixed pricing configured and function returned plain result -> add usage tuple
|
126
|
+
if credits_per_call is not None:
|
127
|
+
usage = {
|
128
|
+
'pricing': {
|
129
|
+
'credits': float(credits_per_call),
|
130
|
+
'reason': reason or f"Tool '{func.__name__}' execution",
|
131
|
+
}
|
132
|
+
}
|
133
|
+
return result, usage
|
134
|
+
return result
|
135
|
+
|
136
|
+
@functools.wraps(func)
|
137
|
+
async def async_wrapper(*args, **kwargs):
|
138
|
+
result = await func(*args, **kwargs)
|
139
|
+
return _attach_usage_tuple(result)
|
140
|
+
|
141
|
+
@functools.wraps(func)
|
142
|
+
def sync_wrapper(*args, **kwargs):
|
143
|
+
result = func(*args, **kwargs)
|
144
|
+
return _attach_usage_tuple(result)
|
145
|
+
|
146
|
+
if inspect.iscoroutinefunction(func):
|
147
|
+
wrapper = async_wrapper
|
148
|
+
else:
|
149
|
+
wrapper = sync_wrapper
|
150
|
+
|
151
|
+
# Copy pricing metadata to wrapper
|
152
|
+
wrapper._webagents_pricing = func._webagents_pricing
|
153
|
+
|
154
|
+
return wrapper
|
155
|
+
|
156
|
+
return decorator
|
157
|
+
|
158
|
+
|
159
|
+
@dataclass
|
160
|
+
class PaymentContext:
|
161
|
+
"""Payment context for billing"""
|
162
|
+
payment_token: Optional[str] = None
|
163
|
+
user_id: Optional[str] = None
|
164
|
+
agent_id: Optional[str] = None
|
165
|
+
|
166
|
+
|
167
|
+
class PaymentSkill(Skill):
|
168
|
+
"""
|
169
|
+
Payment processing and billing skill for WebAgents platform
|
170
|
+
|
171
|
+
Key Features:
|
172
|
+
- Payment token validation on connection
|
173
|
+
- Origin/peer identity context management
|
174
|
+
- LiteLLM cost calculation with markup
|
175
|
+
- Connection finalization charging
|
176
|
+
- Transaction creation via Portal API
|
177
|
+
|
178
|
+
Based on webagents_v1 implementation patterns.
|
179
|
+
"""
|
180
|
+
|
181
|
+
def __init__(self, config: Dict[str, Any] = None):
|
182
|
+
super().__init__(config, scope="all", dependencies=['auth'])
|
183
|
+
|
184
|
+
# Configuration
|
185
|
+
self.config = config or {}
|
186
|
+
self.enable_billing = self.config.get('enable_billing', True)
|
187
|
+
self.min_balance_agent = float(self.config.get('min_balance_agent', os.getenv('MIN_BALANCE_AGENT', '0.11')))
|
188
|
+
# Agent pricing percent as percent (e.g., 20 means 20%)
|
189
|
+
self.agent_pricing_percent = float(self.config.get('agent_pricing_percent', os.getenv('AGENT_PRICING_PERCENT', '20')))
|
190
|
+
self.minimum_balance = float(self.config.get('minimum_balance', os.getenv('MINIMUM_BALANCE', '0.1')))
|
191
|
+
# Optional external amount calculator: (llm_cost_usd, tool_cost_usd, agent_pricing_percent_percent) -> amount_to_charge
|
192
|
+
self.amount_calculator: Optional[Callable[[float, float, float], float]] = self.config.get('amount_calculator')
|
193
|
+
|
194
|
+
# WebAgents integration
|
195
|
+
# Prefer internal portal URL for in-cluster calls, then public URL, then localhost for dev
|
196
|
+
self.webagents_api_url = (
|
197
|
+
self.config.get('webagents_api_url')
|
198
|
+
or os.getenv('ROBUTLER_INTERNAL_API_URL')
|
199
|
+
or os.getenv('ROBUTLER_API_URL')
|
200
|
+
or 'http://localhost:3000'
|
201
|
+
)
|
202
|
+
# IMPORTANT: Inside the PaymentSkill, payment token charges must use the agent's key only
|
203
|
+
# No fallbacks to service keys here
|
204
|
+
self.robutler_api_key = self.config.get('robutler_api_key') or getattr(self.agent, 'api_key', None)
|
205
|
+
|
206
|
+
# API client for platform integration
|
207
|
+
self.client: Optional[RobutlerClient] = None
|
208
|
+
|
209
|
+
# Check LiteLLM availability
|
210
|
+
if not LITELLM_AVAILABLE:
|
211
|
+
self.logger.warning("LiteLLM not available - cost calculations will use fallback methods")
|
212
|
+
|
213
|
+
async def initialize(self, agent) -> None:
|
214
|
+
"""Initialize PaymentSkill with WebAgents Platform client"""
|
215
|
+
from webagents.utils.logging import get_logger, log_skill_event
|
216
|
+
|
217
|
+
self.agent = agent
|
218
|
+
self.logger = get_logger('skill.webagents.payments', agent.name)
|
219
|
+
|
220
|
+
# Log level is configured globally via setup_logging() - no need for manual configuration
|
221
|
+
|
222
|
+
# Resolve WebAgents API key: config -> agent's api_key -> environment -> default
|
223
|
+
if not self.robutler_api_key:
|
224
|
+
self.logger.error("No agent API key provided - cannot initialize platform client for payments")
|
225
|
+
raise ValueError("Agent API key is required for payment processing")
|
226
|
+
|
227
|
+
# Initialize WebAgents Platform client
|
228
|
+
try:
|
229
|
+
# Log init (no secrets)
|
230
|
+
self.logger.debug(
|
231
|
+
f"🔐 Initializing RobutlerClient | base_url={self.webagents_api_url} using agent API key"
|
232
|
+
)
|
233
|
+
self.client = RobutlerClient(
|
234
|
+
api_key=self.robutler_api_key,
|
235
|
+
base_url=self.webagents_api_url
|
236
|
+
)
|
237
|
+
|
238
|
+
except Exception as e:
|
239
|
+
self.logger.error(f"Failed to initialize WebAgents Platform client: {e}")
|
240
|
+
# Continue without platform integration for testing
|
241
|
+
self.client = None
|
242
|
+
|
243
|
+
log_skill_event(agent.name, 'payments', 'initialized', {
|
244
|
+
'enable_billing': self.enable_billing,
|
245
|
+
'agent_pricing_percent': self.agent_pricing_percent,
|
246
|
+
'minimum_balance': self.minimum_balance,
|
247
|
+
'min_balance_agent': self.min_balance_agent,
|
248
|
+
'webagents_api_url': self.webagents_api_url,
|
249
|
+
'has_webagents_client': bool(self.client),
|
250
|
+
'litellm_available': LITELLM_AVAILABLE
|
251
|
+
})
|
252
|
+
|
253
|
+
# ===== CONNECTION LIFECYCLE HOOKS =====
|
254
|
+
@prompt(priority=20, scope="all")
|
255
|
+
def payments_general_prompt(self, context: Any = None) -> str:
|
256
|
+
return "You are part of a network of AI agents working for their owners. You pay each other for services and help the owners make money."
|
257
|
+
|
258
|
+
|
259
|
+
@hook("on_connection", priority=10)
|
260
|
+
async def setup_payment_context(self, context) -> Any:
|
261
|
+
"""Setup payment context and validate payment token on connection"""
|
262
|
+
self.logger.debug("🔧 PaymentSkill.setup_payment_context() called")
|
263
|
+
self.logger.debug(f" - enable_billing: {self.enable_billing}")
|
264
|
+
self.logger.debug(f" - agent_pricing_percent: {self.agent_pricing_percent}")
|
265
|
+
self.logger.debug(f" - minimum_balance: {self.minimum_balance}")
|
266
|
+
|
267
|
+
if not self.enable_billing:
|
268
|
+
self.logger.debug(" - Billing disabled, validating agent owner's min balance")
|
269
|
+
try:
|
270
|
+
if not self.client:
|
271
|
+
raise create_platform_unavailable_error("owner balance check")
|
272
|
+
# With agent key, /user returns the agent owner's profile
|
273
|
+
# Prefer /user/credits (availableCredits) if exposed; fallback to /user
|
274
|
+
try:
|
275
|
+
credits = await self.client.user.credits()
|
276
|
+
available = float(credits)
|
277
|
+
except Exception:
|
278
|
+
user_profile = await self.client.user.get()
|
279
|
+
available = float(getattr(user_profile, 'available_credits', 0))
|
280
|
+
self.logger.debug(f" - Owner available credits: ${available:.6f} (required: ${self.min_balance_agent:.2f})")
|
281
|
+
if available < self.min_balance_agent:
|
282
|
+
raise create_insufficient_balance_error(
|
283
|
+
current_balance=available,
|
284
|
+
required_balance=self.min_balance_agent,
|
285
|
+
token_prefix=None,
|
286
|
+
)
|
287
|
+
except Exception as e:
|
288
|
+
if hasattr(e, 'status_code'):
|
289
|
+
raise
|
290
|
+
self.logger.error(f" - Owner min balance check failed: {e}")
|
291
|
+
raise
|
292
|
+
return context
|
293
|
+
|
294
|
+
try:
|
295
|
+
# Extract payment token and identity headers
|
296
|
+
payment_token = self._extract_payment_token(context)
|
297
|
+
|
298
|
+
# Get harmonized identity from auth skill context
|
299
|
+
caller_user_id = None
|
300
|
+
asserted_agent_id = None
|
301
|
+
try:
|
302
|
+
auth_ns = getattr(context, 'auth', None) or context.get('auth')
|
303
|
+
if auth_ns:
|
304
|
+
caller_user_id = getattr(auth_ns, 'user_id', None)
|
305
|
+
asserted_agent_id = getattr(auth_ns, 'agent_id', None)
|
306
|
+
except Exception:
|
307
|
+
caller_user_id = None
|
308
|
+
asserted_agent_id = None
|
309
|
+
|
310
|
+
self.logger.debug(f" - payment_token: {'present' if payment_token else 'MISSING'}")
|
311
|
+
self.logger.debug(f" - user_id: {caller_user_id}")
|
312
|
+
self.logger.debug(f" - agent_id (asserted): {asserted_agent_id}")
|
313
|
+
|
314
|
+
# Create payment context
|
315
|
+
payment_context = PaymentContext(
|
316
|
+
payment_token=payment_token,
|
317
|
+
user_id=caller_user_id,
|
318
|
+
agent_id=asserted_agent_id,
|
319
|
+
)
|
320
|
+
|
321
|
+
# If agent_pricing_percent < 100, ensure owner's min balance first
|
322
|
+
if self.agent_pricing_percent < 100.0:
|
323
|
+
try:
|
324
|
+
if not self.client:
|
325
|
+
raise create_platform_unavailable_error("owner balance check")
|
326
|
+
try:
|
327
|
+
owner_available = float(await self.client.user.credits())
|
328
|
+
except Exception:
|
329
|
+
owner_profile = await self.client.user.get()
|
330
|
+
owner_available = float(getattr(owner_profile, 'available_credits', 0))
|
331
|
+
self.logger.debug(f" - Owner available credits: ${owner_available:.6f} (required: ${self.min_balance_agent:.2f})")
|
332
|
+
if owner_available < self.min_balance_agent:
|
333
|
+
raise create_insufficient_balance_error(
|
334
|
+
current_balance=owner_available,
|
335
|
+
required_balance=self.min_balance_agent,
|
336
|
+
token_prefix=None,
|
337
|
+
)
|
338
|
+
except Exception as e:
|
339
|
+
if hasattr(e, 'status_code'):
|
340
|
+
raise
|
341
|
+
self.logger.error(f" - Owner min balance check failed: {e}")
|
342
|
+
raise
|
343
|
+
|
344
|
+
# Validate payment token if provided (and agent_pricing_percent > 0 requires token)
|
345
|
+
if payment_token:
|
346
|
+
self.logger.debug(f" - Validating payment token: {payment_token[:20]}...")
|
347
|
+
validation_result = await self._validate_payment_token_with_balance(payment_token)
|
348
|
+
self.logger.debug(f" - Validation result: {validation_result}")
|
349
|
+
|
350
|
+
if not validation_result['valid']:
|
351
|
+
self.logger.error(f" - ❌ Payment token validation failed: {validation_result['error']}")
|
352
|
+
raise create_token_invalid_error(
|
353
|
+
token_prefix=payment_token[:20] if payment_token else None,
|
354
|
+
reason=validation_result.get('error', 'Token validation failed')
|
355
|
+
)
|
356
|
+
|
357
|
+
# Check if balance meets minimum requirement
|
358
|
+
balance = validation_result['balance']
|
359
|
+
self.logger.debug(f" - Balance check: ${balance:.2f} >= ${self.minimum_balance:.2f}")
|
360
|
+
|
361
|
+
if balance < self.minimum_balance:
|
362
|
+
self.logger.error(f" - ❌ Insufficient balance: ${balance:.2f} < ${self.minimum_balance:.2f} required")
|
363
|
+
raise create_insufficient_balance_error(
|
364
|
+
current_balance=balance,
|
365
|
+
required_balance=self.minimum_balance,
|
366
|
+
token_prefix=payment_token[:20] if payment_token else None
|
367
|
+
)
|
368
|
+
|
369
|
+
self.logger.info(f" - ✅ Payment token validated: {payment_token[:20]}... (balance: ${balance:.2f})")
|
370
|
+
elif self.enable_billing and self.agent_pricing_percent > 0.0:
|
371
|
+
# If billing is enabled but no payment token provided, require one (unless minimum_balance is 0)
|
372
|
+
if self.minimum_balance > 0:
|
373
|
+
self.logger.error(" - ❌ Billing enabled but no payment token provided")
|
374
|
+
agent_name = getattr(self.agent, 'name', None) if hasattr(self, 'agent') else None
|
375
|
+
raise create_token_required_error(agent_name=agent_name)
|
376
|
+
|
377
|
+
# Set payment context in payments namespace
|
378
|
+
context.payments = payment_context
|
379
|
+
|
380
|
+
self.logger.debug(
|
381
|
+
f"Payment context setup: token={'✓' if payment_token else '✗'}, user_id={caller_user_id}, agent_id={asserted_agent_id}"
|
382
|
+
)
|
383
|
+
|
384
|
+
except PaymentError as e:
|
385
|
+
# These are payment-specific errors that should return 402
|
386
|
+
self.logger.error(f"🚨 Payment validation failed: {e}")
|
387
|
+
self.logger.error(f" - Error details: {e.to_dict()}")
|
388
|
+
# Re-raise the specific payment error (it already has status_code=402)
|
389
|
+
raise e
|
390
|
+
except Exception as e:
|
391
|
+
self.logger.error(f"🚨 Payment context setup failed: {e}")
|
392
|
+
self.logger.error(f" - enable_billing: {self.enable_billing}, payment_token: {'present' if payment_token else 'missing'}")
|
393
|
+
raise
|
394
|
+
|
395
|
+
return context
|
396
|
+
|
397
|
+
@hook("on_message", priority=90, scope="all")
|
398
|
+
async def accumulate_llm_costs(self, context) -> Any:
|
399
|
+
"""No-op: cost is calculated in finalize_connection from context.usage"""
|
400
|
+
return context
|
401
|
+
|
402
|
+
@hook("after_toolcall", priority=90, scope="all")
|
403
|
+
async def accumulate_tool_costs(self, context) -> Any:
|
404
|
+
"""No-op: BaseAgent appends usage for tools."""
|
405
|
+
return context
|
406
|
+
|
407
|
+
@hook("finalize_connection", priority=95, scope="all")
|
408
|
+
async def finalize_payment(self, context) -> Any:
|
409
|
+
"""Finalize payment by calculating total from context.usage and charging the token"""
|
410
|
+
if not self.enable_billing:
|
411
|
+
return context
|
412
|
+
|
413
|
+
try:
|
414
|
+
payment_context = getattr(context, 'payments', None)
|
415
|
+
if not payment_context:
|
416
|
+
return context
|
417
|
+
|
418
|
+
# Sum LLM and tool costs from context.usage
|
419
|
+
usage_records = getattr(context, 'usage', []) or []
|
420
|
+
llm_cost_usd = 0.0
|
421
|
+
tool_cost_usd = 0.0
|
422
|
+
|
423
|
+
for record in usage_records:
|
424
|
+
if not isinstance(record, dict):
|
425
|
+
continue
|
426
|
+
record_type = record.get('type')
|
427
|
+
if record_type == 'llm':
|
428
|
+
model = record.get('model')
|
429
|
+
prompt_tokens = int(record.get('prompt_tokens') or 0)
|
430
|
+
completion_tokens = int(record.get('completion_tokens') or 0)
|
431
|
+
try:
|
432
|
+
if LITELLM_AVAILABLE and cost_per_token and model:
|
433
|
+
p_cost, c_cost = cost_per_token(
|
434
|
+
model=model,
|
435
|
+
prompt_tokens=prompt_tokens,
|
436
|
+
completion_tokens=completion_tokens
|
437
|
+
)
|
438
|
+
llm_cost_usd += float((p_cost or 0.0) + (c_cost or 0.0))
|
439
|
+
except Exception as e:
|
440
|
+
self.logger.debug(f"LLM cost_per_token failed for model {model}: {e}")
|
441
|
+
continue
|
442
|
+
elif record_type == 'tool':
|
443
|
+
pricing = record.get('pricing') or {}
|
444
|
+
credits = pricing.get('credits')
|
445
|
+
if credits is not None:
|
446
|
+
try:
|
447
|
+
tool_cost_usd += float(credits)
|
448
|
+
except Exception:
|
449
|
+
continue
|
450
|
+
|
451
|
+
# Calculate total to charge using external calculator if provided, else default formula
|
452
|
+
if callable(self.amount_calculator):
|
453
|
+
try:
|
454
|
+
# Log inputs and client auth fingerprint used for downstream calls
|
455
|
+
self.logger.debug(
|
456
|
+
f"🧮 Amount calculator input | llm_cost_usd={llm_cost_usd:.6f} "
|
457
|
+
f"tool_cost_usd={tool_cost_usd:.6f} agent_pricing_percent={self.agent_pricing_percent:.2f}% "
|
458
|
+
f"client_key_src={getattr(self.client, '_api_key_source', 'unknown')} "
|
459
|
+
f"client_key_fp={getattr(self.client, '_api_key_fingerprint', 'na')}"
|
460
|
+
)
|
461
|
+
result = self.amount_calculator(llm_cost_usd, tool_cost_usd, self.agent_pricing_percent)
|
462
|
+
if inspect.isawaitable(result):
|
463
|
+
to_charge = float(await result)
|
464
|
+
else:
|
465
|
+
to_charge = float(result)
|
466
|
+
self.logger.debug(f"🧮 Amount calculator output | to_charge={to_charge:.6f}")
|
467
|
+
except Exception as e:
|
468
|
+
self.logger.error(f"Amount calculator failed: {e}; falling back to default formula")
|
469
|
+
to_charge = (llm_cost_usd + tool_cost_usd) * (1.0 + (self.agent_pricing_percent / 100.0))
|
470
|
+
else:
|
471
|
+
to_charge = (llm_cost_usd + tool_cost_usd) * (1.0 + (self.agent_pricing_percent / 100.0))
|
472
|
+
|
473
|
+
if to_charge <= 0:
|
474
|
+
return context
|
475
|
+
|
476
|
+
# Charge payment token directly
|
477
|
+
if payment_context.payment_token:
|
478
|
+
success = await self._charge_payment_token(
|
479
|
+
payment_context.payment_token,
|
480
|
+
to_charge,
|
481
|
+
f"Agent {getattr(self.agent, 'name', 'unknown')} usage (margin {self.agent_pricing_percent:.2f}%)"
|
482
|
+
)
|
483
|
+
if success:
|
484
|
+
self.logger.info(
|
485
|
+
f"💳 Payment finalized: ${to_charge:.6f} charged to token {payment_context.payment_token[:20]}..."
|
486
|
+
)
|
487
|
+
else:
|
488
|
+
self.logger.error(
|
489
|
+
f"💳 Payment failed: ${to_charge:.6f} could not charge token {payment_context.payment_token[:20]}..."
|
490
|
+
)
|
491
|
+
else:
|
492
|
+
# Enforce billing policy: if there are any costs and no token, raise 402
|
493
|
+
self.logger.error(f"💳 Billing enabled but no payment token available for ${to_charge:.6f}")
|
494
|
+
raise create_token_required_error(agent_name=getattr(self.agent, 'name', None))
|
495
|
+
|
496
|
+
except Exception as e:
|
497
|
+
self.logger.error(f"Payment finalization failed: {e}")
|
498
|
+
|
499
|
+
return context
|
500
|
+
|
501
|
+
|
502
|
+
# ===== INTERNAL METHODS =====
|
503
|
+
|
504
|
+
def _extract_payment_token(self, context) -> Optional[str]:
|
505
|
+
"""Extract payment token from context headers"""
|
506
|
+
headers = context.request.headers
|
507
|
+
query_params = context.request.query_params
|
508
|
+
|
509
|
+
self.logger.debug(f"🔍 Extracting payment token from context")
|
510
|
+
self.logger.debug(f" - headers: {list(headers.keys()) if headers else 'NONE'}")
|
511
|
+
self.logger.debug(f" - query_params: {list(query_params.keys()) if query_params else 'NONE'}")
|
512
|
+
|
513
|
+
# Try X-Payment-Token header
|
514
|
+
payment_token = headers.get('X-Payment-Token') or headers.get('x-payment-token')
|
515
|
+
|
516
|
+
if payment_token:
|
517
|
+
self.logger.debug(f" - Found X-Payment-Token: {payment_token[:20]}...")
|
518
|
+
return payment_token
|
519
|
+
|
520
|
+
|
521
|
+
# Try query parameters
|
522
|
+
token = query_params.get('payment_token')
|
523
|
+
if token:
|
524
|
+
self.logger.debug(f" - Found query param payment_token: {token[:20]}...")
|
525
|
+
return token
|
526
|
+
|
527
|
+
self.logger.debug(f" - No payment token found in any location")
|
528
|
+
return None
|
529
|
+
|
530
|
+
def _extract_header(self, context, header_name: str) -> Optional[str]:
|
531
|
+
"""Extract header value from context"""
|
532
|
+
headers = context.get('headers', {})
|
533
|
+
return headers.get(header_name) or headers.get(header_name.lower())
|
534
|
+
|
535
|
+
async def _validate_payment_token(self, token: str) -> bool:
|
536
|
+
"""Validate payment token with WebAgents Platform"""
|
537
|
+
try:
|
538
|
+
if not self.client:
|
539
|
+
self.logger.warning("Cannot validate payment token - no platform client")
|
540
|
+
raise create_platform_unavailable_error("token validation")
|
541
|
+
|
542
|
+
# Use the object-oriented token validation method
|
543
|
+
return await self.client.tokens.validate(token)
|
544
|
+
|
545
|
+
except Exception as e:
|
546
|
+
self.logger.error(f"Payment token validation error: {e}")
|
547
|
+
# If it's already a PaymentError, re-raise it
|
548
|
+
if isinstance(e, PaymentError):
|
549
|
+
raise e
|
550
|
+
# Otherwise, create a validation error
|
551
|
+
token_prefix = token[:20] if token else None
|
552
|
+
raise create_token_invalid_error(
|
553
|
+
token_prefix=token_prefix,
|
554
|
+
reason=str(e)
|
555
|
+
)
|
556
|
+
|
557
|
+
async def _validate_payment_token_with_balance(self, token: str) -> Dict[str, Any]:
|
558
|
+
"""Validate payment token and check balance with WebAgents Platform"""
|
559
|
+
try:
|
560
|
+
if not self.client:
|
561
|
+
raise create_platform_unavailable_error("token balance check")
|
562
|
+
|
563
|
+
# Use the object-oriented token validation with balance method
|
564
|
+
return await self.client.tokens.validate_with_balance(token)
|
565
|
+
|
566
|
+
except PaymentError as e:
|
567
|
+
# If it's already a PaymentError, convert to dict format expected by caller
|
568
|
+
return {'valid': False, 'error': str(e), 'balance': 0.0}
|
569
|
+
except Exception as e:
|
570
|
+
self.logger.error(f"Payment token balance check failed: {e}")
|
571
|
+
return {'valid': False, 'error': str(e), 'balance': 0.0}
|
572
|
+
|
573
|
+
|
574
|
+
async def _charge_payment_token(self, token: str, amount_usd: float, description: str) -> bool:
|
575
|
+
"""Charge payment token for the specified amount"""
|
576
|
+
try:
|
577
|
+
if not self.client:
|
578
|
+
raise create_platform_unavailable_error("token charging")
|
579
|
+
|
580
|
+
# Convert amount to credits (using current conversion rate)
|
581
|
+
credits = amount_usd
|
582
|
+
|
583
|
+
# Require full token format id:secret for redemption
|
584
|
+
try:
|
585
|
+
has_secret = isinstance(token, str) and (":" in token) and len(token.split(":", 1)[1]) > 0
|
586
|
+
except Exception:
|
587
|
+
has_secret = False
|
588
|
+
if not has_secret:
|
589
|
+
token_prefix = token[:20] if token else None
|
590
|
+
raise create_token_invalid_error(
|
591
|
+
token_prefix=token_prefix,
|
592
|
+
reason="Token must include secret in 'id:secret' format for redemption"
|
593
|
+
)
|
594
|
+
|
595
|
+
# Use the object-oriented token redeem method
|
596
|
+
return await self.client.tokens.redeem(token, credits)
|
597
|
+
|
598
|
+
except Exception as e:
|
599
|
+
self.logger.error(f"Payment token charge failed: {e}")
|
600
|
+
# If it's already a PaymentError, re-raise it
|
601
|
+
if isinstance(e, PaymentError):
|
602
|
+
raise e
|
603
|
+
# Otherwise, create a charging error
|
604
|
+
token_prefix = token[:20] if token else None
|
605
|
+
raise create_charging_error(
|
606
|
+
amount=amount_usd,
|
607
|
+
token_prefix=token_prefix,
|
608
|
+
reason=str(e)
|
609
|
+
)
|
610
|
+
|