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.
- dominusnode_dify/__init__.py +64 -0
- dominusnode_dify/tools.py +1570 -0
- dominusnode_dify-1.0.0.dist-info/LICENSE +21 -0
- dominusnode_dify-1.0.0.dist-info/METADATA +241 -0
- dominusnode_dify-1.0.0.dist-info/RECORD +7 -0
- dominusnode_dify-1.0.0.dist-info/WHEEL +5 -0
- dominusnode_dify-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
])
|