dominusnode-dify 1.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.

Potentially problematic release.


This version of dominusnode-dify might be problematic. Click here for more details.

@@ -0,0 +1,1570 @@
1
+ """DomiNode Dify tool provider -- 24 tools with full security hardening.
2
+
3
+ This module implements the DominusNodeProvider class that Dify invokes.
4
+ Each tool is a method that receives credentials (from the provider YAML)
5
+ and parameters (from the tool YAML), performs the operation against the
6
+ DomiNode REST API, and returns a dict result.
7
+
8
+ Security code is copied verbatim from the AutoGPT integration to ensure
9
+ identical SSRF, OFAC, credential scrubbing, and prototype pollution
10
+ protections.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ipaddress
16
+ import json
17
+ import os
18
+ import re
19
+ import socket
20
+ from datetime import datetime, timedelta, timezone
21
+ from typing import Any, Dict, List, Optional, Set
22
+ from urllib.parse import quote, urlparse
23
+
24
+ import httpx
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # SSRF Prevention -- blocked hostnames
28
+ # ---------------------------------------------------------------------------
29
+
30
+ BLOCKED_HOSTNAMES: Set[str] = {
31
+ "localhost",
32
+ "localhost.localdomain",
33
+ "ip6-localhost",
34
+ "ip6-loopback",
35
+ "[::1]",
36
+ "[::ffff:127.0.0.1]",
37
+ "0.0.0.0",
38
+ "[::]",
39
+ }
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # SSRF Prevention -- blocked IP networks
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _BLOCKED_IPV4_NETWORKS = [
46
+ ipaddress.IPv4Network("0.0.0.0/8"),
47
+ ipaddress.IPv4Network("10.0.0.0/8"),
48
+ ipaddress.IPv4Network("100.64.0.0/10"), # CGNAT
49
+ ipaddress.IPv4Network("127.0.0.0/8"),
50
+ ipaddress.IPv4Network("169.254.0.0/16"), # link-local
51
+ ipaddress.IPv4Network("172.16.0.0/12"),
52
+ ipaddress.IPv4Network("192.0.0.0/24"),
53
+ ipaddress.IPv4Network("192.0.2.0/24"), # TEST-NET-1
54
+ ipaddress.IPv4Network("192.168.0.0/16"),
55
+ ipaddress.IPv4Network("198.18.0.0/15"), # benchmarking
56
+ ipaddress.IPv4Network("198.51.100.0/24"), # TEST-NET-2
57
+ ipaddress.IPv4Network("203.0.113.0/24"), # TEST-NET-3
58
+ ipaddress.IPv4Network("224.0.0.0/4"), # multicast
59
+ ipaddress.IPv4Network("240.0.0.0/4"), # reserved
60
+ ipaddress.IPv4Network("255.255.255.255/32"),
61
+ ]
62
+
63
+ _BLOCKED_IPV6_NETWORKS = [
64
+ ipaddress.IPv6Network("::1/128"), # loopback
65
+ ipaddress.IPv6Network("::/128"), # unspecified
66
+ ipaddress.IPv6Network("::ffff:0:0/96"), # IPv4-mapped
67
+ ipaddress.IPv6Network("64:ff9b::/96"), # NAT64
68
+ ipaddress.IPv6Network("100::/64"), # discard
69
+ ipaddress.IPv6Network("fe80::/10"), # link-local
70
+ ipaddress.IPv6Network("fc00::/7"), # ULA (includes fd00::/8)
71
+ ipaddress.IPv6Network("ff00::/8"), # multicast
72
+ ]
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # SSRF Prevention -- IP normalization and validation
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _normalize_ipv4(hostname: str) -> Optional[str]:
80
+ """Normalize non-standard IPv4 representations to dotted-decimal.
81
+
82
+ Handles hex (0x7f000001), octal (0177.0.0.1), and decimal integer
83
+ (2130706433) forms to prevent SSRF bypasses.
84
+ """
85
+ # Single decimal integer (e.g., 2130706433 = 127.0.0.1)
86
+ if re.match(r"^\d+$", hostname):
87
+ n = int(hostname)
88
+ if 0 <= n <= 0xFFFFFFFF:
89
+ return (
90
+ f"{(n >> 24) & 0xFF}.{(n >> 16) & 0xFF}"
91
+ f".{(n >> 8) & 0xFF}.{n & 0xFF}"
92
+ )
93
+
94
+ # Hex notation (e.g., 0x7f000001)
95
+ if re.match(r"^0x[0-9a-fA-F]+$", hostname, re.IGNORECASE):
96
+ n = int(hostname, 16)
97
+ if 0 <= n <= 0xFFFFFFFF:
98
+ return (
99
+ f"{(n >> 24) & 0xFF}.{(n >> 16) & 0xFF}"
100
+ f".{(n >> 8) & 0xFF}.{n & 0xFF}"
101
+ )
102
+
103
+ # Octal or mixed-radix octets (e.g., 0177.0.0.1)
104
+ parts = hostname.split(".")
105
+ if len(parts) == 4:
106
+ octets = []
107
+ for part in parts:
108
+ try:
109
+ if re.match(r"^0x[0-9a-fA-F]+$", part, re.IGNORECASE):
110
+ val = int(part, 16)
111
+ elif re.match(r"^0\d+$", part):
112
+ val = int(part, 8)
113
+ elif re.match(r"^\d+$", part):
114
+ val = int(part, 10)
115
+ else:
116
+ return None
117
+ if val < 0 or val > 255:
118
+ return None
119
+ octets.append(val)
120
+ except ValueError:
121
+ return None
122
+ return ".".join(str(o) for o in octets)
123
+
124
+ return None
125
+
126
+
127
+ def _extract_teredo_ipv4(addr: ipaddress.IPv6Address) -> Optional[ipaddress.IPv4Address]:
128
+ """Extract the embedded IPv4 server/client from a Teredo address (2001:0000::/32).
129
+
130
+ Teredo format: 2001:0000:SSSS:SSSS:flags:port:CCCC:CCCC
131
+ where SSSS:SSSS is the Teredo server IPv4 and CCCC:CCCC (XORed with 0xFFFF)
132
+ is the client IPv4. Both must be checked.
133
+ """
134
+ packed = addr.packed # 16 bytes
135
+ # Server IPv4 at bytes 4-7
136
+ server_ip = ipaddress.IPv4Address(packed[4:8])
137
+ # Client IPv4 at bytes 12-15 (XOR with 0xFFFFFFFF)
138
+ client_bytes = bytes(b ^ 0xFF for b in packed[12:16])
139
+ client_ip = ipaddress.IPv4Address(client_bytes)
140
+ # Return whichever is private (or server if both are)
141
+ for ip in (server_ip, client_ip):
142
+ if any(ip in net for net in _BLOCKED_IPV4_NETWORKS):
143
+ return ip
144
+ return None
145
+
146
+
147
+ def _extract_6to4_ipv4(addr: ipaddress.IPv6Address) -> Optional[ipaddress.IPv4Address]:
148
+ """Extract the embedded IPv4 from a 6to4 address (2002::/16).
149
+
150
+ 6to4 format: 2002:AABB:CCDD::... where AA.BB.CC.DD is the IPv4.
151
+ """
152
+ packed = addr.packed
153
+ return ipaddress.IPv4Address(packed[2:6])
154
+
155
+
156
+ def _is_private_ip(hostname: str) -> bool:
157
+ """Check if a hostname/IP resolves to a private/reserved IP range.
158
+
159
+ Handles:
160
+ - Standard dotted-decimal IPv4
161
+ - Hex, octal, and decimal-encoded IPv4
162
+ - IPv6 with bracket stripping and zone ID removal
163
+ - IPv4-mapped IPv6 (::ffff:x.x.x.x)
164
+ - IPv4-compatible IPv6 (::x.x.x.x)
165
+ - Teredo tunneling (2001:0000::/32)
166
+ - 6to4 tunneling (2002::/16)
167
+ """
168
+ ip = hostname.strip("[]")
169
+
170
+ # Strip IPv6 zone ID
171
+ zone_idx = ip.find("%")
172
+ if zone_idx != -1:
173
+ ip = ip[:zone_idx]
174
+
175
+ # Try normalizing non-standard IPv4 first
176
+ normalized = _normalize_ipv4(ip)
177
+ check_ip = normalized if normalized else ip
178
+
179
+ # Try parsing as a standard IP address via ipaddress module
180
+ try:
181
+ addr = ipaddress.ip_address(check_ip)
182
+ except ValueError:
183
+ # Not a raw IP literal -- check well-known hostnames
184
+ lower = hostname.lower()
185
+ if lower in ("localhost", "localhost.localdomain"):
186
+ return True
187
+ if lower.endswith(".localhost"):
188
+ return True
189
+ # Hex-encoded IPv4 (e.g. 0x7f000001)
190
+ if lower.startswith("0x"):
191
+ try:
192
+ num = int(lower, 16)
193
+ if 0 <= num <= 0xFFFFFFFF:
194
+ a = ipaddress.IPv4Address(num)
195
+ return any(a in net for net in _BLOCKED_IPV4_NETWORKS)
196
+ except (ValueError, ipaddress.AddressValueError):
197
+ pass
198
+ # Decimal-encoded IPv4 (e.g. 2130706433)
199
+ if lower.isdigit():
200
+ try:
201
+ num = int(lower)
202
+ if 0 <= num <= 0xFFFFFFFF:
203
+ a = ipaddress.IPv4Address(num)
204
+ return any(a in net for net in _BLOCKED_IPV4_NETWORKS)
205
+ except (ValueError, ipaddress.AddressValueError):
206
+ pass
207
+ return False
208
+
209
+ if isinstance(addr, ipaddress.IPv4Address):
210
+ return any(addr in net for net in _BLOCKED_IPV4_NETWORKS)
211
+
212
+ if isinstance(addr, ipaddress.IPv6Address):
213
+ # Check against IPv6 blocked networks
214
+ if any(addr in net for net in _BLOCKED_IPV6_NETWORKS):
215
+ return True
216
+
217
+ # IPv4-mapped IPv6 (::ffff:x.x.x.x)
218
+ mapped = addr.ipv4_mapped
219
+ if mapped is not None:
220
+ return any(mapped in net for net in _BLOCKED_IPV4_NETWORKS)
221
+
222
+ # IPv4-compatible IPv6 (::x.x.x.x) -- deprecated but still a bypass vector
223
+ # These are addresses like ::127.0.0.1 or ::10.0.0.1
224
+ packed = addr.packed
225
+ if all(b == 0 for b in packed[:12]):
226
+ embedded = ipaddress.IPv4Address(packed[12:16])
227
+ return any(embedded in net for net in _BLOCKED_IPV4_NETWORKS)
228
+
229
+ # Teredo tunneling (2001:0000::/32)
230
+ if addr in ipaddress.IPv6Network("2001:0000::/32"):
231
+ private_v4 = _extract_teredo_ipv4(addr)
232
+ if private_v4 is not None:
233
+ return True
234
+ # Even if both IPs are "public", Teredo is suspicious -- block it
235
+ return True
236
+
237
+ # 6to4 tunneling (2002::/16) -- block unconditionally
238
+ if addr in ipaddress.IPv6Network("2002::/16"):
239
+ return True
240
+
241
+ return False
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # SSRF Prevention -- full URL validation
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ def validate_url(url: str) -> str:
250
+ """Validate a URL for SSRF safety.
251
+
252
+ Args:
253
+ url: The URL to validate.
254
+
255
+ Returns:
256
+ The validated URL string.
257
+
258
+ Raises:
259
+ ValueError: If the URL is invalid or targets a private/blocked address.
260
+ """
261
+ if not url or not isinstance(url, str):
262
+ raise ValueError("URL must be a non-empty string")
263
+
264
+ if len(url) > 2048:
265
+ raise ValueError("URL exceeds maximum length of 2048 characters")
266
+
267
+ try:
268
+ parsed = urlparse(url)
269
+ except Exception:
270
+ raise ValueError(f"Invalid URL: {url}")
271
+
272
+ if parsed.scheme not in ("http", "https"):
273
+ raise ValueError(
274
+ f"Only http: and https: protocols are supported, got {parsed.scheme}:"
275
+ )
276
+
277
+ hostname = (parsed.hostname or "").lower()
278
+ if not hostname:
279
+ raise ValueError("URL must contain a hostname")
280
+
281
+ # Block well-known loopback hostnames
282
+ if hostname in BLOCKED_HOSTNAMES:
283
+ raise ValueError("Requests to localhost/loopback addresses are blocked")
284
+
285
+ # Block private/reserved IPs
286
+ if _is_private_ip(hostname):
287
+ raise ValueError("Requests to private/internal IP addresses are blocked")
288
+
289
+ # Block dangerous TLD suffixes
290
+ if hostname.endswith(".localhost"):
291
+ raise ValueError("Requests to .localhost addresses are blocked")
292
+
293
+ if (
294
+ hostname.endswith(".local")
295
+ or hostname.endswith(".internal")
296
+ or hostname.endswith(".arpa")
297
+ ):
298
+ raise ValueError("Requests to internal network hostnames are blocked")
299
+
300
+ # Block embedded credentials in URL
301
+ if parsed.username or parsed.password:
302
+ raise ValueError("URLs with embedded credentials are not allowed")
303
+
304
+ # DNS rebinding protection: resolve hostname and check all resolved IPs
305
+ try:
306
+ ipaddress.ip_address(hostname.strip("[]"))
307
+ except ValueError:
308
+ # It is a hostname, not a raw IP -- resolve and check
309
+ try:
310
+ infos = socket.getaddrinfo(
311
+ hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
312
+ )
313
+ for _family, _type, _proto, _canonname, sockaddr in infos:
314
+ addr_str = sockaddr[0]
315
+ # Strip zone ID from resolved addresses
316
+ if "%" in addr_str:
317
+ addr_str = addr_str.split("%")[0]
318
+ if _is_private_ip(addr_str):
319
+ raise ValueError(
320
+ f"Hostname {hostname!r} resolves to private IP {addr_str}"
321
+ )
322
+ except socket.gaierror:
323
+ raise ValueError(f"Could not resolve hostname: {hostname!r}")
324
+
325
+ return url
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Sanctioned countries (OFAC)
330
+ # ---------------------------------------------------------------------------
331
+
332
+ SANCTIONED_COUNTRIES: Set[str] = {"CU", "IR", "KP", "RU", "SY"}
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Max response size
336
+ # ---------------------------------------------------------------------------
337
+
338
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024 # 10 MB
339
+ MAX_RESPONSE_CHARS = 4000
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Credential sanitization
343
+ # ---------------------------------------------------------------------------
344
+
345
+ _CREDENTIAL_RE = re.compile(r"dn_(live|test)_[a-zA-Z0-9]+")
346
+
347
+
348
+ def _sanitize_error(message: str) -> str:
349
+ """Remove DomiNode API key patterns from error messages."""
350
+ return _CREDENTIAL_RE.sub("***", message)
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # Prototype pollution prevention
355
+ # ---------------------------------------------------------------------------
356
+
357
+ _DANGEROUS_KEYS = frozenset({"__proto__", "constructor", "prototype"})
358
+
359
+
360
+ def _strip_dangerous_keys(obj: Any, depth: int = 0) -> None:
361
+ """Recursively remove prototype pollution keys from parsed JSON."""
362
+ if depth > 50 or obj is None or not isinstance(obj, (dict, list)):
363
+ return
364
+ if isinstance(obj, list):
365
+ for item in obj:
366
+ _strip_dangerous_keys(item, depth + 1)
367
+ return
368
+ keys_to_remove = [k for k in obj if k in _DANGEROUS_KEYS]
369
+ for k in keys_to_remove:
370
+ del obj[k]
371
+ for v in obj.values():
372
+ if isinstance(v, (dict, list)):
373
+ _strip_dangerous_keys(v, depth + 1)
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Allowed HTTP methods for proxied fetch
378
+ # ---------------------------------------------------------------------------
379
+
380
+ _ALLOWED_FETCH_METHODS: Set[str] = {"GET", "HEAD", "OPTIONS"}
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # UUID regex for ID validation
384
+ # ---------------------------------------------------------------------------
385
+
386
+ _UUID_RE = re.compile(
387
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
388
+ re.IGNORECASE,
389
+ )
390
+
391
+ _DOMAIN_RE = re.compile(
392
+ r"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$"
393
+ )
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Period to date range helper
398
+ # ---------------------------------------------------------------------------
399
+
400
+
401
+ def _period_to_date_range(period: str) -> Dict[str, str]:
402
+ """Convert a human-readable period to ISO date range parameters."""
403
+ now = datetime.now(timezone.utc)
404
+ until = now.isoformat()
405
+
406
+ if period == "day":
407
+ since = (now - timedelta(days=1)).isoformat()
408
+ elif period == "week":
409
+ since = (now - timedelta(weeks=1)).isoformat()
410
+ else: # month (default)
411
+ since = (now - timedelta(days=30)).isoformat()
412
+
413
+ return {"since": since, "until": until}
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Input validation helpers
418
+ # ---------------------------------------------------------------------------
419
+
420
+
421
+ def _validate_label(label: Any, field_name: str = "label") -> Optional[str]:
422
+ """Validate a label string, returning an error message or None."""
423
+ if not label or not isinstance(label, str):
424
+ return f"{field_name} is required and must be a string"
425
+ if len(label) > 100:
426
+ return f"{field_name} must be 100 characters or fewer"
427
+ if any(0 <= ord(c) <= 0x1F or ord(c) == 0x7F for c in label):
428
+ return f"{field_name} contains invalid control characters"
429
+ return None
430
+
431
+
432
+ def _validate_positive_int(
433
+ value: Any,
434
+ field_name: str,
435
+ max_value: int = 2_147_483_647,
436
+ min_value: int = 1,
437
+ ) -> Optional[str]:
438
+ """Validate that value is an integer within range, returning error or None."""
439
+ if not isinstance(value, int) or isinstance(value, bool):
440
+ return f"{field_name} must be an integer"
441
+ if value < min_value or value > max_value:
442
+ return f"{field_name} must be between {min_value} and {max_value}"
443
+ return None
444
+
445
+
446
+ def _validate_uuid(value: Any, field_name: str) -> Optional[str]:
447
+ """Validate that value is a valid UUID string, returning error or None."""
448
+ if not value or not isinstance(value, str):
449
+ return f"{field_name} is required and must be a string"
450
+ if not _UUID_RE.match(value):
451
+ return f"{field_name} must be a valid UUID"
452
+ return None
453
+
454
+
455
+ def _validate_allowed_domains(domains: Any) -> Optional[str]:
456
+ """Validate allowed_domains list, returning error message or None."""
457
+ if not isinstance(domains, list):
458
+ return "allowed_domains must be a list of strings"
459
+ if len(domains) > 100:
460
+ return "allowed_domains must contain at most 100 entries"
461
+ for i, domain in enumerate(domains):
462
+ if not isinstance(domain, str):
463
+ return f"allowed_domains[{i}] must be a string"
464
+ if len(domain) > 253:
465
+ return f"allowed_domains[{i}] exceeds maximum length of 253 characters"
466
+ if not _DOMAIN_RE.match(domain):
467
+ return f"allowed_domains[{i}] is not a valid domain format"
468
+ return None
469
+
470
+
471
+ # ===========================================================================
472
+ # DominusNodeProvider -- Dify tool provider
473
+ # ===========================================================================
474
+
475
+
476
+ class DominusNodeProvider:
477
+ """Dify tool provider for DomiNode rotating proxy service.
478
+
479
+ Authenticates using a DomiNode API key and exposes 24 tool methods that
480
+ Dify can invoke. Each tool method receives ``credentials`` and ``params``
481
+ dicts from the Dify runtime and returns a dict result.
482
+
483
+ Args:
484
+ credentials: Dify provider credentials dict containing ``api_key``,
485
+ optional ``base_url``, and optional ``proxy_host``.
486
+
487
+ Example::
488
+
489
+ provider = DominusNodeProvider(credentials={
490
+ "api_key": "dn_live_...",
491
+ "base_url": "https://api.dominusnode.com",
492
+ "proxy_host": "proxy.dominusnode.com",
493
+ })
494
+ result = provider.proxied_fetch({"url": "https://example.com"})
495
+ """
496
+
497
+ def __init__(self, credentials: Dict[str, Any] | None = None):
498
+ creds = credentials or {}
499
+ self.api_key: str = creds.get("api_key", "") or os.environ.get("DOMINUSNODE_API_KEY", "")
500
+ base = creds.get("base_url", "") or os.environ.get("DOMINUSNODE_BASE_URL", "https://api.dominusnode.com")
501
+ self.base_url: str = base.rstrip("/")
502
+ self.proxy_host: str = creds.get("proxy_host", "") or os.environ.get("DOMINUSNODE_PROXY_HOST", "proxy.dominusnode.com")
503
+ self.proxy_port: int = int(os.environ.get("DOMINUSNODE_PROXY_PORT", "8080"))
504
+ self.timeout: float = 30.0
505
+ self._token: str | None = None
506
+
507
+ # ------------------------------------------------------------------
508
+ # Authentication
509
+ # ------------------------------------------------------------------
510
+
511
+ def _authenticate(self) -> str:
512
+ """Authenticate with the DomiNode API using the API key.
513
+
514
+ Returns:
515
+ The JWT bearer token.
516
+
517
+ Raises:
518
+ RuntimeError: If authentication fails.
519
+ """
520
+ if not self.api_key:
521
+ raise RuntimeError(
522
+ "DomiNode API key is required. Set api_key in provider credentials "
523
+ "or DOMINUSNODE_API_KEY environment variable."
524
+ )
525
+
526
+ with httpx.Client(
527
+ timeout=self.timeout,
528
+ follow_redirects=False,
529
+ ) as client:
530
+ resp = client.post(
531
+ f"{self.base_url}/api/auth/verify-key",
532
+ json={"apiKey": self.api_key},
533
+ headers={
534
+ "User-Agent": "dominusnode-dify/1.0.0",
535
+ "Content-Type": "application/json",
536
+ },
537
+ )
538
+
539
+ if resp.status_code != 200:
540
+ body = _sanitize_error(resp.text[:500])
541
+ raise RuntimeError(
542
+ f"Authentication failed ({resp.status_code}): {body}"
543
+ )
544
+
545
+ data = resp.json()
546
+ token = data.get("token")
547
+ if not token:
548
+ raise RuntimeError("Authentication response missing token")
549
+ self._token = token
550
+ return token
551
+
552
+ def _ensure_auth(self) -> None:
553
+ """Ensure the provider is authenticated, authenticating if needed."""
554
+ if self._token is None:
555
+ self._authenticate()
556
+
557
+ # ------------------------------------------------------------------
558
+ # API request helper
559
+ # ------------------------------------------------------------------
560
+
561
+ def _api_request(
562
+ self,
563
+ method: str,
564
+ path: str,
565
+ body: Optional[Dict[str, Any]] = None,
566
+ ) -> Any:
567
+ """Make an authenticated API request to the DomiNode REST API.
568
+
569
+ Args:
570
+ method: HTTP method (GET, POST, PATCH, DELETE).
571
+ path: API path (e.g., ``/api/wallet``).
572
+ body: Optional JSON body.
573
+
574
+ Returns:
575
+ Parsed JSON response (with dangerous keys stripped).
576
+
577
+ Raises:
578
+ RuntimeError: On auth failure, API errors, or oversized responses.
579
+ """
580
+ if self._token is None:
581
+ raise RuntimeError("Not authenticated")
582
+
583
+ with httpx.Client(
584
+ timeout=self.timeout,
585
+ follow_redirects=False,
586
+ ) as client:
587
+ kwargs: Dict[str, Any] = {
588
+ "headers": {
589
+ "User-Agent": "dominusnode-dify/1.0.0",
590
+ "Content-Type": "application/json",
591
+ "Authorization": f"Bearer {self._token}",
592
+ },
593
+ }
594
+
595
+ if body is not None and method.upper() not in ("GET", "HEAD", "OPTIONS"):
596
+ kwargs["json"] = body
597
+
598
+ resp = client.request(method, f"{self.base_url}{path}", **kwargs)
599
+
600
+ if len(resp.content) > MAX_RESPONSE_BYTES:
601
+ raise RuntimeError("Response body too large")
602
+
603
+ if resp.status_code >= 400:
604
+ try:
605
+ err_data = resp.json()
606
+ msg = err_data.get("error", resp.text)
607
+ except Exception:
608
+ msg = resp.text
609
+ msg = str(msg)[:500]
610
+ raise RuntimeError(
611
+ f"API error {resp.status_code}: {_sanitize_error(msg)}"
612
+ )
613
+
614
+ if resp.text:
615
+ data = resp.json()
616
+ _strip_dangerous_keys(data)
617
+ return data
618
+ return {}
619
+
620
+ def _request_with_retry(
621
+ self,
622
+ method: str,
623
+ path: str,
624
+ body: Optional[Dict[str, Any]] = None,
625
+ ) -> Any:
626
+ """Make an API request, retrying once on 401 (token expired)."""
627
+ self._ensure_auth()
628
+ try:
629
+ return self._api_request(method, path, body)
630
+ except RuntimeError as e:
631
+ if "401" in str(e):
632
+ self._token = None
633
+ self._ensure_auth()
634
+ return self._api_request(method, path, body)
635
+ raise
636
+
637
+ # ==================================================================
638
+ # Tool 1: proxied_fetch
639
+ # ==================================================================
640
+
641
+ def proxied_fetch(self, params: Dict[str, Any]) -> Dict[str, Any]:
642
+ """Fetch a URL through the DomiNode rotating proxy network.
643
+
644
+ Args:
645
+ params: Dict with keys: url (required), method, country, proxy_type.
646
+
647
+ Returns:
648
+ Dict with status, headers, and body (or error).
649
+ """
650
+ url = params.get("url")
651
+ if not url or not isinstance(url, str):
652
+ return {"error": "url is required and must be a string"}
653
+
654
+ # Validate URL for SSRF
655
+ try:
656
+ validate_url(url)
657
+ except ValueError as e:
658
+ return {"error": str(e)}
659
+
660
+ country = params.get("country")
661
+
662
+ # OFAC country check
663
+ if country:
664
+ upper = country.upper()
665
+ if upper in SANCTIONED_COUNTRIES:
666
+ return {"error": f"Country '{upper}' is blocked (OFAC sanctioned country)"}
667
+
668
+ # Restrict HTTP methods
669
+ method = params.get("method", "GET")
670
+ method_upper = (method or "GET").upper()
671
+ if method_upper not in _ALLOWED_FETCH_METHODS:
672
+ return {
673
+ "error": (
674
+ f"HTTP method '{method_upper}' is not allowed. "
675
+ "Only GET, HEAD, OPTIONS are permitted."
676
+ )
677
+ }
678
+
679
+ # Validate proxy_type
680
+ proxy_type = params.get("proxy_type", "dc")
681
+ if proxy_type not in ("dc", "residential", "auto"):
682
+ return {"error": "proxy_type must be 'dc', 'residential', or 'auto'"}
683
+
684
+ try:
685
+ # Build proxy username for routing
686
+ parts: list[str] = []
687
+ if proxy_type and proxy_type != "auto":
688
+ parts.append(proxy_type)
689
+ if country:
690
+ parts.append(f"country-{country.upper()}")
691
+ username = "-".join(parts) if parts else "auto"
692
+
693
+ proxy_url = (
694
+ f"http://{username}:{self.api_key}"
695
+ f"@{self.proxy_host}:{self.proxy_port}"
696
+ )
697
+
698
+ with httpx.Client(
699
+ proxy=proxy_url,
700
+ timeout=self.timeout,
701
+ follow_redirects=False,
702
+ max_redirects=0,
703
+ ) as proxy_client:
704
+ resp = proxy_client.request(
705
+ method=method_upper,
706
+ url=url,
707
+ )
708
+
709
+ # Enforce response size limit
710
+ if len(resp.content) > MAX_RESPONSE_BYTES:
711
+ return {"error": "Response body too large (>10 MB)"}
712
+
713
+ body = resp.text[:MAX_RESPONSE_CHARS]
714
+ resp_headers = dict(resp.headers)
715
+ # Scrub sensitive response headers
716
+ for h in ("set-cookie", "www-authenticate", "proxy-authenticate"):
717
+ resp_headers.pop(h, None)
718
+
719
+ return {
720
+ "status": resp.status_code,
721
+ "headers": resp_headers,
722
+ "body": body,
723
+ }
724
+ except Exception as e:
725
+ return {
726
+ "error": f"Proxy fetch failed: {_sanitize_error(str(e))}",
727
+ "hint": "Ensure the DomiNode proxy gateway is running and accessible.",
728
+ }
729
+
730
+ # ==================================================================
731
+ # Tool 2: check_balance
732
+ # ==================================================================
733
+
734
+ def check_balance(self, params: Dict[str, Any]) -> Dict[str, Any]:
735
+ """Check the current wallet balance.
736
+
737
+ Args:
738
+ params: Empty dict (no parameters needed).
739
+
740
+ Returns:
741
+ Dict with wallet balance information.
742
+ """
743
+ try:
744
+ result = self._request_with_retry("GET", "/api/wallet")
745
+ return result if isinstance(result, dict) else {"data": result}
746
+ except Exception as e:
747
+ return {"error": _sanitize_error(str(e))}
748
+
749
+ # ==================================================================
750
+ # Tool 3: check_usage
751
+ # ==================================================================
752
+
753
+ def check_usage(self, params: Dict[str, Any]) -> Dict[str, Any]:
754
+ """Check proxy usage statistics.
755
+
756
+ Args:
757
+ params: Dict with optional key: period (day, week, month).
758
+
759
+ Returns:
760
+ Dict with usage statistics.
761
+ """
762
+ period = params.get("period", "month")
763
+ if period not in ("day", "week", "month"):
764
+ return {"error": "period must be 'day', 'week', or 'month'"}
765
+ try:
766
+ date_range = _period_to_date_range(period)
767
+ result = self._request_with_retry(
768
+ "GET",
769
+ f"/api/usage?since={quote(date_range['since'], safe='')}"
770
+ f"&until={quote(date_range['until'], safe='')}",
771
+ )
772
+ return result if isinstance(result, dict) else {"data": result}
773
+ except Exception as e:
774
+ return {"error": _sanitize_error(str(e))}
775
+
776
+ # ==================================================================
777
+ # Tool 4: get_proxy_config
778
+ # ==================================================================
779
+
780
+ def get_proxy_config(self, params: Dict[str, Any]) -> Dict[str, Any]:
781
+ """Get proxy configuration information.
782
+
783
+ Args:
784
+ params: Empty dict (no parameters needed).
785
+
786
+ Returns:
787
+ Dict with proxy configuration.
788
+ """
789
+ try:
790
+ result = self._request_with_retry("GET", "/api/proxy/config")
791
+ return result if isinstance(result, dict) else {"data": result}
792
+ except Exception as e:
793
+ return {"error": _sanitize_error(str(e))}
794
+
795
+ # ==================================================================
796
+ # Tool 5: list_sessions
797
+ # ==================================================================
798
+
799
+ def list_sessions(self, params: Dict[str, Any]) -> Dict[str, Any]:
800
+ """List active proxy sessions.
801
+
802
+ Args:
803
+ params: Empty dict (no parameters needed).
804
+
805
+ Returns:
806
+ Dict with active session information.
807
+ """
808
+ try:
809
+ result = self._request_with_retry("GET", "/api/sessions/active")
810
+ return result if isinstance(result, dict) else {"data": result}
811
+ except Exception as e:
812
+ return {"error": _sanitize_error(str(e))}
813
+
814
+ # ==================================================================
815
+ # Tool 6: create_agentic_wallet
816
+ # ==================================================================
817
+
818
+ def create_agentic_wallet(self, params: Dict[str, Any]) -> Dict[str, Any]:
819
+ """Create a new agentic sub-wallet with a spending limit.
820
+
821
+ Args:
822
+ params: Dict with keys: label (required), spending_limit_cents (required),
823
+ daily_limit_cents (optional), allowed_domains (optional).
824
+
825
+ Returns:
826
+ Dict with the created wallet details.
827
+ """
828
+ label = params.get("label")
829
+ spending_limit_cents = params.get("spending_limit_cents")
830
+ daily_limit_cents = params.get("daily_limit_cents")
831
+ allowed_domains = params.get("allowed_domains")
832
+
833
+ if label is None or spending_limit_cents is None:
834
+ return {"error": "label and spending_limit_cents are required"}
835
+
836
+ # Dify sends numbers as float; coerce to int
837
+ if isinstance(spending_limit_cents, float) and spending_limit_cents == int(spending_limit_cents):
838
+ spending_limit_cents = int(spending_limit_cents)
839
+
840
+ err = _validate_label(label, "label")
841
+ if err:
842
+ return {"error": err}
843
+
844
+ err = _validate_positive_int(spending_limit_cents, "spending_limit_cents")
845
+ if err:
846
+ return {"error": err}
847
+
848
+ if daily_limit_cents is not None:
849
+ if isinstance(daily_limit_cents, float) and daily_limit_cents == int(daily_limit_cents):
850
+ daily_limit_cents = int(daily_limit_cents)
851
+ err = _validate_positive_int(
852
+ daily_limit_cents, "daily_limit_cents", max_value=1_000_000
853
+ )
854
+ if err:
855
+ return {"error": err}
856
+
857
+ if allowed_domains is not None:
858
+ err = _validate_allowed_domains(allowed_domains)
859
+ if err:
860
+ return {"error": err}
861
+
862
+ body: Dict[str, Any] = {
863
+ "label": label,
864
+ "spendingLimitCents": spending_limit_cents,
865
+ }
866
+ if daily_limit_cents is not None:
867
+ body["dailyLimitCents"] = daily_limit_cents
868
+ if allowed_domains is not None:
869
+ body["allowedDomains"] = allowed_domains
870
+
871
+ try:
872
+ result = self._request_with_retry("POST", "/api/agent-wallet", body)
873
+ return result if isinstance(result, dict) else {"data": result}
874
+ except Exception as e:
875
+ return {"error": _sanitize_error(str(e))}
876
+
877
+ # ==================================================================
878
+ # Tool 7: fund_agentic_wallet
879
+ # ==================================================================
880
+
881
+ def fund_agentic_wallet(self, params: Dict[str, Any]) -> Dict[str, Any]:
882
+ """Fund an agentic wallet from the main wallet.
883
+
884
+ Args:
885
+ params: Dict with keys: wallet_id (required), amount_cents (required).
886
+
887
+ Returns:
888
+ Dict with the updated wallet details.
889
+ """
890
+ wallet_id = params.get("wallet_id")
891
+ amount_cents = params.get("amount_cents")
892
+
893
+ if wallet_id is None or amount_cents is None:
894
+ return {"error": "wallet_id and amount_cents are required"}
895
+
896
+ if not wallet_id or not isinstance(wallet_id, str):
897
+ return {"error": "wallet_id is required and must be a string"}
898
+
899
+ # Dify sends numbers as float; coerce to int
900
+ if isinstance(amount_cents, float) and amount_cents == int(amount_cents):
901
+ amount_cents = int(amount_cents)
902
+
903
+ err = _validate_positive_int(amount_cents, "amount_cents")
904
+ if err:
905
+ return {"error": err}
906
+
907
+ try:
908
+ result = self._request_with_retry(
909
+ "POST",
910
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/fund",
911
+ {"amountCents": amount_cents},
912
+ )
913
+ return result if isinstance(result, dict) else {"data": result}
914
+ except Exception as e:
915
+ return {"error": _sanitize_error(str(e))}
916
+
917
+ # ==================================================================
918
+ # Tool 8: agentic_wallet_balance
919
+ # ==================================================================
920
+
921
+ def agentic_wallet_balance(self, params: Dict[str, Any]) -> Dict[str, Any]:
922
+ """Check the balance of an agentic wallet.
923
+
924
+ Args:
925
+ params: Dict with key: wallet_id (required).
926
+
927
+ Returns:
928
+ Dict with wallet balance details.
929
+ """
930
+ wallet_id = params.get("wallet_id")
931
+ if not wallet_id or not isinstance(wallet_id, str):
932
+ return {"error": "wallet_id is required and must be a string"}
933
+
934
+ try:
935
+ result = self._request_with_retry(
936
+ "GET",
937
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}",
938
+ )
939
+ return result if isinstance(result, dict) else {"data": result}
940
+ except Exception as e:
941
+ return {"error": _sanitize_error(str(e))}
942
+
943
+ # ==================================================================
944
+ # Tool 9: list_agentic_wallets
945
+ # ==================================================================
946
+
947
+ def list_agentic_wallets(self, params: Dict[str, Any]) -> Dict[str, Any]:
948
+ """List all agentic wallets.
949
+
950
+ Args:
951
+ params: Empty dict (no parameters needed).
952
+
953
+ Returns:
954
+ Dict with a list of all agentic wallets.
955
+ """
956
+ try:
957
+ result = self._request_with_retry("GET", "/api/agent-wallet")
958
+ return result if isinstance(result, dict) else {"data": result}
959
+ except Exception as e:
960
+ return {"error": _sanitize_error(str(e))}
961
+
962
+ # ==================================================================
963
+ # Tool 10: agentic_transactions
964
+ # ==================================================================
965
+
966
+ def agentic_transactions(self, params: Dict[str, Any]) -> Dict[str, Any]:
967
+ """Get transaction history for an agentic wallet.
968
+
969
+ Args:
970
+ params: Dict with keys: wallet_id (required), limit (optional, 1-100).
971
+
972
+ Returns:
973
+ Dict with transaction history.
974
+ """
975
+ wallet_id = params.get("wallet_id")
976
+ if not wallet_id or not isinstance(wallet_id, str):
977
+ return {"error": "wallet_id is required and must be a string"}
978
+
979
+ limit = params.get("limit", 20)
980
+ # Dify sends numbers as float; coerce to int
981
+ if isinstance(limit, float) and limit == int(limit):
982
+ limit = int(limit)
983
+
984
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 100:
985
+ return {"error": "limit must be an integer between 1 and 100"}
986
+
987
+ qs = f"?limit={limit}" if limit != 20 else ""
988
+
989
+ try:
990
+ result = self._request_with_retry(
991
+ "GET",
992
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/transactions{qs}",
993
+ )
994
+ return result if isinstance(result, dict) else {"data": result}
995
+ except Exception as e:
996
+ return {"error": _sanitize_error(str(e))}
997
+
998
+ # ==================================================================
999
+ # Tool 11: freeze_agentic_wallet
1000
+ # ==================================================================
1001
+
1002
+ def freeze_agentic_wallet(self, params: Dict[str, Any]) -> Dict[str, Any]:
1003
+ """Freeze an agentic wallet (prevent spending).
1004
+
1005
+ Args:
1006
+ params: Dict with key: wallet_id (required).
1007
+
1008
+ Returns:
1009
+ Dict confirming the wallet is frozen.
1010
+ """
1011
+ wallet_id = params.get("wallet_id")
1012
+ if not wallet_id or not isinstance(wallet_id, str):
1013
+ return {"error": "wallet_id is required and must be a string"}
1014
+
1015
+ try:
1016
+ result = self._request_with_retry(
1017
+ "POST",
1018
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/freeze",
1019
+ )
1020
+ return result if isinstance(result, dict) else {"data": result}
1021
+ except Exception as e:
1022
+ return {"error": _sanitize_error(str(e))}
1023
+
1024
+ # ==================================================================
1025
+ # Tool 12: unfreeze_agentic_wallet
1026
+ # ==================================================================
1027
+
1028
+ def unfreeze_agentic_wallet(self, params: Dict[str, Any]) -> Dict[str, Any]:
1029
+ """Unfreeze an agentic wallet (re-enable spending).
1030
+
1031
+ Args:
1032
+ params: Dict with key: wallet_id (required).
1033
+
1034
+ Returns:
1035
+ Dict confirming the wallet is unfrozen.
1036
+ """
1037
+ wallet_id = params.get("wallet_id")
1038
+ if not wallet_id or not isinstance(wallet_id, str):
1039
+ return {"error": "wallet_id is required and must be a string"}
1040
+
1041
+ try:
1042
+ result = self._request_with_retry(
1043
+ "POST",
1044
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/unfreeze",
1045
+ )
1046
+ return result if isinstance(result, dict) else {"data": result}
1047
+ except Exception as e:
1048
+ return {"error": _sanitize_error(str(e))}
1049
+
1050
+ # ==================================================================
1051
+ # Tool 13: delete_agentic_wallet
1052
+ # ==================================================================
1053
+
1054
+ def delete_agentic_wallet(self, params: Dict[str, Any]) -> Dict[str, Any]:
1055
+ """Delete an agentic wallet (must be unfrozen first; refunds balance).
1056
+
1057
+ Args:
1058
+ params: Dict with key: wallet_id (required).
1059
+
1060
+ Returns:
1061
+ Dict confirming deletion.
1062
+ """
1063
+ wallet_id = params.get("wallet_id")
1064
+ if not wallet_id or not isinstance(wallet_id, str):
1065
+ return {"error": "wallet_id is required and must be a string"}
1066
+
1067
+ try:
1068
+ result = self._request_with_retry(
1069
+ "DELETE",
1070
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}",
1071
+ )
1072
+ return result if isinstance(result, dict) else {"data": result}
1073
+ except Exception as e:
1074
+ return {"error": _sanitize_error(str(e))}
1075
+
1076
+ # ==================================================================
1077
+ # Tool 14: update_wallet_policy
1078
+ # ==================================================================
1079
+
1080
+ def update_wallet_policy(self, params: Dict[str, Any]) -> Dict[str, Any]:
1081
+ """Update the policy for an agentic wallet.
1082
+
1083
+ Args:
1084
+ params: Dict with keys: wallet_id (required),
1085
+ daily_limit_cents (optional), allowed_domains (optional).
1086
+
1087
+ Returns:
1088
+ Dict with the updated wallet policy.
1089
+ """
1090
+ wallet_id = params.get("wallet_id")
1091
+ daily_limit_cents = params.get("daily_limit_cents")
1092
+ allowed_domains = params.get("allowed_domains")
1093
+
1094
+ err = _validate_uuid(wallet_id, "wallet_id")
1095
+ if err:
1096
+ return {"error": err}
1097
+
1098
+ body: Dict[str, Any] = {}
1099
+
1100
+ if daily_limit_cents is not None:
1101
+ if isinstance(daily_limit_cents, float) and daily_limit_cents == int(daily_limit_cents):
1102
+ daily_limit_cents = int(daily_limit_cents)
1103
+ err = _validate_positive_int(
1104
+ daily_limit_cents, "daily_limit_cents", max_value=1_000_000
1105
+ )
1106
+ if err:
1107
+ return {"error": err}
1108
+ body["dailyLimitCents"] = daily_limit_cents
1109
+
1110
+ if allowed_domains is not None:
1111
+ err = _validate_allowed_domains(allowed_domains)
1112
+ if err:
1113
+ return {"error": err}
1114
+ body["allowedDomains"] = allowed_domains
1115
+
1116
+ try:
1117
+ result = self._request_with_retry(
1118
+ "PATCH",
1119
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/policy",
1120
+ body,
1121
+ )
1122
+ return result if isinstance(result, dict) else {"data": result}
1123
+ except Exception as e:
1124
+ return {"error": _sanitize_error(str(e))}
1125
+
1126
+ # ==================================================================
1127
+ # Tool 15: create_team
1128
+ # ==================================================================
1129
+
1130
+ def create_team(self, params: Dict[str, Any]) -> Dict[str, Any]:
1131
+ """Create a new team with a shared wallet.
1132
+
1133
+ Args:
1134
+ params: Dict with keys: name (required), max_members (optional, 1-100).
1135
+
1136
+ Returns:
1137
+ Dict with the created team details.
1138
+ """
1139
+ name = params.get("name")
1140
+ if not name:
1141
+ return {"error": "name is required"}
1142
+
1143
+ err = _validate_label(name, "name")
1144
+ if err:
1145
+ return {"error": err}
1146
+
1147
+ body: Dict[str, Any] = {"name": name}
1148
+
1149
+ max_members = params.get("max_members")
1150
+ if max_members is not None:
1151
+ # Dify sends numbers as float; coerce to int
1152
+ if isinstance(max_members, float) and max_members == int(max_members):
1153
+ max_members = int(max_members)
1154
+ err = _validate_positive_int(max_members, "max_members", max_value=100)
1155
+ if err:
1156
+ return {"error": err}
1157
+ body["maxMembers"] = max_members
1158
+
1159
+ try:
1160
+ result = self._request_with_retry("POST", "/api/teams", body)
1161
+ return result if isinstance(result, dict) else {"data": result}
1162
+ except Exception as e:
1163
+ return {"error": _sanitize_error(str(e))}
1164
+
1165
+ # ==================================================================
1166
+ # Tool 15: list_teams
1167
+ # ==================================================================
1168
+
1169
+ def list_teams(self, params: Dict[str, Any]) -> Dict[str, Any]:
1170
+ """List all teams the user belongs to.
1171
+
1172
+ Args:
1173
+ params: Empty dict (no parameters needed).
1174
+
1175
+ Returns:
1176
+ Dict with a list of teams.
1177
+ """
1178
+ try:
1179
+ result = self._request_with_retry("GET", "/api/teams")
1180
+ return result if isinstance(result, dict) else {"data": result}
1181
+ except Exception as e:
1182
+ return {"error": _sanitize_error(str(e))}
1183
+
1184
+ # ==================================================================
1185
+ # Tool 16: team_details
1186
+ # ==================================================================
1187
+
1188
+ def team_details(self, params: Dict[str, Any]) -> Dict[str, Any]:
1189
+ """Get details for a specific team.
1190
+
1191
+ Args:
1192
+ params: Dict with key: team_id (required, UUID).
1193
+
1194
+ Returns:
1195
+ Dict with team details.
1196
+ """
1197
+ team_id = params.get("team_id")
1198
+ err = _validate_uuid(team_id, "team_id")
1199
+ if err:
1200
+ return {"error": err}
1201
+
1202
+ try:
1203
+ result = self._request_with_retry(
1204
+ "GET", f"/api/teams/{quote(team_id, safe='')}"
1205
+ )
1206
+ return result if isinstance(result, dict) else {"data": result}
1207
+ except Exception as e:
1208
+ return {"error": _sanitize_error(str(e))}
1209
+
1210
+ # ==================================================================
1211
+ # Tool 17: team_fund
1212
+ # ==================================================================
1213
+
1214
+ def team_fund(self, params: Dict[str, Any]) -> Dict[str, Any]:
1215
+ """Fund a team's shared wallet from the user's personal wallet.
1216
+
1217
+ Args:
1218
+ params: Dict with keys: team_id (required), amount_cents (required, 100-1000000).
1219
+
1220
+ Returns:
1221
+ Dict with the updated team wallet details.
1222
+ """
1223
+ team_id = params.get("team_id")
1224
+ amount_cents = params.get("amount_cents")
1225
+
1226
+ if team_id is None or amount_cents is None:
1227
+ return {"error": "team_id and amount_cents are required"}
1228
+
1229
+ err = _validate_uuid(team_id, "team_id")
1230
+ if err:
1231
+ return {"error": err}
1232
+
1233
+ # Dify sends numbers as float; coerce to int
1234
+ if isinstance(amount_cents, float) and amount_cents == int(amount_cents):
1235
+ amount_cents = int(amount_cents)
1236
+
1237
+ err = _validate_positive_int(
1238
+ amount_cents, "amount_cents", min_value=100, max_value=1_000_000
1239
+ )
1240
+ if err:
1241
+ return {"error": err}
1242
+
1243
+ try:
1244
+ result = self._request_with_retry(
1245
+ "POST",
1246
+ f"/api/teams/{quote(team_id, safe='')}/wallet/fund",
1247
+ {"amountCents": amount_cents},
1248
+ )
1249
+ return result if isinstance(result, dict) else {"data": result}
1250
+ except Exception as e:
1251
+ return {"error": _sanitize_error(str(e))}
1252
+
1253
+ # ==================================================================
1254
+ # Tool 18: team_create_key
1255
+ # ==================================================================
1256
+
1257
+ def team_create_key(self, params: Dict[str, Any]) -> Dict[str, Any]:
1258
+ """Create a new API key for a team.
1259
+
1260
+ Args:
1261
+ params: Dict with keys: team_id (required), label (required).
1262
+
1263
+ Returns:
1264
+ Dict with the created API key (shown once).
1265
+ """
1266
+ team_id = params.get("team_id")
1267
+ label = params.get("label")
1268
+
1269
+ if team_id is None or label is None:
1270
+ return {"error": "team_id and label are required"}
1271
+
1272
+ err = _validate_uuid(team_id, "team_id")
1273
+ if err:
1274
+ return {"error": err}
1275
+
1276
+ err = _validate_label(label, "label")
1277
+ if err:
1278
+ return {"error": err}
1279
+
1280
+ try:
1281
+ result = self._request_with_retry(
1282
+ "POST",
1283
+ f"/api/teams/{quote(team_id, safe='')}/keys",
1284
+ {"label": label},
1285
+ )
1286
+ return result if isinstance(result, dict) else {"data": result}
1287
+ except Exception as e:
1288
+ return {"error": _sanitize_error(str(e))}
1289
+
1290
+ # ==================================================================
1291
+ # Tool 19: team_usage
1292
+ # ==================================================================
1293
+
1294
+ def team_usage(self, params: Dict[str, Any]) -> Dict[str, Any]:
1295
+ """Get usage/transaction history for a team.
1296
+
1297
+ Args:
1298
+ params: Dict with keys: team_id (required), limit (optional, 1-100).
1299
+
1300
+ Returns:
1301
+ Dict with team usage/transaction history.
1302
+ """
1303
+ team_id = params.get("team_id")
1304
+ if not team_id:
1305
+ return {"error": "team_id is required"}
1306
+
1307
+ err = _validate_uuid(team_id, "team_id")
1308
+ if err:
1309
+ return {"error": err}
1310
+
1311
+ limit = params.get("limit", 20)
1312
+ # Dify sends numbers as float; coerce to int
1313
+ if isinstance(limit, float) and limit == int(limit):
1314
+ limit = int(limit)
1315
+
1316
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 100:
1317
+ return {"error": "limit must be an integer between 1 and 100"}
1318
+
1319
+ qs = f"?limit={limit}" if limit != 20 else ""
1320
+
1321
+ try:
1322
+ result = self._request_with_retry(
1323
+ "GET",
1324
+ f"/api/teams/{quote(team_id, safe='')}/wallet/transactions{qs}",
1325
+ )
1326
+ return result if isinstance(result, dict) else {"data": result}
1327
+ except Exception as e:
1328
+ return {"error": _sanitize_error(str(e))}
1329
+
1330
+ # ==================================================================
1331
+ # Tool 20: update_team
1332
+ # ==================================================================
1333
+
1334
+ def update_team(self, params: Dict[str, Any]) -> Dict[str, Any]:
1335
+ """Update team settings (name and/or max_members).
1336
+
1337
+ Args:
1338
+ params: Dict with keys: team_id (required), name (optional), max_members (optional).
1339
+
1340
+ Returns:
1341
+ Dict with the updated team details.
1342
+ """
1343
+ team_id = params.get("team_id")
1344
+ if not team_id:
1345
+ return {"error": "team_id is required"}
1346
+
1347
+ err = _validate_uuid(team_id, "team_id")
1348
+ if err:
1349
+ return {"error": err}
1350
+
1351
+ body: Dict[str, Any] = {}
1352
+
1353
+ name = params.get("name")
1354
+ if name is not None:
1355
+ err = _validate_label(name, "name")
1356
+ if err:
1357
+ return {"error": err}
1358
+ body["name"] = name
1359
+
1360
+ max_members = params.get("max_members")
1361
+ if max_members is not None:
1362
+ # Dify sends numbers as float; coerce to int
1363
+ if isinstance(max_members, float) and max_members == int(max_members):
1364
+ max_members = int(max_members)
1365
+ err = _validate_positive_int(max_members, "max_members", max_value=100)
1366
+ if err:
1367
+ return {"error": err}
1368
+ body["maxMembers"] = max_members
1369
+
1370
+ if not body:
1371
+ return {"error": "At least one of name or max_members must be provided"}
1372
+
1373
+ try:
1374
+ result = self._request_with_retry(
1375
+ "PATCH",
1376
+ f"/api/teams/{quote(team_id, safe='')}",
1377
+ body,
1378
+ )
1379
+ return result if isinstance(result, dict) else {"data": result}
1380
+ except Exception as e:
1381
+ return {"error": _sanitize_error(str(e))}
1382
+
1383
+ # ==================================================================
1384
+ # Tool 21: update_team_member_role
1385
+ # ==================================================================
1386
+
1387
+ def update_team_member_role(self, params: Dict[str, Any]) -> Dict[str, Any]:
1388
+ """Update the role of a team member.
1389
+
1390
+ Args:
1391
+ params: Dict with keys: team_id (required), user_id (required),
1392
+ role (required, 'member' or 'admin').
1393
+
1394
+ Returns:
1395
+ Dict confirming the role update.
1396
+ """
1397
+ team_id = params.get("team_id")
1398
+ user_id = params.get("user_id")
1399
+ role = params.get("role")
1400
+
1401
+ if team_id is None or user_id is None or role is None:
1402
+ return {"error": "team_id, user_id, and role are required"}
1403
+
1404
+ err = _validate_uuid(team_id, "team_id")
1405
+ if err:
1406
+ return {"error": err}
1407
+
1408
+ err = _validate_uuid(user_id, "user_id")
1409
+ if err:
1410
+ return {"error": err}
1411
+
1412
+ if not role or not isinstance(role, str):
1413
+ return {"error": "role is required and must be a string"}
1414
+ if role not in ("member", "admin"):
1415
+ return {"error": "role must be 'member' or 'admin'"}
1416
+
1417
+ try:
1418
+ result = self._request_with_retry(
1419
+ "PATCH",
1420
+ f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}",
1421
+ {"role": role},
1422
+ )
1423
+ return result if isinstance(result, dict) else {"data": result}
1424
+ except Exception as e:
1425
+ return {"error": _sanitize_error(str(e))}
1426
+
1427
+ # ==================================================================
1428
+ # Tool 22: x402_info
1429
+ # ==================================================================
1430
+
1431
+ def x402_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
1432
+ """Get x402 micropayment protocol information.
1433
+
1434
+ Args:
1435
+ params: Empty dict (no parameters needed).
1436
+
1437
+ Returns:
1438
+ Dict with x402 protocol information.
1439
+ """
1440
+ try:
1441
+ result = self._request_with_retry("GET", "/api/x402/info")
1442
+ return result if isinstance(result, dict) else {"data": result}
1443
+ except Exception as e:
1444
+ return {"error": _sanitize_error(str(e))}
1445
+
1446
+ # ==================================================================
1447
+ # Tool 23: topup_paypal
1448
+ # ==================================================================
1449
+
1450
+ def topup_paypal(self, params: Dict[str, Any]) -> Dict[str, Any]:
1451
+ """Create a PayPal top-up order for the user's wallet.
1452
+
1453
+ Args:
1454
+ params: Dict with key: amount_cents (required, 500-1000000).
1455
+
1456
+ Returns:
1457
+ Dict with orderId and approvalUrl.
1458
+ """
1459
+ amount_cents = params.get("amount_cents")
1460
+ if amount_cents is None:
1461
+ return {"error": "amount_cents is required"}
1462
+
1463
+ # Dify sends numbers as float; coerce to int
1464
+ if isinstance(amount_cents, float) and amount_cents == int(amount_cents):
1465
+ amount_cents = int(amount_cents)
1466
+
1467
+ err = _validate_positive_int(
1468
+ amount_cents, "amount_cents", min_value=500, max_value=1_000_000
1469
+ )
1470
+ if err:
1471
+ return {"error": err}
1472
+
1473
+ try:
1474
+ result = self._request_with_retry(
1475
+ "POST",
1476
+ "/api/wallet/topup/paypal",
1477
+ {"amountCents": amount_cents},
1478
+ )
1479
+ return result if isinstance(result, dict) else {"data": result}
1480
+ except Exception as e:
1481
+ return {"error": _sanitize_error(str(e))}
1482
+
1483
+ # ==================================================================
1484
+ # Tool dispatcher (Dify invokes tools by name)
1485
+ # ==================================================================
1486
+
1487
+ def invoke_tool(self, tool_name: str, params: Dict[str, Any]) -> Dict[str, Any]:
1488
+ """Dispatch a tool invocation by name.
1489
+
1490
+ This is the main entry point for Dify to invoke tools.
1491
+
1492
+ Args:
1493
+ tool_name: Tool name matching the YAML identity name.
1494
+ params: Dictionary of tool parameters.
1495
+
1496
+ Returns:
1497
+ Dict with the tool result or error.
1498
+ """
1499
+ dispatch: Dict[str, Any] = {
1500
+ "proxied_fetch": self.proxied_fetch,
1501
+ "check_balance": self.check_balance,
1502
+ "check_usage": self.check_usage,
1503
+ "get_proxy_config": self.get_proxy_config,
1504
+ "list_sessions": self.list_sessions,
1505
+ "create_agentic_wallet": self.create_agentic_wallet,
1506
+ "fund_agentic_wallet": self.fund_agentic_wallet,
1507
+ "agentic_wallet_balance": self.agentic_wallet_balance,
1508
+ "list_agentic_wallets": self.list_agentic_wallets,
1509
+ "agentic_transactions": self.agentic_transactions,
1510
+ "freeze_agentic_wallet": self.freeze_agentic_wallet,
1511
+ "unfreeze_agentic_wallet": self.unfreeze_agentic_wallet,
1512
+ "delete_agentic_wallet": self.delete_agentic_wallet,
1513
+ "update_wallet_policy": self.update_wallet_policy,
1514
+ "create_team": self.create_team,
1515
+ "list_teams": self.list_teams,
1516
+ "team_details": self.team_details,
1517
+ "team_fund": self.team_fund,
1518
+ "team_create_key": self.team_create_key,
1519
+ "team_usage": self.team_usage,
1520
+ "update_team": self.update_team,
1521
+ "update_team_member_role": self.update_team_member_role,
1522
+ "x402_info": self.x402_info,
1523
+ "topup_paypal": self.topup_paypal,
1524
+ }
1525
+
1526
+ handler = dispatch.get(tool_name)
1527
+ if handler is None:
1528
+ return {
1529
+ "error": f"Unknown tool: {tool_name}",
1530
+ "available_tools": sorted(dispatch.keys()),
1531
+ }
1532
+
1533
+ try:
1534
+ return handler(params)
1535
+ except Exception as e:
1536
+ return {"error": _sanitize_error(str(e))}
1537
+
1538
+ @staticmethod
1539
+ def get_tools() -> list[str]:
1540
+ """Return the list of all 22 tool names (plus topup_paypal = 23 entries).
1541
+
1542
+ Returns:
1543
+ Sorted list of tool name strings.
1544
+ """
1545
+ return sorted([
1546
+ "proxied_fetch",
1547
+ "check_balance",
1548
+ "check_usage",
1549
+ "get_proxy_config",
1550
+ "list_sessions",
1551
+ "create_agentic_wallet",
1552
+ "fund_agentic_wallet",
1553
+ "agentic_wallet_balance",
1554
+ "list_agentic_wallets",
1555
+ "agentic_transactions",
1556
+ "freeze_agentic_wallet",
1557
+ "unfreeze_agentic_wallet",
1558
+ "delete_agentic_wallet",
1559
+ "update_wallet_policy",
1560
+ "create_team",
1561
+ "list_teams",
1562
+ "team_details",
1563
+ "team_fund",
1564
+ "team_create_key",
1565
+ "team_usage",
1566
+ "update_team",
1567
+ "update_team_member_role",
1568
+ "x402_info",
1569
+ "topup_paypal",
1570
+ ])