dominusnode-langchain 2.2.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.
@@ -0,0 +1,15 @@
1
+ """LangChain tools for the DominusNode AI-agent relay network (v2).
2
+
3
+ Example::
4
+
5
+ from dominusnode_langchain import DominusNodeToolkit
6
+
7
+ tools = DominusNodeToolkit().get_tools() # reads DOMINUSNODE_API_KEY
8
+ """
9
+
10
+ from ._core import TOOL_NAMES, DominusNodeTools
11
+ from .toolkit import DominusNodeToolkit, build_tools
12
+
13
+ __version__ = "2.2.0"
14
+
15
+ __all__ = ["DominusNodeToolkit", "DominusNodeTools", "build_tools", "TOOL_NAMES", "__version__"]
@@ -0,0 +1,522 @@
1
+ """Framework-neutral DominusNode v2 tool set, built on the ``dominusnode`` SDK.
2
+
3
+ Every framework adapter in this package wraps :class:`DominusNodeTools`. The 19
4
+ methods named in :data:`TOOL_NAMES` are the standard v2 integration tools. All
5
+ HTTP, SSRF/DNS-rebinding checks, gateway username building and validation live
6
+ in the SDK; this module only adapts arguments and formats results for an LLM.
7
+
8
+ Security properties kept here:
9
+
10
+ - The API key comes from ``DOMINUSNODE_API_KEY`` or constructor config, never
11
+ from a tool argument, and is never returned in a tool output.
12
+ - Every result and error string is passed through the SDK's
13
+ ``scrub_credentials`` (dn_live_/dn_test_/dn_node_/dn_proxy_ keys and JWTs).
14
+ - Signing flows (node register, withdraw, payout wallet) are not exposed.
15
+ """
16
+
17
+ import inspect
18
+ import json
19
+ import os
20
+ import re
21
+ from typing import Any, Callable, Dict, List, Optional, Tuple, get_type_hints
22
+
23
+ from dominusnode import (
24
+ DEFAULT_BASE_URL,
25
+ DEFAULT_HTTP_PROXY_PORT,
26
+ DEFAULT_PROXY_HOST,
27
+ DEFAULT_SOCKS5_PROXY_PORT,
28
+ DominusNodeClient,
29
+ json_default,
30
+ scrub_credentials,
31
+ )
32
+
33
+ API_KEY_ENV = "DOMINUSNODE_API_KEY"
34
+ BASE_URL_ENV = "DOMINUSNODE_BASE_URL"
35
+ PROXY_HOST_ENV = "DOMINUSNODE_PROXY_HOST"
36
+ PROXY_PORT_ENV = "DOMINUSNODE_PROXY_PORT"
37
+ PROXY_URL_ENV = "DOMINUSNODE_PROXY_URL"
38
+ PROXY_TLS_PORT_ENV = "DOMINUSNODE_PROXY_TLS_PORT"
39
+ PROXY_INSECURE_ENV = "DOMINUSNODE_PROXY_INSECURE"
40
+
41
+ MAX_BODY_CHARS = 100_000
42
+ DEFAULT_BODY_CHARS = 4_000
43
+ KEY_PLACEHOLDER = "<DOMINUSNODE_API_KEY>"
44
+
45
+ TOOL_NAMES: Tuple[str, ...] = (
46
+ "dominusnode_fetch",
47
+ "dominusnode_proxy_url",
48
+ "dominusnode_network_policy",
49
+ "dominusnode_network_pricing",
50
+ "dominusnode_network_stats",
51
+ "dominusnode_balance",
52
+ "dominusnode_transactions",
53
+ "dominusnode_topup_crypto",
54
+ "dominusnode_check_payment",
55
+ "dominusnode_usage",
56
+ "dominusnode_active_sessions",
57
+ "dominusnode_report_session",
58
+ "dominusnode_node_list",
59
+ "dominusnode_node_tier",
60
+ "dominusnode_node_setup_guide",
61
+ "dominusnode_credits_balance",
62
+ "dominusnode_credits_convert",
63
+ "dominusnode_payouts_list",
64
+ "dominusnode_x402_info",
65
+ )
66
+
67
+ NODE_SETUP_GUIDE = """DominusNode relay node setup (earn by contributing an IP)
68
+
69
+ How earning works
70
+ - Your node dials OUT to the gateway (no inbound ports) and relays other agents' traffic.
71
+ - You earn your tier's share of each paid GB relayed; live prices and shares are in
72
+ dominusnode_network_pricing. Earnings mature before they become available.
73
+ - Self-dealing earns nothing: your own, your wallet's, your team's and your /24's traffic is
74
+ never routed to your node.
75
+ - Tiers rise with paid tenure, uptime, active IPs and reputation (see dominusnode_node_tier).
76
+ - The node floor is fixed: ports 80/443 only, never private/loopback/metadata targets,
77
+ connection metadata only (no payload logging). See dominusnode_network_policy.
78
+
79
+ Prerequisites
80
+ - A wallet-linked account and a personal dn_live_ API key (not a team key).
81
+ - Docker on the machine whose IP you contribute.
82
+ - IPs in sanctioned countries (Cuba, Iran, North Korea, Russia, Syria) cannot join.
83
+
84
+ Step 1: Store the owner wallet key in a private file (never pass it as an argument or to an LLM)
85
+ umask 077 && printf '%s' "$WALLET_PRIVATE_KEY_HEX" > ~/.dominusnode/wallet.key
86
+
87
+ Step 2: Register the node with the published node image (official coordinator pins baked in;
88
+ signing happens locally, not through this tool)
89
+ sudo install -d -o 1001 -g 1001 -m 700 /etc/dominusnode
90
+ sudo install -o 1001 -g 1001 -m 600 ~/.dominusnode/wallet.key /etc/dominusnode/wallet.key
91
+ export DN_API_KEY # your dn_live_ key, from the environment only
92
+ docker run --rm -e DN_API_KEY -e DN_WALLET_KEY_FILE=/run/dominusnode/wallet.key \\
93
+ -v /etc/dominusnode:/run/dominusnode ghcr.io/0xcircuitbreaker/dominusnode-node register
94
+ sudo rm /etc/dominusnode/wallet.key # not needed after registration
95
+ (Alternatively use the DominusNode MCP server or SDK nodes.register with a signer key file.)
96
+
97
+ Step 3: Run the node agent on the machine that owns the IP
98
+ docker run -d --name dominusnode-node --restart unless-stopped -e DN_NODE_GB_PER_DAY=5 \\
99
+ -v /etc/dominusnode/node.json:/run/dominusnode/node.json:ro ghcr.io/0xcircuitbreaker/dominusnode-node
100
+ A plain source build has no coordinator pins and refuses to start: use the image, or pass
101
+ --coordinator <domain> --trust sha256:<fingerprint> (or DN_NODE_COORD_PINS) for another coordinator.
102
+ Keep it running (the restart policy above): uptime drives your tier.
103
+
104
+ Step 4: Verify
105
+ dominusnode_node_list shows status and last-seen time once the tunnel is up.
106
+ """
107
+
108
+
109
+ #: Gateway TLS (HTTPS proxy) port: production 8443; self-hosted coordinators use 8443 too.
110
+ DEFAULT_TLS_PROXY_PORT = 8443
111
+
112
+
113
+ #: credits_convert needs a wallet signature; signing flows are not exposed as LLM tools.
114
+ CREDITS_CONVERT_SIGNING_MESSAGE = (
115
+ "Converting credits needs a fresh signature from the wallet linked to this account, so this tool does not convert. Use the DominusNode MCP server (tool dominusnode_credits_convert, with DOMINUSNODE_WALLET_KEY_FILE pointing at a chmod-600 wallet key file) or a DominusNode SDK's credits.convert with a signer key file. Check available credit with credits_balance."
116
+ )
117
+
118
+
119
+ def resolve_gateway(
120
+ proxy_host: Optional[str] = None,
121
+ tls_port: Optional[int] = None,
122
+ http_port: Optional[int] = None,
123
+ insecure: Optional[bool] = None,
124
+ env: Optional[Any] = None,
125
+ ) -> Dict[str, Any]:
126
+ """Where proxy traffic goes. TLS to the gateway's HTTPS proxy is the default: production
127
+ gateways refuse credentials on plaintext 8080/1080. ``DOMINUSNODE_PROXY_URL=https://host[:port]``
128
+ sets host and TLS port (same variable as the MCP server), ``DOMINUSNODE_PROXY_TLS_PORT`` the TLS
129
+ port, and ``DOMINUSNODE_PROXY_INSECURE=1`` selects plaintext (local development only).
130
+
131
+ Returns ``{"proxy_host", "http_port", "gateway_tls"}`` where ``gateway_tls`` is ``False`` or
132
+ ``{"port": n}``, ready for ``DominusNodeClient(gateway_tls=...)``.
133
+ """
134
+ from urllib.parse import urlsplit
135
+
136
+ e = os.environ if env is None else env
137
+
138
+ def _port(v: Any) -> Optional[int]:
139
+ try:
140
+ n = int(v)
141
+ except (TypeError, ValueError):
142
+ return None
143
+ return n if 1 <= n <= 65535 else None
144
+
145
+ url_host: Optional[str] = None
146
+ url_port: Optional[int] = None
147
+ raw = (e.get("DOMINUSNODE_PROXY_URL") or "").strip()
148
+ if raw:
149
+ u = urlsplit(raw)
150
+ if u.scheme != "https" or not u.hostname:
151
+ raise ValueError("DOMINUSNODE_PROXY_URL must be https://host[:port] (TLS to the gateway)")
152
+ if u.username or u.password:
153
+ raise ValueError("DOMINUSNODE_PROXY_URL must not contain credentials")
154
+ url_host = u.hostname.lower()
155
+ try:
156
+ url_port = _port(u.port) if u.port else DEFAULT_TLS_PROXY_PORT
157
+ except ValueError:
158
+ raise ValueError("DOMINUSNODE_PROXY_URL has an invalid port") from None
159
+ if insecure is None:
160
+ insecure = (e.get("DOMINUSNODE_PROXY_INSECURE") or "").strip() == "1"
161
+ if insecure and raw:
162
+ raise ValueError("DOMINUSNODE_PROXY_URL (TLS) and DOMINUSNODE_PROXY_INSECURE=1 are mutually exclusive")
163
+ tls = _port(tls_port) or url_port or _port(e.get("DOMINUSNODE_PROXY_TLS_PORT")) or DEFAULT_TLS_PROXY_PORT
164
+ return {
165
+ "proxy_host": proxy_host or url_host or (e.get("DOMINUSNODE_PROXY_HOST") or "").strip() or None,
166
+ "http_port": _port(http_port) or _port(e.get("DOMINUSNODE_PROXY_PORT")),
167
+ "gateway_tls": False if insecure else {"port": tls},
168
+ }
169
+
170
+
171
+ _JSON_SCALARS = (dict, list, tuple, str, int, float, bool, type(None))
172
+
173
+
174
+ def _json_default(obj: object) -> object:
175
+ """SDK ``json_default`` (Secret -> [REDACTED]) with a ``str`` fallback for anything else."""
176
+ try:
177
+ out = json_default(obj)
178
+ except TypeError:
179
+ return str(obj)
180
+ return out if isinstance(out, _JSON_SCALARS) else str(out)
181
+
182
+
183
+ class DominusNodeTools:
184
+ """The 19 standard DominusNode v2 tools as plain Python methods returning strings.
185
+
186
+ Args:
187
+ api_key: Optional config value; defaults to the ``DOMINUSNODE_API_KEY`` env var.
188
+ Never accepted as a tool argument.
189
+ base_url: API base URL (default ``DOMINUSNODE_BASE_URL`` or the public API).
190
+ proxy_host: Gateway host for ``dominusnode_proxy_url``.
191
+ http_proxy_port: Gateway HTTP port.
192
+ client: A pre-built ``dominusnode.DominusNodeClient`` (already connected or not).
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ api_key: Optional[str] = None,
198
+ base_url: Optional[str] = None,
199
+ proxy_host: Optional[str] = None,
200
+ http_proxy_port: Optional[int] = None,
201
+ client: Any = None,
202
+ tls_proxy_port: Optional[int] = None,
203
+ insecure_proxy: Optional[bool] = None,
204
+ ) -> None:
205
+ self.__key = api_key or os.environ.get(API_KEY_ENV) or None
206
+ self._base_url = base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL
207
+ gw = resolve_gateway(proxy_host, tls_proxy_port, http_proxy_port, insecure_proxy)
208
+ self._proxy_host = gw["proxy_host"] or DEFAULT_PROXY_HOST
209
+ self._http_port = gw["http_port"] or DEFAULT_HTTP_PROXY_PORT
210
+ self._gateway_tls = gw["gateway_tls"]
211
+ self._client = client
212
+ self._connected = client is not None and getattr(client, "is_connected", False) is True
213
+
214
+ def __repr__(self) -> str:
215
+ return f"DominusNodeTools(configured={'true' if self.__key or self._client else 'false'})"
216
+
217
+ __str__ = __repr__
218
+
219
+ def __reduce__(self) -> Any:
220
+ raise TypeError("DominusNodeTools cannot be pickled")
221
+
222
+ # -- plumbing ---------------------------------------------------------
223
+
224
+ def _sdk(self) -> Any:
225
+ if self._client is None:
226
+ self._client = DominusNodeClient(
227
+ base_url=self._base_url,
228
+ proxy_host=self._proxy_host,
229
+ http_proxy_port=self._http_port,
230
+ socks5_proxy_port=DEFAULT_SOCKS5_PROXY_PORT,
231
+ gateway_tls=self._gateway_tls,
232
+ )
233
+ if not self._connected:
234
+ if not self.__key:
235
+ raise RuntimeError(f"DominusNode API key not configured: set {API_KEY_ENV}")
236
+ self._client.connect_with_key(self.__key)
237
+ self._connected = True
238
+ return self._client
239
+
240
+ def _clean(self, text: str) -> str:
241
+ out = scrub_credentials(text)
242
+ if self.__key and len(self.__key) >= 8:
243
+ out = out.replace(self.__key, "[REDACTED]")
244
+ return out
245
+
246
+ def _ok(self, value: Any) -> str:
247
+ if isinstance(value, str):
248
+ return self._clean(value)
249
+ return self._clean(json.dumps(value, default=_json_default, indent=2, sort_keys=True))
250
+
251
+ def _err(self, exc: BaseException) -> str:
252
+ return "Error: " + self._clean(f"{type(exc).__name__}: {exc}")
253
+
254
+ def _call(self, fn: Callable[[Any], Any]) -> str:
255
+ try:
256
+ return self._ok(fn(self._sdk()))
257
+ except Exception as exc: # noqa: BLE001 - every error is returned scrubbed to the model
258
+ return self._err(exc)
259
+
260
+ def close(self) -> None:
261
+ if self._client is not None:
262
+ try:
263
+ self._client.disconnect()
264
+ except Exception: # noqa: BLE001
265
+ pass
266
+ self._connected = False
267
+
268
+ # -- 1-2 proxy ---------------------------------------------------------
269
+
270
+ def dominusnode_fetch(
271
+ self,
272
+ url: str,
273
+ method: str = "GET",
274
+ country: Optional[str] = None,
275
+ ip_class: Optional[str] = None,
276
+ session_id: Optional[str] = None,
277
+ allow_http: bool = False,
278
+ max_chars: int = DEFAULT_BODY_CHARS,
279
+ ) -> str:
280
+ """Fetch a public URL through the DominusNode relay network of contributed exit IPs.
281
+
282
+ Billed per GB at the live price from dominusnode_network_pricing. GET or HEAD only, ports
283
+ 80/443, private/local targets refused, redirects returned (not followed), 10 MB cap.
284
+ HTTPS is end-to-end encrypted; plaintext http:// needs allow_http=true and is visible to
285
+ the exit node, so never send secrets over it.
286
+
287
+ Args:
288
+ url: Absolute https:// (or http:// with allow_http) URL to fetch.
289
+ method: GET or HEAD.
290
+ country: Optional ISO-2 exit country, e.g. US or DE (sanctioned countries refused).
291
+ ip_class: Optional exit class: residential, dc, mobile or masked (masked = cheapest, VPN/anonymizer exits, opt-in).
292
+ session_id: Optional sticky-session id (1-64 letters/digits) to keep the same exit.
293
+ allow_http: Opt in to plaintext http:// (exit-visible).
294
+ max_chars: Maximum body characters returned to you (1-100000, default 4000).
295
+ """
296
+
297
+ def run(dn: Any) -> Any:
298
+ limit = max(1, min(int(max_chars), MAX_BODY_CHARS))
299
+ r = dn.proxy.fetch(
300
+ url,
301
+ method=str(method).upper(),
302
+ country=country,
303
+ ip_class=ip_class,
304
+ session_id=session_id,
305
+ allow_http=bool(allow_http),
306
+ )
307
+ body = r.text()
308
+ keep = ("content-type", "content-length", "location", "retry-after", "last-modified")
309
+ return {
310
+ "status": r.status,
311
+ "statusText": r.status_text,
312
+ "headers": {k: v for k, v in r.headers.items() if k.lower() in keep},
313
+ "body": body[:limit],
314
+ "bodyTruncated": bool(r.truncated) or len(body) > limit,
315
+ }
316
+
317
+ return self._call(run)
318
+
319
+ def dominusnode_proxy_url(
320
+ self,
321
+ country: Optional[str] = None,
322
+ ip_class: Optional[str] = None,
323
+ session_id: Optional[str] = None,
324
+ allow_http: bool = False,
325
+ ) -> str:
326
+ """Build the gateway proxy URL for routing your own HTTP client through the relay network.
327
+
328
+ Routing flags go in the proxy username (hyphenated, e.g. country-US-class-dc); the API key
329
+ goes in the password. The key is NOT returned: the URL contains a placeholder that your
330
+ runtime substitutes from the DOMINUSNODE_API_KEY environment variable.
331
+
332
+ Args:
333
+ country: Optional ISO-2 exit country (sanctioned countries refused).
334
+ ip_class: Optional exit class: residential, dc, mobile or masked (masked = cheapest, VPN/anonymizer exits, opt-in).
335
+ session_id: Optional sticky-session id (1-64 letters/digits).
336
+ allow_http: Allow plaintext http:// targets (visible to the exit node).
337
+ """
338
+
339
+ def run(dn: Any) -> Any:
340
+ user = dn.proxy.build_username(
341
+ country=country, ip_class=ip_class, session_id=session_id, allow_http=bool(allow_http)
342
+ )
343
+ return {
344
+ "username": user,
345
+ "proxyUrl": (
346
+ f"https://{user}:{KEY_PLACEHOLDER}@{self._proxy_host}:{self._gateway_tls['port']}"
347
+ if self._gateway_tls
348
+ else f"http://{user}:{KEY_PLACEHOLDER}@{self._proxy_host}:{self._http_port}"
349
+ ),
350
+ "note": f"Replace {KEY_PLACEHOLDER} with the value of ${API_KEY_ENV}; never log the real URL.",
351
+ }
352
+
353
+ return self._call(run)
354
+
355
+ # -- 3-5 network -------------------------------------------------------
356
+
357
+ def dominusnode_network_policy(self) -> str:
358
+ """Show the relay network policy: allowed ports, blocked targets, sanctioned countries, logging rules."""
359
+ return self._call(lambda dn: dn.network.policy())
360
+
361
+ def dominusnode_network_pricing(self) -> str:
362
+ """Show live per-GB prices by IP class (in µUSD; 1 USD = 1,000,000 µUSD) and node earning shares.
363
+
364
+ Always use this instead of assuming prices.
365
+ """
366
+ return self._call(lambda dn: dn.network.pricing())
367
+
368
+ def dominusnode_network_stats(self) -> str:
369
+ """Show relay network statistics: online nodes, countries and IP classes available."""
370
+ return self._call(lambda dn: dn.network.stats())
371
+
372
+ # -- 6-9 wallet --------------------------------------------------------
373
+
374
+ def dominusnode_balance(self) -> str:
375
+ """Show your prepaid wallet balance used to pay for relay traffic."""
376
+ return self._call(lambda dn: dn.wallet.get_balance())
377
+
378
+ def dominusnode_transactions(self, limit: int = 20, offset: int = 0) -> str:
379
+ """List recent wallet transactions (top-ups and usage charges).
380
+
381
+ Args:
382
+ limit: Number of transactions to return (1-100).
383
+ offset: Number of transactions to skip.
384
+ """
385
+ return self._call(lambda dn: dn.wallet.get_transactions(limit=limit, offset=offset))
386
+
387
+ def dominusnode_topup_crypto(self, amount_cents: int, currency: str) -> str:
388
+ """Create a crypto invoice to top up your wallet ($10-$1,000).
389
+
390
+ Returns a payment address/invoice; check it with dominusnode_check_payment.
391
+
392
+ Args:
393
+ amount_cents: Amount in US cents, 1000 to 100000.
394
+ currency: One of btc, eth, ltc, xmr, zec, usdc, sol, usdt, dai, bnb, link.
395
+ """
396
+ return self._call(lambda dn: dn.wallet.top_up_crypto(amount_cents, currency))
397
+
398
+ def dominusnode_check_payment(self, invoice_id: str) -> str:
399
+ """Check the status of a crypto top-up invoice.
400
+
401
+ Args:
402
+ invoice_id: Invoice UUID returned by dominusnode_topup_crypto.
403
+ """
404
+ return self._call(lambda dn: dn.wallet.get_crypto_status(invoice_id))
405
+
406
+ # -- 10-12 usage and sessions -----------------------------------------
407
+
408
+ def dominusnode_usage(self, since: Optional[str] = None, until: Optional[str] = None) -> str:
409
+ """Summarise your relay bandwidth usage and cost.
410
+
411
+ Args:
412
+ since: Optional ISO-8601 start date/time.
413
+ until: Optional ISO-8601 end date/time.
414
+ """
415
+ return self._call(lambda dn: dn.usage.get(from_=since, to=until))
416
+
417
+ def dominusnode_active_sessions(self) -> str:
418
+ """List your active proxy sessions (id, start time, status; exit IPs are never shown)."""
419
+ return self._call(lambda dn: dn.sessions.get_active())
420
+
421
+ def dominusnode_report_session(self, session_id: str, reason: str) -> str:
422
+ """Report a bad relay session so the exit node's reputation is updated.
423
+
424
+ Args:
425
+ session_id: Session UUID from dominusnode_active_sessions.
426
+ reason: One of blocked, captcha, rate_limited, slow, bad_content, other.
427
+ """
428
+ return self._call(lambda dn: dn.sessions.report(session_id, reason))
429
+
430
+ # -- 13-15 nodes -------------------------------------------------------
431
+
432
+ def dominusnode_node_list(self, limit: int = 50, offset: int = 0) -> str:
433
+ """List the relay nodes you contribute (status, class, last seen).
434
+
435
+ Args:
436
+ limit: Number of nodes to return (1-500).
437
+ offset: Number of nodes to skip.
438
+ """
439
+ return self._call(lambda dn: dn.nodes.list(limit=limit, offset=offset))
440
+
441
+ def dominusnode_node_tier(self) -> str:
442
+ """Show your node-operator tier, earning share and progress to the next tier."""
443
+ return self._call(lambda dn: dn.nodes.tier())
444
+
445
+ def dominusnode_node_setup_guide(self) -> str:
446
+ """Explain how to earn by contributing an IP: run the DominusNode node agent, step by step."""
447
+ return self._ok(NODE_SETUP_GUIDE)
448
+
449
+ # -- 16-18 earnings ----------------------------------------------------
450
+
451
+ def dominusnode_credits_balance(self) -> str:
452
+ """Show node-operator credits earned by relaying traffic (µUSD; pending and available)."""
453
+ return self._call(lambda dn: dn.credits.balance())
454
+
455
+ def dominusnode_credits_convert(self, amount_musd: str, idempotency_key: Optional[str] = None) -> str:
456
+ """Explain how to convert earned credits into wallet balance (needs a wallet signature, so not done here).
457
+
458
+ Args:
459
+ amount_musd: Amount in µUSD as a digit string (10000 = $0.01; min 10000).
460
+ idempotency_key: Optional key from a previous attempt, to retry safely.
461
+ """
462
+ return self._ok({"status": "signing_required", "message": CREDITS_CONVERT_SIGNING_MESSAGE})
463
+
464
+ def dominusnode_payouts_list(self, limit: int = 20, offset: int = 0) -> str:
465
+ """List your on-chain payouts (withdrawals of earned credits). Withdrawing needs a signer, not this tool.
466
+
467
+ Args:
468
+ limit: Number of payouts to return (1-200).
469
+ offset: Number of payouts to skip.
470
+ """
471
+ return self._call(lambda dn: dn.payouts.list(limit=limit, offset=offset))
472
+
473
+ # -- 19 x402 -----------------------------------------------------------
474
+
475
+ def dominusnode_x402_info(self) -> str:
476
+ """Show x402 (HTTP 402 micropayment) support details for paying per request."""
477
+ return self._call(lambda dn: dn.x402.get_info())
478
+
479
+ # -- helpers for adapters ---------------------------------------------
480
+
481
+ def tool_functions(self) -> List[Callable[..., str]]:
482
+ """The 19 bound tool methods, in standard order."""
483
+ return [getattr(self, n) for n in TOOL_NAMES]
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Docstring helpers shared by adapters
488
+ # ---------------------------------------------------------------------------
489
+
490
+ _ARGS_RE = re.compile(r"^\s{4,}(\w+):\s*(.+)$")
491
+
492
+
493
+ def describe(fn: Callable[..., Any]) -> str:
494
+ """Tool description: the docstring without its Args section."""
495
+ doc = inspect.getdoc(fn) or ""
496
+ return doc.split("\nArgs:")[0].strip()
497
+
498
+
499
+ def arg_docs(fn: Callable[..., Any]) -> Dict[str, str]:
500
+ doc = fn.__doc__ or ""
501
+ if "Args:" not in doc:
502
+ return {}
503
+ out: Dict[str, str] = {}
504
+ for line in doc.split("Args:", 1)[1].splitlines():
505
+ m = _ARGS_RE.match(line)
506
+ if m:
507
+ out[m.group(1)] = m.group(2).strip()
508
+ return out
509
+
510
+
511
+ def args_model(fn: Callable[..., Any], model_name: str) -> Any:
512
+ """Build a pydantic model for a tool method's arguments (for schema-driven frameworks)."""
513
+ from pydantic import Field, create_model
514
+
515
+ docs = arg_docs(fn)
516
+ hints = get_type_hints(fn)
517
+ fields: Dict[str, Any] = {}
518
+ for name, p in inspect.signature(fn).parameters.items():
519
+ ann = hints.get(name, str)
520
+ default = ... if p.default is inspect.Parameter.empty else p.default
521
+ fields[name] = (ann, Field(default, description=docs.get(name, name)))
522
+ return create_model(model_name, **fields)
@@ -0,0 +1,66 @@
1
+ """LangChain toolkit exposing the 19 standard DominusNode v2 tools."""
2
+
3
+ from typing import Any, List, Optional
4
+
5
+ from langchain_core.tools import BaseTool, BaseToolkit, StructuredTool
6
+ from pydantic import ConfigDict, PrivateAttr
7
+
8
+ from ._core import TOOL_NAMES, DominusNodeTools, args_model, describe
9
+
10
+
11
+ def _model_name(tool_name: str) -> str:
12
+ return "".join(p.capitalize() for p in tool_name.split("_")) + "Input"
13
+
14
+
15
+ def build_tools(core: DominusNodeTools) -> List[BaseTool]:
16
+ """Wrap each core method as a LangChain ``StructuredTool``."""
17
+ tools: List[BaseTool] = []
18
+ for name in TOOL_NAMES:
19
+ fn = getattr(core, name)
20
+ tools.append(
21
+ StructuredTool.from_function(
22
+ func=fn,
23
+ name=name,
24
+ description=describe(fn),
25
+ args_schema=args_model(fn, _model_name(name)),
26
+ handle_tool_error=True,
27
+ )
28
+ )
29
+ return tools
30
+
31
+
32
+ class DominusNodeToolkit(BaseToolkit):
33
+ """DominusNode relay-network tools for LangChain agents.
34
+
35
+ The API key is read from ``DOMINUSNODE_API_KEY`` (or passed as config here); it is
36
+ never a tool argument and never appears in tool output.
37
+
38
+ Example::
39
+
40
+ toolkit = DominusNodeToolkit()
41
+ agent = create_agent(model, tools=toolkit.get_tools())
42
+ """
43
+
44
+ model_config = ConfigDict(arbitrary_types_allowed=True)
45
+
46
+ _core: DominusNodeTools = PrivateAttr()
47
+
48
+ def __init__(
49
+ self,
50
+ api_key: Optional[str] = None,
51
+ base_url: Optional[str] = None,
52
+ proxy_host: Optional[str] = None,
53
+ http_proxy_port: Optional[int] = None,
54
+ client: Any = None,
55
+ **kwargs: Any,
56
+ ) -> None:
57
+ super().__init__(**kwargs)
58
+ self._core = DominusNodeTools(
59
+ api_key=api_key, base_url=base_url, proxy_host=proxy_host, http_proxy_port=http_proxy_port, client=client
60
+ )
61
+
62
+ def get_tools(self) -> List[BaseTool]:
63
+ return build_tools(self._core)
64
+
65
+ def __repr__(self) -> str:
66
+ return "DominusNodeToolkit()"
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.5
2
+ Name: dominusnode-langchain
3
+ Version: 2.2.0
4
+ Summary: LangChain tools for the DominusNode AI-agent relay network (19 v2 tools on the dominusnode SDK)
5
+ Project-URL: Homepage, https://github.com/0xcircuitbreaker/DomiNode/tree/master/integrations/langchain
6
+ Project-URL: Repository, https://github.com/0xcircuitbreaker/DomiNode
7
+ Project-URL: Issues, https://github.com/0xcircuitbreaker/DomiNode/issues
8
+ Project-URL: Changelog, https://github.com/0xcircuitbreaker/DomiNode/blob/master/integrations/langchain/CHANGELOG.md
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.9
12
+ Requires-Dist: dominusnode<3,>=2.2.0
13
+ Requires-Dist: langchain-core>=0.3.0
14
+ Requires-Dist: pydantic>=2.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # dominusnode-langchain
20
+
21
+ LangChain tools for the **DominusNode AI-agent relay network** (v2). Agents fetch the web through
22
+ exit IPs contributed by other operators, pay per GB from a prepaid wallet (live prices from
23
+ `dominusnode_network_pricing`, nothing hardcoded), and can earn by contributing their own IP
24
+ with the DominusNode node agent.
25
+
26
+ Built on the official [`dominusnode`](../../sdks/python) Python SDK v2: all HTTP, SSRF and
27
+ DNS-rebinding checks, gateway username flags and validation come from the SDK.
28
+
29
+ ```bash
30
+ pip install dominusnode-langchain
31
+ export DOMINUSNODE_API_KEY=dn_live_... # read from the environment, never a tool argument
32
+ ```
33
+
34
+ Defaults: API `https://api.dominusnode.com`, relay gateway `proxy.dominusnode.com`. Override with `DOMINUSNODE_BASE_URL` / `DOMINUSNODE_PROXY_HOST`. Proxy traffic uses TLS to the gateway (HTTPS proxy, port 8443). For a self-hosted coordinator set `DOMINUSNODE_PROXY_URL=https://<proxy-host>:8443`; `DOMINUSNODE_PROXY_INSECURE=1` selects plaintext 8080 for local development only (production gateways refuse it).
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ from langchain.agents import create_agent
40
+ from dominusnode_langchain import DominusNodeToolkit
41
+
42
+ tools = DominusNodeToolkit().get_tools() # 19 StructuredTools
43
+ agent = create_agent("anthropic:claude-sonnet-4-5", tools=tools)
44
+ agent.invoke({"messages": [{"role": "user", "content": "What does residential traffic cost per GB?"}]})
45
+ ```
46
+
47
+ `DominusNodeToolkit` is a LangChain `BaseToolkit`; each tool is a `StructuredTool` with a
48
+ pydantic `args_schema`, and errors come back as scrubbed `Error: ...` strings.
49
+
50
+ ## The 19 tools
51
+
52
+ | # | Tool | What it does | SDK call |
53
+ |---|---|---|---|
54
+ | 1 | `dominusnode_fetch` | Fetch a public URL through the relay (GET/HEAD; country, ip_class, session_id, allow_http) | `proxy.fetch` |
55
+ | 2 | `dominusnode_proxy_url` | Gateway proxy URL/username for your own client (key shown as a placeholder) | `proxy.build_username` |
56
+ | 3 | `dominusnode_network_policy` | Relay policy: ports, blocked targets, sanctioned countries | `network.policy` |
57
+ | 4 | `dominusnode_network_pricing` | Live per-GB prices by IP class and node earning shares | `network.pricing` |
58
+ | 5 | `dominusnode_network_stats` | Online nodes, countries, IP classes | `network.stats` |
59
+ | 6 | `dominusnode_balance` | Prepaid wallet balance | `wallet.get_balance` |
60
+ | 7 | `dominusnode_transactions` | Wallet transactions | `wallet.get_transactions` |
61
+ | 8 | `dominusnode_topup_crypto` | Crypto top-up invoice ($10-$1,000) | `wallet.top_up_crypto` |
62
+ | 9 | `dominusnode_check_payment` | Crypto invoice status | `wallet.get_crypto_status` |
63
+ | 10 | `dominusnode_usage` | Bandwidth usage and cost | `usage.get` |
64
+ | 11 | `dominusnode_active_sessions` | Active sessions (no IPs) | `sessions.get_active` |
65
+ | 12 | `dominusnode_report_session` | Report a bad session | `sessions.report` |
66
+ | 13 | `dominusnode_node_list` | Your relay nodes | `nodes.list` |
67
+ | 14 | `dominusnode_node_tier` | Node-operator tier and share | `nodes.tier` |
68
+ | 15 | `dominusnode_node_setup_guide` | Steps to earn by running the node agent | `static text` |
69
+ | 16 | `dominusnode_credits_balance` | Earned node credits | `credits.balance` |
70
+ | 17 | `dominusnode_credits_convert` | Points to the MCP server or SDK (needs a wallet signature) | none (signed flow) |
71
+ | 18 | `dominusnode_payouts_list` | On-chain payouts | `payouts.list` |
72
+ | 19 | `dominusnode_x402_info` | x402 micropayment info | `x402.get_info` |
73
+
74
+ ## Security
75
+
76
+ - The API key comes from `DOMINUSNODE_API_KEY` (or constructor config). It is never a tool
77
+ argument, never in a tool schema and never in tool output; `dominusnode_proxy_url` returns a
78
+ `<DOMINUSNODE_API_KEY>` placeholder.
79
+ - Every result and error is scrubbed of `dn_live_`/`dn_test_`/`dn_node_`/`dn_proxy_` keys and JWTs.
80
+ - `dominusnode_fetch` (via the SDK): GET/HEAD only, ports 80/443, private/local hosts and
81
+ DNS-rebinding refused, redirects not followed, 10 MB cap, sanctioned countries refused.
82
+ Plaintext `http://` needs `allow_http=true` and is visible to the exit node.
83
+ - Signed flows (node registration, withdrawals, payout wallet) are not LLM tools: use the
84
+ DominusNode MCP server, the node agent, or the SDK with a signer key file.
85
+
86
+ Configuration env vars: `DOMINUSNODE_API_KEY`, `DOMINUSNODE_BASE_URL`, `DOMINUSNODE_PROXY_HOST`,
87
+ `DOMINUSNODE_PROXY_URL` (`https://host[:port]`, TLS; default port 8443, self-host 8443 too),
88
+ `DOMINUSNODE_PROXY_TLS_PORT`, `DOMINUSNODE_PROXY_INSECURE=1` (plaintext, local dev only) and
89
+ `DOMINUSNODE_PROXY_PORT` (plaintext port).
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ pip install -e ../../sdks/python -e '.[dev]'
95
+ pytest
96
+ ```
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,7 @@
1
+ dominusnode_langchain/__init__.py,sha256=MLwBSvMzjoC9LKlKsEqXs1ZOrv3xvObV-KC4l58CDUE,440
2
+ dominusnode_langchain/_core.py,sha256=UVO9THD4td2Xxx9_JzGTvm0LF_WK9pmw2MhAsa1_icA,22528
3
+ dominusnode_langchain/toolkit.py,sha256=vBPfvAhknqav9D4_-fRBvwh0G0nCDpAcBt3erXMz4-4,2042
4
+ dominusnode_langchain-2.2.0.dist-info/METADATA,sha256=fDOyn-rYQHPetZw_nrZlIKCRNLkU0v-0R0N_DE1zBfE,5449
5
+ dominusnode_langchain-2.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ dominusnode_langchain-2.2.0.dist-info/licenses/LICENSE,sha256=NBkjfgN0tpbJjWf7uqsPCOMgq7uuSEW4uaGDCpB8HbI,1069
7
+ dominusnode_langchain-2.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominus Node
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.