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.
Files changed (94) hide show
  1. webagents/__init__.py +18 -0
  2. webagents/__main__.py +55 -0
  3. webagents/agents/__init__.py +13 -0
  4. webagents/agents/core/__init__.py +19 -0
  5. webagents/agents/core/base_agent.py +1834 -0
  6. webagents/agents/core/handoffs.py +293 -0
  7. webagents/agents/handoffs/__init__.py +0 -0
  8. webagents/agents/interfaces/__init__.py +0 -0
  9. webagents/agents/lifecycle/__init__.py +0 -0
  10. webagents/agents/skills/__init__.py +109 -0
  11. webagents/agents/skills/base.py +136 -0
  12. webagents/agents/skills/core/__init__.py +8 -0
  13. webagents/agents/skills/core/guardrails/__init__.py +0 -0
  14. webagents/agents/skills/core/llm/__init__.py +0 -0
  15. webagents/agents/skills/core/llm/anthropic/__init__.py +1 -0
  16. webagents/agents/skills/core/llm/litellm/__init__.py +10 -0
  17. webagents/agents/skills/core/llm/litellm/skill.py +538 -0
  18. webagents/agents/skills/core/llm/openai/__init__.py +1 -0
  19. webagents/agents/skills/core/llm/xai/__init__.py +1 -0
  20. webagents/agents/skills/core/mcp/README.md +375 -0
  21. webagents/agents/skills/core/mcp/__init__.py +15 -0
  22. webagents/agents/skills/core/mcp/skill.py +731 -0
  23. webagents/agents/skills/core/memory/__init__.py +11 -0
  24. webagents/agents/skills/core/memory/long_term_memory/__init__.py +10 -0
  25. webagents/agents/skills/core/memory/long_term_memory/memory_skill.py +639 -0
  26. webagents/agents/skills/core/memory/short_term_memory/__init__.py +9 -0
  27. webagents/agents/skills/core/memory/short_term_memory/skill.py +341 -0
  28. webagents/agents/skills/core/memory/vector_memory/skill.py +447 -0
  29. webagents/agents/skills/core/planning/__init__.py +9 -0
  30. webagents/agents/skills/core/planning/planner.py +343 -0
  31. webagents/agents/skills/ecosystem/__init__.py +0 -0
  32. webagents/agents/skills/ecosystem/crewai/__init__.py +1 -0
  33. webagents/agents/skills/ecosystem/database/__init__.py +1 -0
  34. webagents/agents/skills/ecosystem/filesystem/__init__.py +0 -0
  35. webagents/agents/skills/ecosystem/google/__init__.py +0 -0
  36. webagents/agents/skills/ecosystem/google/calendar/__init__.py +6 -0
  37. webagents/agents/skills/ecosystem/google/calendar/skill.py +306 -0
  38. webagents/agents/skills/ecosystem/n8n/__init__.py +0 -0
  39. webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
  40. webagents/agents/skills/ecosystem/web/__init__.py +0 -0
  41. webagents/agents/skills/ecosystem/zapier/__init__.py +0 -0
  42. webagents/agents/skills/robutler/__init__.py +11 -0
  43. webagents/agents/skills/robutler/auth/README.md +63 -0
  44. webagents/agents/skills/robutler/auth/__init__.py +17 -0
  45. webagents/agents/skills/robutler/auth/skill.py +354 -0
  46. webagents/agents/skills/robutler/crm/__init__.py +18 -0
  47. webagents/agents/skills/robutler/crm/skill.py +368 -0
  48. webagents/agents/skills/robutler/discovery/README.md +281 -0
  49. webagents/agents/skills/robutler/discovery/__init__.py +16 -0
  50. webagents/agents/skills/robutler/discovery/skill.py +230 -0
  51. webagents/agents/skills/robutler/kv/__init__.py +6 -0
  52. webagents/agents/skills/robutler/kv/skill.py +80 -0
  53. webagents/agents/skills/robutler/message_history/__init__.py +9 -0
  54. webagents/agents/skills/robutler/message_history/skill.py +270 -0
  55. webagents/agents/skills/robutler/messages/__init__.py +0 -0
  56. webagents/agents/skills/robutler/nli/__init__.py +13 -0
  57. webagents/agents/skills/robutler/nli/skill.py +687 -0
  58. webagents/agents/skills/robutler/notifications/__init__.py +5 -0
  59. webagents/agents/skills/robutler/notifications/skill.py +141 -0
  60. webagents/agents/skills/robutler/payments/__init__.py +41 -0
  61. webagents/agents/skills/robutler/payments/exceptions.py +255 -0
  62. webagents/agents/skills/robutler/payments/skill.py +610 -0
  63. webagents/agents/skills/robutler/storage/__init__.py +10 -0
  64. webagents/agents/skills/robutler/storage/files/__init__.py +9 -0
  65. webagents/agents/skills/robutler/storage/files/skill.py +445 -0
  66. webagents/agents/skills/robutler/storage/json/__init__.py +9 -0
  67. webagents/agents/skills/robutler/storage/json/skill.py +336 -0
  68. webagents/agents/skills/robutler/storage/kv/skill.py +88 -0
  69. webagents/agents/skills/robutler/storage.py +389 -0
  70. webagents/agents/tools/__init__.py +0 -0
  71. webagents/agents/tools/decorators.py +426 -0
  72. webagents/agents/tracing/__init__.py +0 -0
  73. webagents/agents/workflows/__init__.py +0 -0
  74. webagents/scripts/__init__.py +0 -0
  75. webagents/server/__init__.py +28 -0
  76. webagents/server/context/__init__.py +0 -0
  77. webagents/server/context/context_vars.py +121 -0
  78. webagents/server/core/__init__.py +0 -0
  79. webagents/server/core/app.py +843 -0
  80. webagents/server/core/middleware.py +69 -0
  81. webagents/server/core/models.py +98 -0
  82. webagents/server/core/monitoring.py +59 -0
  83. webagents/server/endpoints/__init__.py +0 -0
  84. webagents/server/interfaces/__init__.py +0 -0
  85. webagents/server/middleware.py +330 -0
  86. webagents/server/models.py +92 -0
  87. webagents/server/monitoring.py +659 -0
  88. webagents/utils/__init__.py +0 -0
  89. webagents/utils/logging.py +359 -0
  90. webagents-0.1.0.dist-info/METADATA +230 -0
  91. webagents-0.1.0.dist-info/RECORD +94 -0
  92. webagents-0.1.0.dist-info/WHEEL +4 -0
  93. webagents-0.1.0.dist-info/entry_points.txt +2 -0
  94. 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
+
@@ -0,0 +1,10 @@
1
+ """
2
+ WebAgents Storage Skills
3
+
4
+ File and JSON storage capabilities for the WebAgents platform.
5
+ """
6
+
7
+ from .files import RobutlerFilesSkill
8
+ from .json import RobutlerJSONSkill
9
+
10
+ __all__ = ['RobutlerFilesSkill', 'RobutlerJSONSkill']
@@ -0,0 +1,9 @@
1
+ """
2
+ WebAgents Files Storage Skill
3
+
4
+ File management capabilities for the WebAgents platform with scope-based access controls.
5
+ """
6
+
7
+ from .skill import RobutlerFilesSkill
8
+
9
+ __all__ = ['RobutlerFilesSkill']