merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
950
rmi_sdk.py
Normal file
950
rmi_sdk.py
Normal file
|
|
@ -0,0 +1,950 @@
|
|||
"""
|
||||
RMI Python SDK v2.0 — Production-Grade Client
|
||||
===============================================
|
||||
Rug Munch Intelligence Python SDK with:
|
||||
- Async/sync dual API (httpx)
|
||||
- Automatic retry with exponential backoff
|
||||
- Batch tool calls (parallel execution)
|
||||
- Webhook registration and management
|
||||
- Developer API key support
|
||||
- x402 payment handling
|
||||
- Type hints throughout
|
||||
- Proper error classes
|
||||
|
||||
Install: pip install rmi-sdk (or copy this file as rmi_sdk.py)
|
||||
|
||||
Usage:
|
||||
from rmi_sdk import RMI
|
||||
rmi = RMI(api_key="rmi_dev_...") # free tier, 100 calls/day
|
||||
result = rmi.scan("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
|
||||
|
||||
# Async
|
||||
async with AsyncRMI(api_key="rmi_dev_...") as rmi:
|
||||
result = await rmi.scan("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
|
||||
|
||||
# Batch
|
||||
results = rmi.batch([
|
||||
("scan", {"address": "0x..."}),
|
||||
("audit", {"address": "0x..."}),
|
||||
("whale", {"address": "0x..."}),
|
||||
])
|
||||
|
||||
Author: RMI Development
|
||||
Date: 2026-06-05
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, TypeVar
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
HTTPX_AVAILABLE = True
|
||||
except ImportError:
|
||||
HTTPX_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
ASYNCIO_AVAILABLE = True
|
||||
except ImportError:
|
||||
ASYNCIO_AVAILABLE = False
|
||||
|
||||
# ── Error Classes ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RMIError(Exception):
|
||||
"""Base RMI SDK error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RMIConnectionError(RMIError):
|
||||
"""Failed to connect to RMI API."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RMITimeoutError(RMIError):
|
||||
"""Request timed out."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RMIAuthenticationError(RMIError):
|
||||
"""Invalid or missing API key."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RMIRateLimitError(RMIError):
|
||||
"""Rate limit exceeded."""
|
||||
|
||||
def __init__(self, message: str, retry_after: int = 60, remaining: int = 0):
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
self.remaining = remaining
|
||||
|
||||
|
||||
class RMIPaymentRequired(RMIError):
|
||||
"""Payment required for this tool call."""
|
||||
|
||||
def __init__(self, message: str, pay_to: str = "", amount: str = "", chain: str = ""):
|
||||
super().__init__(message)
|
||||
self.pay_to = pay_to
|
||||
self.amount = amount
|
||||
self.chain = chain
|
||||
|
||||
|
||||
class RMIToolError(RMIError):
|
||||
"""Tool execution failed."""
|
||||
|
||||
def __init__(self, tool: str, message: str, status: str = "error"):
|
||||
super().__init__(message)
|
||||
self.tool = tool
|
||||
self.status = status
|
||||
|
||||
|
||||
class RMIDataError(RMIError):
|
||||
"""No data returned for the query."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ── Data Classes ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""Result from a tool call."""
|
||||
|
||||
tool: str
|
||||
data: dict[str, Any]
|
||||
status: str = "ok"
|
||||
enrichment: dict[str, Any] | None = None
|
||||
intel: dict[str, Any] | None = None
|
||||
confidence: dict[str, Any] | None = None
|
||||
trial_remaining: int = 0
|
||||
cache_hit: bool = False
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_ok(self) -> bool:
|
||||
return self.status in ("ok", "success")
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
if not self.data:
|
||||
return False
|
||||
# Check for meaningful data (not just empty dicts/lists)
|
||||
return any(v is not None and v != [] and v != {} for v in self.data.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchResult:
|
||||
"""Result from a batch tool call."""
|
||||
|
||||
results: list[ToolResult]
|
||||
successful: int = 0
|
||||
failed: int = 0
|
||||
total_time_ms: float = 0
|
||||
|
||||
@property
|
||||
def success_rate(self) -> float:
|
||||
total = self.successful + self.failed
|
||||
return (self.successful / total * 100) if total > 0 else 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Webhook:
|
||||
"""Registered webhook."""
|
||||
|
||||
id: str
|
||||
url: str
|
||||
events: list[str]
|
||||
address: str
|
||||
active: bool = True
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TierInfo:
|
||||
"""Developer tier information."""
|
||||
|
||||
name: str
|
||||
daily_limit: int
|
||||
rate_limit_per_min: int
|
||||
price: float | str
|
||||
webhook_support: bool
|
||||
priority_queue: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageInfo:
|
||||
"""API key usage information."""
|
||||
|
||||
tier: str
|
||||
email: str
|
||||
status: str
|
||||
today_calls: int
|
||||
daily_limit: int
|
||||
remaining_today: int
|
||||
total_calls: int
|
||||
created_at: str
|
||||
rate_limit_per_min: int
|
||||
|
||||
|
||||
# ── Retry Logic ───────────────────────────────────────────────────
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def retry_with_backoff(
|
||||
func: Callable[..., T],
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
exponential_base: float = 2.0,
|
||||
retryable_errors: tuple[type, ...] = (RMIConnectionError, RMITimeoutError),
|
||||
) -> T:
|
||||
"""Execute a function with exponential backoff retry."""
|
||||
last_exception = None
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return func()
|
||||
except retryable_errors as e:
|
||||
last_exception = e
|
||||
if attempt == max_retries:
|
||||
break
|
||||
delay = min(base_delay * (exponential_base**attempt), max_delay)
|
||||
# Add jitter
|
||||
import random
|
||||
|
||||
delay *= 0.5 + random.random()
|
||||
time.sleep(delay)
|
||||
raise last_exception or RMIError("Retry failed")
|
||||
|
||||
|
||||
# ── Sync SDK ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RMI:
|
||||
"""Rug Munch Intelligence client with automatic x402 payment handling."""
|
||||
|
||||
DEFAULT_BASE = "https://mcp.rugmunch.io/api/v1/x402-tools"
|
||||
DEFAULT_DEV_BASE = "https://mcp.rugmunch.io/api/v1/developer"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
dev_base_url: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 1.0,
|
||||
fingerprint: str | None = None,
|
||||
wallet_identity: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize RMI client.
|
||||
|
||||
Args:
|
||||
api_key: Developer API key (from /api/v1/developer/register).
|
||||
If not provided, uses trial mode (limited calls).
|
||||
base_url: Base URL for x402 tools API.
|
||||
dev_base_url: Base URL for developer API.
|
||||
timeout: Request timeout in seconds.
|
||||
max_retries: Number of retries on transient errors.
|
||||
retry_delay: Base delay between retries (seconds).
|
||||
fingerprint: Client fingerprint for trial tracking.
|
||||
wallet_identity: Verified wallet identity (from /developer/wallet/verify).
|
||||
"""
|
||||
if not HTTPX_AVAILABLE:
|
||||
raise ImportError(
|
||||
"httpx is required: pip install httpx. Or use the fallback urllib mode (limited features)."
|
||||
)
|
||||
|
||||
self.api_key = api_key or os.environ.get("RMI_API_KEY")
|
||||
self.base = (base_url or os.environ.get("RMI_API_BASE") or self.DEFAULT_BASE).rstrip("/")
|
||||
self.dev_base = (dev_base_url or os.environ.get("RMI_DEV_BASE") or self.DEFAULT_DEV_BASE).rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
self.wallet_identity = wallet_identity
|
||||
|
||||
# Generate fingerprint if not provided
|
||||
if fingerprint:
|
||||
self._fingerprint = fingerprint
|
||||
else:
|
||||
self._fingerprint = hashlib.sha256(f"rmi-sdk-v2-{os.uname().nodename}-{time.time()}".encode()).hexdigest()[
|
||||
:32
|
||||
]
|
||||
|
||||
# Create client
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-fingerprint": self._fingerprint,
|
||||
"User-Agent": "rmi-python-sdk/2.0",
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-RMI-Dev-Key"] = self.api_key
|
||||
if self.wallet_identity:
|
||||
headers["X-Wallet-Identity"] = self.wallet_identity
|
||||
|
||||
self._client = httpx.Client(
|
||||
base_url=self.base,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(timeout, connect=10.0),
|
||||
http2=True, # Use HTTP/2 if available
|
||||
)
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict | None = None,
|
||||
json_data: dict | None = None,
|
||||
headers: dict | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Make an HTTP request with retry logic."""
|
||||
f"{base_url or self.base}/{path.lstrip('/')}"
|
||||
|
||||
def _do_request():
|
||||
try:
|
||||
if method.upper() == "GET":
|
||||
return self._client.get(path, params=params, headers=headers)
|
||||
else:
|
||||
return self._client.post(path, json=json_data, headers=headers)
|
||||
except httpx.ConnectTimeout as e:
|
||||
raise RMITimeoutError(f"Connection timeout: {e}") from e
|
||||
except httpx.ReadTimeout as e:
|
||||
raise RMITimeoutError(f"Read timeout: {e}") from e
|
||||
except httpx.ConnectError as e:
|
||||
raise RMIConnectionError(f"Connection failed: {e}") from e
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise RMIAuthenticationError("Invalid API key") from e
|
||||
elif e.response.status_code == 402:
|
||||
body = e.response.json() if e.response.content else {}
|
||||
raise RMIPaymentRequired(
|
||||
message=body.get("error", "Payment required"),
|
||||
pay_to=body.get("pay_to", ""),
|
||||
amount=body.get("amount", ""),
|
||||
chain=body.get("chain", ""),
|
||||
) from e
|
||||
elif e.response.status_code == 429:
|
||||
retry_after = int(e.response.headers.get("Retry-After", 60))
|
||||
raise RMIRateLimitError(
|
||||
message="Rate limit exceeded",
|
||||
retry_after=retry_after,
|
||||
) from e
|
||||
else:
|
||||
raise RMIToolError(
|
||||
tool=path.split("/")[-1],
|
||||
message=f"HTTP {e.response.status_code}: {e.response.text[:200]}",
|
||||
) from e
|
||||
|
||||
return retry_with_backoff(
|
||||
_do_request,
|
||||
max_retries=self.max_retries,
|
||||
base_delay=self.retry_delay,
|
||||
retryable_errors=(RMIConnectionError, RMITimeoutError),
|
||||
)
|
||||
|
||||
def _parse_response(self, response: httpx.Response, tool: str) -> ToolResult:
|
||||
"""Parse an API response into a ToolResult."""
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
raise RMIToolError(tool, f"Invalid JSON response: {response.text[:200]}")
|
||||
|
||||
# Check for error
|
||||
if "error" in data and not data.get("result"):
|
||||
raise RMIToolError(tool, data["error"], status=data.get("status", "error"))
|
||||
|
||||
# Check for no data
|
||||
if data.get("status") == "no_data":
|
||||
raise RMIDataError("No data found for query")
|
||||
|
||||
return ToolResult(
|
||||
tool=tool,
|
||||
data=data.get("result", data),
|
||||
status=data.get("status", "ok"),
|
||||
enrichment=data.get("_enrichment"),
|
||||
intel=data.get("_intel"),
|
||||
confidence=data.get("_confidence"),
|
||||
trial_remaining=int(response.headers.get("X-RMI-Trial-Remaining", 0)),
|
||||
cache_hit=response.headers.get("X-Cache") == "HIT",
|
||||
raw=data,
|
||||
)
|
||||
|
||||
# ── Core Tool Execution ───────────────────────────────────────
|
||||
|
||||
def call(self, tool: str, params: dict | None = None) -> ToolResult:
|
||||
"""Call a single tool.
|
||||
|
||||
Args:
|
||||
tool: Tool name (e.g., "scan", "audit", "whale").
|
||||
params: Tool parameters as a dict.
|
||||
|
||||
Returns:
|
||||
ToolResult with data, enrichment, and metadata.
|
||||
"""
|
||||
response = self._request("POST", tool, json_data=params or {})
|
||||
return self._parse_response(response, tool)
|
||||
|
||||
# ── Convenience Methods ───────────────────────────────────────
|
||||
|
||||
def scan(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
"""Scan a wallet address for security risks."""
|
||||
return self.call("scan", {"address": address, "chain": chain})
|
||||
|
||||
def audit(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
"""Audit a smart contract."""
|
||||
return self.call("audit", {"address": address, "chain": chain})
|
||||
|
||||
def whale(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
"""Analyze whale wallet activity."""
|
||||
return self.call("whale", {"address": address, "chain": chain})
|
||||
|
||||
def reputation(self, address: str) -> ToolResult:
|
||||
"""Get reputation score for an address."""
|
||||
return self.call("reputation_score", {"address": address})
|
||||
|
||||
def honeypot(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
"""Check if a token is a honeypot."""
|
||||
return self.call("honeypot_check", {"address": address, "chain": chain})
|
||||
|
||||
def forensics(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
"""Run wallet forensics."""
|
||||
return self.call("forensics", {"address": address, "chain": chain})
|
||||
|
||||
def sentiment(self, query: str) -> ToolResult:
|
||||
"""Get market sentiment for a query."""
|
||||
return self.call("sentiment", {"query": query})
|
||||
|
||||
def rug_probability(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
"""Get rug probability score for a token."""
|
||||
return self.call("rug_probability", {"address": address, "chain": chain})
|
||||
|
||||
def composite_score(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
"""Get composite score for a token."""
|
||||
return self.call("composite_score", {"address": address, "chain": chain})
|
||||
|
||||
def smart_money(self, address: str) -> ToolResult:
|
||||
"""Find smart money wallets similar to this one."""
|
||||
return self.call("smart_money", {"address": address})
|
||||
|
||||
def market_overview(self) -> ToolResult:
|
||||
"""Get market overview."""
|
||||
return self.call("market_overview", {})
|
||||
|
||||
def narrative(self) -> ToolResult:
|
||||
"""Get current market narrative."""
|
||||
return self.call("narrative", {})
|
||||
|
||||
# ── Batch Execution ───────────────────────────────────────────
|
||||
|
||||
def batch(self, calls: list[tuple[str, dict]], max_concurrent: int = 5) -> BatchResult:
|
||||
"""Execute multiple tool calls in parallel.
|
||||
|
||||
Args:
|
||||
calls: List of (tool_name, params) tuples.
|
||||
max_concurrent: Maximum concurrent requests.
|
||||
|
||||
Returns:
|
||||
BatchResult with all results.
|
||||
"""
|
||||
import concurrent.futures
|
||||
import threading
|
||||
|
||||
results = []
|
||||
successful = 0
|
||||
failed = 0
|
||||
start = time.monotonic()
|
||||
semaphore = threading.Semaphore(max_concurrent)
|
||||
|
||||
def _execute(tool: str, params: dict) -> ToolResult:
|
||||
with semaphore:
|
||||
try:
|
||||
return self.call(tool, params)
|
||||
except Exception as e:
|
||||
return ToolResult(
|
||||
tool=tool,
|
||||
data={},
|
||||
status="error",
|
||||
raw={"error": str(e)},
|
||||
)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrent) as executor:
|
||||
futures = {executor.submit(_execute, tool, params): (tool, params) for tool, params in calls}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
if result.is_ok:
|
||||
successful += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
total_time = (time.monotonic() - start) * 1000
|
||||
|
||||
return BatchResult(
|
||||
results=results,
|
||||
successful=successful,
|
||||
failed=failed,
|
||||
total_time_ms=total_time,
|
||||
)
|
||||
|
||||
# ── Developer API ─────────────────────────────────────────────
|
||||
|
||||
def register(self, email: str, tier: str = "free", wallet_address: str | None = None) -> dict:
|
||||
"""Register for a developer API key.
|
||||
|
||||
Args:
|
||||
email: Email address for registration.
|
||||
tier: Tier name (free, trial, paid, pro).
|
||||
wallet_address: Optional wallet address for enhanced identity.
|
||||
|
||||
Returns:
|
||||
Dict with api_key and tier info.
|
||||
"""
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/register",
|
||||
json_data={"email": email, "tier": tier, "wallet_address": wallet_address},
|
||||
base_url=self.dev_base,
|
||||
)
|
||||
result = response.json()
|
||||
# If we got a new key, update the client
|
||||
if result.get("success") and result.get("api_key"):
|
||||
self.api_key = result["api_key"]
|
||||
self._client.headers["X-RMI-Dev-Key"] = self.api_key
|
||||
return result
|
||||
|
||||
def verify_key(self) -> UsageInfo:
|
||||
"""Verify the current API key and get usage info."""
|
||||
if not self.api_key:
|
||||
raise RMIAuthenticationError("No API key set")
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/verify",
|
||||
json_data={"api_key": self.api_key},
|
||||
base_url=self.dev_base,
|
||||
)
|
||||
data = response.json()
|
||||
if not data.get("valid"):
|
||||
raise RMIAuthenticationError(data.get("error", "Invalid API key"))
|
||||
|
||||
return UsageInfo(
|
||||
tier=data.get("tier", "free"),
|
||||
email=data.get("email", ""),
|
||||
status=data.get("status", "active"),
|
||||
today_calls=data.get("today_calls", 0),
|
||||
daily_limit=data.get("daily_limit", 100),
|
||||
remaining_today=data.get("remaining_today", 0),
|
||||
total_calls=data.get("total_calls", 0),
|
||||
created_at=data.get("created_at", ""),
|
||||
rate_limit_per_min=data.get("rate_limit_per_min", 10),
|
||||
)
|
||||
|
||||
def get_tiers(self) -> list[TierInfo]:
|
||||
"""Get available developer tiers."""
|
||||
response = self._request("GET", "/tiers", base_url=self.dev_base)
|
||||
data = response.json()
|
||||
tiers = []
|
||||
for name, info in data.get("tiers", {}).items():
|
||||
tiers.append(
|
||||
TierInfo(
|
||||
name=name,
|
||||
daily_limit=info.get("daily_limit", 0),
|
||||
rate_limit_per_min=info.get("rate_limit_per_min", 0),
|
||||
price=info.get("price", 0),
|
||||
webhook_support=info.get("webhook_support", False),
|
||||
priority_queue=info.get("priority_queue", False),
|
||||
)
|
||||
)
|
||||
return tiers
|
||||
|
||||
def get_usage(self) -> dict:
|
||||
"""Get detailed usage stats for the current API key."""
|
||||
if not self.api_key:
|
||||
raise RMIAuthenticationError("No API key set")
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/usage",
|
||||
json_data={"api_key": self.api_key},
|
||||
base_url=self.dev_base,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
# ── Webhook Management ────────────────────────────────────────
|
||||
|
||||
def register_webhook(
|
||||
self,
|
||||
url: str,
|
||||
events: list[str],
|
||||
address: str,
|
||||
) -> Webhook:
|
||||
"""Register a webhook for real-time alerts.
|
||||
|
||||
Args:
|
||||
url: Webhook URL to receive POST requests.
|
||||
events: List of event types (rug_pull, whale_move, price_crash, etc.).
|
||||
address: Wallet or token address to monitor.
|
||||
|
||||
Returns:
|
||||
Webhook object with registration details.
|
||||
"""
|
||||
# Use the x402 webhook_register tool
|
||||
result = self.call(
|
||||
"webhook_register",
|
||||
{
|
||||
"url": url,
|
||||
"events": events,
|
||||
"address": address,
|
||||
},
|
||||
)
|
||||
return Webhook(
|
||||
id=result.data.get("webhook_id", ""),
|
||||
url=url,
|
||||
events=events,
|
||||
address=address,
|
||||
active=True,
|
||||
)
|
||||
|
||||
def list_webhooks(self, address: str | None = None) -> list[Webhook]:
|
||||
"""List registered webhooks."""
|
||||
params = {}
|
||||
if address:
|
||||
params["address"] = address
|
||||
|
||||
response = self._request("GET", "/webhook_list", params=params)
|
||||
data = response.json()
|
||||
webhooks = []
|
||||
for wh in data.get("webhooks", []):
|
||||
webhooks.append(
|
||||
Webhook(
|
||||
id=wh.get("id", ""),
|
||||
url=wh.get("url", ""),
|
||||
events=wh.get("events", []),
|
||||
address=wh.get("address", ""),
|
||||
active=wh.get("active", True),
|
||||
created_at=wh.get("created_at", ""),
|
||||
)
|
||||
)
|
||||
return webhooks
|
||||
|
||||
def delete_webhook(self, webhook_id: str) -> bool:
|
||||
"""Delete a registered webhook."""
|
||||
# Webhook deletion via the x402 tools API
|
||||
try:
|
||||
self.call("webhook_delete", {"webhook_id": webhook_id})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ── Tool Catalog ──────────────────────────────────────────────
|
||||
|
||||
def list_tools(self) -> list[dict]:
|
||||
"""Get the full tool catalog."""
|
||||
response = self._request("GET", "/catalog")
|
||||
data = response.json()
|
||||
return data.get("tools", [])
|
||||
|
||||
def get_tool_info(self, tool: str) -> dict | None:
|
||||
"""Get info about a specific tool."""
|
||||
response = self._request("GET", f"/{tool}")
|
||||
return response.json()
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────
|
||||
|
||||
def close(self):
|
||||
"""Close the HTTP client."""
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
def __del__(self):
|
||||
with contextlib.suppress(Exception):
|
||||
self.close()
|
||||
|
||||
|
||||
# ── Async SDK ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AsyncRMI:
|
||||
"""Async RMI client with httpx.
|
||||
|
||||
Usage:
|
||||
async with AsyncRMI(api_key="...") as rmi:
|
||||
result = await rmi.scan("0x...")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
dev_base_url: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 1.0,
|
||||
fingerprint: str | None = None,
|
||||
wallet_identity: str | None = None,
|
||||
):
|
||||
if not HTTPX_AVAILABLE:
|
||||
raise ImportError("httpx is required for async mode: pip install httpx")
|
||||
|
||||
self.api_key = api_key or os.environ.get("RMI_API_KEY")
|
||||
self.base = (base_url or os.environ.get("RMI_API_BASE") or RMI.DEFAULT_BASE).rstrip("/")
|
||||
self.dev_base = (dev_base_url or os.environ.get("RMI_DEV_BASE") or RMI.DEFAULT_DEV_BASE).rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
self.wallet_identity = wallet_identity
|
||||
|
||||
if fingerprint:
|
||||
self._fingerprint = fingerprint
|
||||
else:
|
||||
self._fingerprint = hashlib.sha256(
|
||||
f"rmi-sdk-v2-async-{os.uname().nodename}-{time.time()}".encode()
|
||||
).hexdigest()[:32]
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-fingerprint": self._fingerprint,
|
||||
"User-Agent": "rmi-python-sdk/2.0-async",
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-RMI-Dev-Key"] = self.api_key
|
||||
if self.wallet_identity:
|
||||
headers["X-Wallet-Identity"] = self.wallet_identity
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(timeout, connect=10.0),
|
||||
http2=True,
|
||||
)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict | None = None,
|
||||
json_data: dict | None = None,
|
||||
headers: dict | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> httpx.Response:
|
||||
f"{base_url or self.base}/{path.lstrip('/')}"
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
if method.upper() == "GET":
|
||||
response = await self._client.get(path, params=params, headers=headers)
|
||||
else:
|
||||
response = await self._client.post(path, json=json_data, headers=headers)
|
||||
|
||||
# Handle error responses
|
||||
if response.status_code == 401:
|
||||
raise RMIAuthenticationError("Invalid API key")
|
||||
elif response.status_code == 402:
|
||||
body = response.json() if response.content else {}
|
||||
raise RMIPaymentRequired(
|
||||
message=body.get("error", "Payment required"),
|
||||
pay_to=body.get("pay_to", ""),
|
||||
amount=body.get("amount", ""),
|
||||
chain=body.get("chain", ""),
|
||||
)
|
||||
elif response.status_code == 429:
|
||||
retry_after = int(response.headers.get("Retry-After", 60))
|
||||
raise RMIRateLimitError("Rate limit exceeded", retry_after=retry_after)
|
||||
elif response.status_code >= 400:
|
||||
raise RMIToolError(
|
||||
tool=path.split("/")[-1],
|
||||
message=f"HTTP {response.status_code}: {response.text[:200]}",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except (httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
||||
if attempt == self.max_retries:
|
||||
raise RMITimeoutError(str(e)) from e
|
||||
except httpx.ConnectError as e:
|
||||
if attempt == self.max_retries:
|
||||
raise RMIConnectionError(str(e)) from e
|
||||
|
||||
# Retry with backoff
|
||||
delay = min(self.retry_delay * (2**attempt), 30.0)
|
||||
import random
|
||||
|
||||
delay *= 0.5 + random.random()
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
raise RMIError("All retries failed")
|
||||
|
||||
def _parse_response(self, response: httpx.Response, tool: str) -> ToolResult:
|
||||
data = response.json()
|
||||
if "error" in data and not data.get("result"):
|
||||
raise RMIToolError(tool, data["error"], status=data.get("status", "error"))
|
||||
if data.get("status") == "no_data":
|
||||
raise RMIDataError("No data found")
|
||||
|
||||
return ToolResult(
|
||||
tool=tool,
|
||||
data=data.get("result", data),
|
||||
status=data.get("status", "ok"),
|
||||
enrichment=data.get("_enrichment"),
|
||||
intel=data.get("_intel"),
|
||||
confidence=data.get("_confidence"),
|
||||
trial_remaining=int(response.headers.get("X-RMI-Trial-Remaining", 0)),
|
||||
cache_hit=response.headers.get("X-Cache") == "HIT",
|
||||
raw=data,
|
||||
)
|
||||
|
||||
async def call(self, tool: str, params: dict | None = None) -> ToolResult:
|
||||
"""Call a single tool."""
|
||||
response = await self._request("POST", tool, json_data=params or {})
|
||||
return self._parse_response(response, tool)
|
||||
|
||||
# Convenience methods (same as sync version)
|
||||
async def scan(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
return await self.call("scan", {"address": address, "chain": chain})
|
||||
|
||||
async def audit(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
return await self.call("audit", {"address": address, "chain": chain})
|
||||
|
||||
async def whale(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
return await self.call("whale", {"address": address, "chain": chain})
|
||||
|
||||
async def reputation(self, address: str) -> ToolResult:
|
||||
return await self.call("reputation_score", {"address": address})
|
||||
|
||||
async def honeypot(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
return await self.call("honeypot_check", {"address": address, "chain": chain})
|
||||
|
||||
async def forensics(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
return await self.call("forensics", {"address": address, "chain": chain})
|
||||
|
||||
async def sentiment(self, query: str) -> ToolResult:
|
||||
return await self.call("sentiment", {"query": query})
|
||||
|
||||
async def rug_probability(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
return await self.call("rug_probability", {"address": address, "chain": chain})
|
||||
|
||||
async def composite_score(self, address: str, chain: str = "solana") -> ToolResult:
|
||||
return await self.call("composite_score", {"address": address, "chain": chain})
|
||||
|
||||
async def smart_money(self, address: str) -> ToolResult:
|
||||
return await self.call("smart_money", {"address": address})
|
||||
|
||||
async def market_overview(self) -> ToolResult:
|
||||
return await self.call("market_overview", {})
|
||||
|
||||
async def narrative(self) -> ToolResult:
|
||||
return await self.call("narrative", {})
|
||||
|
||||
# Batch execution (async)
|
||||
async def batch(self, calls: list[tuple[str, dict]], max_concurrent: int = 5) -> BatchResult:
|
||||
"""Execute multiple tool calls concurrently."""
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
results = []
|
||||
successful = 0
|
||||
failed = 0
|
||||
start = time.monotonic()
|
||||
|
||||
async def _execute(tool: str, params: dict) -> ToolResult:
|
||||
async with semaphore:
|
||||
try:
|
||||
return await self.call(tool, params)
|
||||
except Exception as e:
|
||||
return ToolResult(tool=tool, data={}, status="error", raw={"error": str(e)})
|
||||
|
||||
tasks = [_execute(tool, params) for tool, params in calls]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
failed += 1
|
||||
elif result.is_ok:
|
||||
successful += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
total_time = (time.monotonic() - start) * 1000
|
||||
|
||||
return BatchResult(
|
||||
results=[r for r in results if isinstance(r, ToolResult)],
|
||||
successful=successful,
|
||||
failed=failed,
|
||||
total_time_ms=total_time,
|
||||
)
|
||||
|
||||
# Developer API (async)
|
||||
async def register(self, email: str, tier: str = "free") -> dict:
|
||||
response = await self._request(
|
||||
"POST",
|
||||
"/register",
|
||||
json_data={"email": email, "tier": tier},
|
||||
base_url=self.dev_base,
|
||||
)
|
||||
result = response.json()
|
||||
if result.get("success") and result.get("api_key"):
|
||||
self.api_key = result["api_key"]
|
||||
self._client.headers["X-RMI-Dev-Key"] = self.api_key
|
||||
return result
|
||||
|
||||
async def verify_key(self) -> UsageInfo:
|
||||
if not self.api_key:
|
||||
raise RMIAuthenticationError("No API key set")
|
||||
response = await self._request(
|
||||
"POST",
|
||||
"/verify",
|
||||
json_data={"api_key": self.api_key},
|
||||
base_url=self.dev_base,
|
||||
)
|
||||
data = response.json()
|
||||
if not data.get("valid"):
|
||||
raise RMIAuthenticationError(data.get("error", "Invalid API key"))
|
||||
return UsageInfo(
|
||||
tier=data.get("tier", "free"),
|
||||
email=data.get("email", ""),
|
||||
status=data.get("status", "active"),
|
||||
today_calls=data.get("today_calls", 0),
|
||||
daily_limit=data.get("daily_limit", 100),
|
||||
remaining_today=data.get("remaining_today", 0),
|
||||
total_calls=data.get("total_calls", 0),
|
||||
created_at=data.get("created_at", ""),
|
||||
rate_limit_per_min=data.get("rate_limit_per_min", 10),
|
||||
)
|
||||
|
||||
async def list_tools(self) -> list[dict]:
|
||||
response = await self._request("GET", "/catalog")
|
||||
return response.json().get("tools", [])
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
await self.close()
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
if hasattr(self, "_client"):
|
||||
asyncio.get_event_loop().create_task(self._client.aclose())
|
||||
except Exception:
|
||||
pass
|
||||
Loading…
Add table
Add a link
Reference in a new issue