apexrunner-tools 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ """APEX Runner pre-trade crypto signals for LangChain and CrewAI agents, paid per call over x402.
2
+
3
+ Framework adapters live in ``apexrunner_tools.langchain`` and ``apexrunner_tools.crewai``
4
+ so that importing the core never requires either framework.
5
+ """
6
+
7
+ from .client import ApexClient, ApexResult
8
+ from .constants import __version__
9
+ from .errors import (
10
+ ApexError,
11
+ ApexResponseError,
12
+ ApexUnavailable,
13
+ BudgetExceeded,
14
+ PaymentRefused,
15
+ PaymentRejected,
16
+ )
17
+ from .signals import SIGNALS, SignalSpec
18
+
19
+ __all__ = [
20
+ "ApexClient",
21
+ "ApexResult",
22
+ "ApexError",
23
+ "ApexResponseError",
24
+ "ApexUnavailable",
25
+ "BudgetExceeded",
26
+ "PaymentRefused",
27
+ "PaymentRejected",
28
+ "SIGNALS",
29
+ "SignalSpec",
30
+ "__version__",
31
+ ]
@@ -0,0 +1,297 @@
1
+ """ApexClient: one paid call per signal, with caching, budget and error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field, replace
9
+ from typing import Any, Callable
10
+
11
+ import requests
12
+ from x402.http import decode_payment_required_header, decode_payment_response_header
13
+ from x402.http.clients import wrapRequestsWithPayment, x402HttpxClient
14
+
15
+ from .constants import (
16
+ APEX_BASE_URL,
17
+ DEFAULT_BUDGET_USDC,
18
+ DEFAULT_MAX_PAYMENT_USDC,
19
+ MAX_ALLOWED_PAYMENT_USDC,
20
+ MAX_CACHE_SECONDS,
21
+ MAX_CONTENT_CHARS,
22
+ REQUEST_TIMEOUT_S,
23
+ USER_AGENT,
24
+ )
25
+ from .errors import ApexResponseError, ApexUnavailable, PaymentRejected
26
+ from .payment import (
27
+ SpendBudget,
28
+ build_async_client,
29
+ build_sync_client,
30
+ call_ledger,
31
+ from_atomic,
32
+ map_payment_exception,
33
+ resolve_signer,
34
+ to_atomic,
35
+ )
36
+ from .signals import SignalSpec, get_spec
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ApexResult:
41
+ signal: str
42
+ data: dict[str, Any]
43
+ cached: bool = False
44
+ #: USDC signed for this call (0.0 when served from cache).
45
+ amount_usdc: float = 0.0
46
+ transaction: str | None = None
47
+ payer: str | None = None
48
+ network: str | None = None
49
+ query: dict[str, str] = field(default_factory=dict)
50
+
51
+ def payment(self) -> dict[str, Any]:
52
+ return {
53
+ "amount_usdc": self.amount_usdc,
54
+ "transaction": self.transaction,
55
+ "network": self.network,
56
+ "cached": self.cached,
57
+ }
58
+
59
+ def to_content(self, max_chars: int = MAX_CONTENT_CHARS) -> str:
60
+ """Compact JSON for the model."""
61
+ out = json.dumps(
62
+ {"signal": self.signal, "data": self.data, "payment": self.payment()},
63
+ separators=(",", ":"),
64
+ default=str,
65
+ )
66
+ return out if len(out) <= max_chars else out[:max_chars] + "...[truncated]"
67
+
68
+ def to_artifact(self) -> dict[str, Any]:
69
+ """Full result for the application (not sent to the model)."""
70
+ return {
71
+ "signal": self.signal,
72
+ "query": self.query,
73
+ "data": self.data,
74
+ "payment": {**self.payment(), "payer": self.payer},
75
+ }
76
+
77
+
78
+ class _TTLCache:
79
+ def __init__(self, clock: Callable[[], float]):
80
+ self._clock = clock
81
+ self._items: dict[tuple, tuple[float, ApexResult]] = {}
82
+ self._lock = threading.Lock()
83
+
84
+ def get(self, key: tuple) -> ApexResult | None:
85
+ with self._lock:
86
+ hit = self._items.get(key)
87
+ if hit is None:
88
+ return None
89
+ if hit[0] <= self._clock():
90
+ del self._items[key]
91
+ return None
92
+ return hit[1]
93
+
94
+ def put(self, key: tuple, result: ApexResult, ttl: float) -> None:
95
+ if ttl <= 0:
96
+ return
97
+ with self._lock:
98
+ self._items[key] = (self._clock() + ttl, result)
99
+
100
+
101
+ def _ttl_for(data: dict[str, Any]) -> float:
102
+ """Cache for as long as the response says it is valid, minus its age, capped."""
103
+ valid = data.get("valid_for_seconds")
104
+ if not isinstance(valid, (int, float)) or isinstance(valid, bool) or valid <= 0:
105
+ return 0.0
106
+ age = data.get("data_age_seconds", 0)
107
+ if not isinstance(age, (int, float)) or isinstance(age, bool) or age < 0:
108
+ age = 0
109
+ if data.get("stale") is True:
110
+ return 0.0
111
+ return float(min(valid - age, MAX_CACHE_SECONDS))
112
+
113
+
114
+ class ApexClient:
115
+ """Pays for and fetches APEX Runner signals.
116
+
117
+ Supply the wallet as exactly one of ``private_key``, ``account`` (an
118
+ eth_account LocalAccount) or ``signer`` (any object with ``address`` and
119
+ ``sign_typed_data``). The wallet needs USDC on Base mainnet; no ETH is
120
+ needed, because payments are signed authorizations settled by the facilitator.
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ *,
126
+ private_key: str | None = None,
127
+ account: Any = None,
128
+ signer: Any = None,
129
+ max_payment_usdc: float = DEFAULT_MAX_PAYMENT_USDC,
130
+ budget_usdc: float = DEFAULT_BUDGET_USDC,
131
+ cache: bool = True,
132
+ timeout: float = REQUEST_TIMEOUT_S,
133
+ base_url: str = APEX_BASE_URL,
134
+ _clock: Callable[[], float] = time.monotonic,
135
+ ):
136
+ if not (0 < max_payment_usdc <= MAX_ALLOWED_PAYMENT_USDC):
137
+ raise ValueError(f"max_payment_usdc must be in (0, {MAX_ALLOWED_PAYMENT_USDC}]")
138
+ self._signer = resolve_signer(private_key, account, signer)
139
+ self.address: str = self._signer.address
140
+ self.budget = SpendBudget(budget_usdc)
141
+ self._max_atomic = to_atomic(max_payment_usdc)
142
+ self._timeout = timeout
143
+ self._base_url = base_url.rstrip("/")
144
+ self._cache = _TTLCache(_clock) if cache else None
145
+ self._session = wrapRequestsWithPayment(
146
+ requests.Session(), build_sync_client(self._signer, self.budget, self._max_atomic)
147
+ )
148
+ self._session.headers["User-Agent"] = USER_AGENT
149
+ self._session.headers["Accept"] = "application/json"
150
+ self._async_x402 = build_async_client(self._signer, self.budget, self._max_atomic)
151
+
152
+ # ── public API ─────────────────────────────────────────────────────────────
153
+
154
+ def call(self, signal: str, **params: Any) -> ApexResult:
155
+ spec, query, key = self._prepare(signal, params)
156
+ hit = self._cached(key)
157
+ if hit is not None:
158
+ return hit
159
+ ledger: list[int] = []
160
+ token = call_ledger.set(ledger)
161
+ try:
162
+ resp = self._session.get(self._url(spec), params=query, timeout=self._timeout)
163
+ except Exception as exc: # noqa: BLE001 - mapped to ApexError below
164
+ raise map_payment_exception(exc, self.budget) from exc
165
+ finally:
166
+ call_ledger.reset(token)
167
+ return self._finish(spec, query, key, resp.status_code, resp.headers, resp.content, ledger)
168
+
169
+ async def acall(self, signal: str, **params: Any) -> ApexResult:
170
+ spec, query, key = self._prepare(signal, params)
171
+ hit = self._cached(key)
172
+ if hit is not None:
173
+ return hit
174
+ ledger: list[int] = []
175
+ token = call_ledger.set(ledger)
176
+ try:
177
+ # A fresh AsyncClient per call: httpx clients are bound to an event loop,
178
+ # and agent frameworks do not guarantee the same loop between calls.
179
+ async with x402HttpxClient(
180
+ self._async_x402,
181
+ timeout=self._timeout,
182
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
183
+ ) as http:
184
+ resp = await http.get(self._url(spec), params=query)
185
+ content = resp.content
186
+ except Exception as exc: # noqa: BLE001
187
+ raise map_payment_exception(exc, self.budget) from exc
188
+ finally:
189
+ call_ledger.reset(token)
190
+ return self._finish(spec, query, key, resp.status_code, resp.headers, content, ledger)
191
+
192
+ def close(self) -> None:
193
+ self._session.close()
194
+
195
+ def __enter__(self) -> "ApexClient":
196
+ return self
197
+
198
+ def __exit__(self, *exc: Any) -> None:
199
+ self.close()
200
+
201
+ # ── internals ──────────────────────────────────────────────────────────────
202
+
203
+ def _url(self, spec: SignalSpec) -> str:
204
+ return f"{self._base_url}/{spec.slug}"
205
+
206
+ def _prepare(self, signal: str, params: dict[str, Any]):
207
+ spec = get_spec(signal)
208
+ args = spec.input_model(**params)
209
+ query = spec.to_query(args)
210
+ return spec, query, (spec.slug, tuple(sorted(query.items())))
211
+
212
+ def _cached(self, key: tuple) -> ApexResult | None:
213
+ if self._cache is None:
214
+ return None
215
+ hit = self._cache.get(key)
216
+ return None if hit is None else replace(hit, cached=True, amount_usdc=0.0)
217
+
218
+ def _finish(
219
+ self,
220
+ spec: SignalSpec,
221
+ query: dict[str, str],
222
+ key: tuple,
223
+ status: int,
224
+ headers: Any,
225
+ content: bytes,
226
+ ledger: list[int],
227
+ ) -> ApexResult:
228
+ signed = from_atomic(sum(ledger))
229
+ if status == 402:
230
+ reason = _payment_error_reason(headers)
231
+ raise PaymentRejected(
232
+ f"Payment was rejected{f' ({reason})' if reason else ''}. Check that wallet "
233
+ f"{self.address} holds USDC on Base mainnet (about $0.01 per call)."
234
+ )
235
+ if not 200 <= status < 300:
236
+ body = content[:300].decode("utf-8", "replace")
237
+ raise ApexUnavailable(
238
+ f"APEX Runner returned HTTP {status} for {spec.name}; x402 settles only on "
239
+ f"success, so this call should not be charged. Body: {body}",
240
+ status_code=status,
241
+ )
242
+ try:
243
+ data = json.loads(content)
244
+ except ValueError:
245
+ raise ApexResponseError(f"APEX Runner returned a non-JSON body for {spec.name}.") from None
246
+ if not isinstance(data, dict):
247
+ raise ApexResponseError(f"APEX Runner returned a non-object JSON body for {spec.name}.")
248
+
249
+ receipt = _settle_receipt(headers)
250
+ result = ApexResult(
251
+ signal=spec.name,
252
+ data=data,
253
+ amount_usdc=signed,
254
+ transaction=receipt.get("transaction"),
255
+ payer=receipt.get("payer"),
256
+ network=receipt.get("network"),
257
+ query=query,
258
+ )
259
+ if self._cache is not None:
260
+ self._cache.put(key, result, _ttl_for(data))
261
+ return result
262
+
263
+
264
+ def _header(headers: Any, *names: str) -> str | None:
265
+ for name in names:
266
+ value = headers.get(name)
267
+ if value:
268
+ return value
269
+ return None
270
+
271
+
272
+ def _settle_receipt(headers: Any) -> dict[str, Any]:
273
+ raw = _header(headers, "PAYMENT-RESPONSE", "X-PAYMENT-RESPONSE")
274
+ if not raw:
275
+ return {}
276
+ try:
277
+ settle = decode_payment_response_header(raw)
278
+ return {
279
+ "transaction": getattr(settle, "transaction", None),
280
+ "payer": getattr(settle, "payer", None),
281
+ "network": getattr(settle, "network", None),
282
+ }
283
+ except Exception: # noqa: BLE001 - a receipt we cannot read is not a failed call
284
+ return {}
285
+
286
+
287
+ def _payment_error_reason(headers: Any) -> str | None:
288
+ raw = _header(headers, "PAYMENT-REQUIRED")
289
+ if not raw:
290
+ return None
291
+ try:
292
+ return getattr(decode_payment_required_header(raw), "error", None)
293
+ except Exception: # noqa: BLE001
294
+ return None
295
+
296
+
297
+ __all__ = ["ApexClient", "ApexResult"]
@@ -0,0 +1,34 @@
1
+ """Pinned values. The payment policy refuses any payment request that differs."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ APEX_BASE_URL = "https://apexrunner.ai/signals"
6
+
7
+ # Base mainnet, CAIP-2. APEX is paid here only.
8
+ BASE_MAINNET = "eip155:8453"
9
+
10
+ # Native USDC on Base (6 decimals).
11
+ BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
12
+ USDC_DECIMALS = 6
13
+
14
+ # APEX Runner's receiving address, as served in every 402 challenge.
15
+ APEX_PAY_TO = "0x8D0663BBb967ebF1c047405A888DDf865f3E8854"
16
+
17
+ # Per-call ceiling. List price today is $0.01 (charged $0.007 until 2027-01-01).
18
+ DEFAULT_MAX_PAYMENT_USDC = 0.02
19
+ # Hard ceiling for any configured per-call cap: a typo must not authorise $10 calls.
20
+ MAX_ALLOWED_PAYMENT_USDC = 1.00
21
+
22
+ # Total the client may sign for over its lifetime.
23
+ DEFAULT_BUDGET_USDC = 1.00
24
+
25
+ REQUEST_TIMEOUT_S = 20.0
26
+
27
+ # Never serve a cached result older than this, whatever the response claims.
28
+ MAX_CACHE_SECONDS = 300
29
+
30
+ # Keeps one signal payload from flooding the model's context window.
31
+ MAX_CONTENT_CHARS = 16_000
32
+
33
+ # Distinct UA so APEX can attribute traffic from this package.
34
+ USER_AGENT = f"apexrunner-tools/{__version__} (python; +https://apexrunner.ai)"
@@ -0,0 +1,75 @@
1
+ """CrewAI tools. Requires ``pip install apexrunner-tools[crewai]``.
2
+
3
+ from apexrunner_tools.crewai import get_apex_tools
4
+ analyst = Agent(role="...", goal="...", backstory="...",
5
+ tools=get_apex_tools(private_key=os.environ["AGENT_WALLET_KEY"]))
6
+
7
+ CrewAI's own tool cache is switched off for these tools: it has no expiry, so
8
+ it would keep serving market data long after it went stale. ApexClient caches
9
+ each result only for as long as the response says it is valid.
10
+
11
+ Failures are returned as JSON with ``success: false`` and the reason, so the
12
+ agent can read them instead of the crew crashing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import Any
19
+
20
+ try:
21
+ from crewai.tools import BaseTool
22
+ except ImportError as exc: # pragma: no cover
23
+ raise ImportError("Install the CrewAI extra: pip install 'apexrunner-tools[crewai]'") from exc
24
+
25
+ from pydantic import ConfigDict, Field
26
+
27
+ from .client import ApexClient
28
+ from .errors import ApexError
29
+ from .signals import SIGNALS
30
+
31
+
32
+ def _never_cache(_args: Any = None, _result: Any = None) -> bool:
33
+ return False
34
+
35
+
36
+ class ApexCrewTool(BaseTool):
37
+ model_config = ConfigDict(arbitrary_types_allowed=True)
38
+
39
+ client: ApexClient = Field(exclude=True)
40
+ signal: str
41
+ cache_function: Any = _never_cache
42
+
43
+ def _run(self, **kwargs: Any) -> str:
44
+ try:
45
+ return self.client.call(self.signal, **kwargs).to_content()
46
+ except ApexError as exc:
47
+ return _failure(self.signal, exc)
48
+
49
+ async def _arun(self, **kwargs: Any) -> str:
50
+ try:
51
+ return (await self.client.acall(self.signal, **kwargs)).to_content()
52
+ except ApexError as exc:
53
+ return _failure(self.signal, exc)
54
+
55
+
56
+ def _failure(signal: str, exc: ApexError) -> str:
57
+ return json.dumps({"success": False, "signal": signal, "error": str(exc)})
58
+
59
+
60
+ def get_apex_tools(client: ApexClient | None = None, **client_kwargs: Any) -> list[ApexCrewTool]:
61
+ """The three APEX tools sharing one client (one wallet, one budget, one cache)."""
62
+ if client is None:
63
+ client = ApexClient(**client_kwargs)
64
+ elif client_kwargs:
65
+ raise ValueError("Pass either client or client arguments, not both.")
66
+ return [
67
+ ApexCrewTool(
68
+ name=spec.name,
69
+ description=spec.description,
70
+ args_schema=spec.input_model,
71
+ client=client,
72
+ signal=spec.name,
73
+ )
74
+ for spec in SIGNALS
75
+ ]
@@ -0,0 +1,31 @@
1
+ """Every failure the client raises. All subclass ApexError."""
2
+
3
+
4
+ class ApexError(Exception):
5
+ """Base class for apexrunner-tools errors."""
6
+
7
+
8
+ class PaymentRefused(ApexError):
9
+ """No payment option passed the local policy. Nothing was signed."""
10
+
11
+
12
+ class BudgetExceeded(PaymentRefused):
13
+ """Signing this payment would exceed the client's spend budget. Nothing was signed."""
14
+
15
+
16
+ class PaymentRejected(ApexError):
17
+ """A payment was signed but the server or facilitator refused it.
18
+
19
+ Usually the wallet holds too little USDC on Base."""
20
+
21
+
22
+ class ApexUnavailable(ApexError):
23
+ """APEX answered with a non-success status. x402 settles only on success."""
24
+
25
+ def __init__(self, message: str, status_code: int):
26
+ super().__init__(message)
27
+ self.status_code = status_code
28
+
29
+
30
+ class ApexResponseError(ApexError):
31
+ """APEX returned a success status with a body that is not a JSON object."""
@@ -0,0 +1,71 @@
1
+ """LangChain tools. Requires ``pip install apexrunner-tools[langchain]``.
2
+
3
+ from apexrunner_tools.langchain import get_apex_tools
4
+ tools = get_apex_tools(private_key=os.environ["AGENT_WALLET_KEY"])
5
+ agent = create_agent(model, tools=tools)
6
+
7
+ Each tool returns ``content_and_artifact``: the model sees compact JSON, and the
8
+ ToolMessage artifact carries the full data plus the payment receipt.
9
+ Failures raise ToolException, which ``handle_tool_error=True`` turns into a
10
+ message the model can read and act on.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Literal
16
+
17
+ try:
18
+ from langchain_core.tools import BaseTool, ToolException
19
+ except ImportError as exc: # pragma: no cover
20
+ raise ImportError("Install the LangChain extra: pip install 'apexrunner-tools[langchain]'") from exc
21
+
22
+ from pydantic import ConfigDict, Field
23
+
24
+ from .client import ApexClient
25
+ from .errors import ApexError
26
+ from .signals import SIGNALS
27
+
28
+
29
+ class ApexTool(BaseTool):
30
+ model_config = ConfigDict(arbitrary_types_allowed=True)
31
+
32
+ client: ApexClient = Field(exclude=True)
33
+ signal: str
34
+ response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
35
+ handle_tool_error: bool = True
36
+
37
+ def _run(self, *args: Any, run_manager: Any = None, **kwargs: Any) -> tuple[str, dict]:
38
+ try:
39
+ result = self.client.call(self.signal, **kwargs)
40
+ except ApexError as exc:
41
+ raise ToolException(str(exc)) from exc
42
+ return result.to_content(), result.to_artifact()
43
+
44
+ async def _arun(self, *args: Any, run_manager: Any = None, **kwargs: Any) -> tuple[str, dict]:
45
+ try:
46
+ result = await self.client.acall(self.signal, **kwargs)
47
+ except ApexError as exc:
48
+ raise ToolException(str(exc)) from exc
49
+ return result.to_content(), result.to_artifact()
50
+
51
+
52
+ def get_apex_tools(client: ApexClient | None = None, **client_kwargs: Any) -> list[ApexTool]:
53
+ """The three APEX tools sharing one client (one wallet, one budget, one cache).
54
+
55
+ Pass an existing ``client``, or the ApexClient arguments (``private_key=...``,
56
+ ``budget_usdc=...``, ...) to create one.
57
+ """
58
+ if client is None:
59
+ client = ApexClient(**client_kwargs)
60
+ elif client_kwargs:
61
+ raise ValueError("Pass either client or client arguments, not both.")
62
+ return [
63
+ ApexTool(
64
+ name=spec.name,
65
+ description=spec.description,
66
+ args_schema=spec.input_model,
67
+ client=client,
68
+ signal=spec.name,
69
+ )
70
+ for spec in SIGNALS
71
+ ]
@@ -0,0 +1,181 @@
1
+ """Wallet setup, the APEX-only payment policy, and the spend budget.
2
+
3
+ Two independent gates run before anything is signed:
4
+
5
+ 1. A payment *policy* removes every payment option that is not exact-scheme
6
+ USDC on Base, to APEX's pinned address, at or under the per-call cap. If no
7
+ option is left, x402 raises and nothing is signed.
8
+ 2. A *before-payment hook* re-checks the selected option and charges the spend
9
+ budget. If the budget would be exceeded it aborts, and nothing is signed.
10
+
11
+ The hook runs once per signature, not once per call, so the SDK's "recovery"
12
+ path (which can sign a second payment within one HTTP call) is budgeted too.
13
+ A signed authorization counts as spent the moment it is signed: the budget
14
+ never assumes a payment was *not* settled.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ from contextvars import ContextVar
21
+ from decimal import Decimal
22
+ from typing import Any
23
+
24
+ from x402 import x402Client, x402ClientSync
25
+ from x402.mechanisms.evm.exact import register_exact_evm_client
26
+ from x402.mechanisms.evm.signers import EthAccountSigner
27
+ from x402.schemas import AbortResult, NoMatchingRequirementsError, PaymentAbortedError
28
+
29
+ from .constants import APEX_PAY_TO, BASE_MAINNET, BASE_USDC, USDC_DECIMALS
30
+ from .errors import ApexError, BudgetExceeded, PaymentRefused
31
+
32
+ ABORT_BUDGET = "apexrunner_budget_exceeded"
33
+ ABORT_NOT_APEX = "apexrunner_not_apex_payment"
34
+
35
+ _UNIT = Decimal(10) ** USDC_DECIMALS
36
+
37
+
38
+ def to_atomic(usdc: float | int | str | Decimal) -> int:
39
+ return int((Decimal(str(usdc)) * _UNIT).to_integral_value())
40
+
41
+
42
+ def from_atomic(atomic: int) -> float:
43
+ return float(Decimal(atomic) / _UNIT)
44
+
45
+
46
+ # Amounts signed during the current call. Set by ApexClient around each request.
47
+ # Works for threads (sync) and asyncio tasks, including hooks the async client
48
+ # runs through asyncio.to_thread (which copies the context).
49
+ call_ledger: ContextVar[list[int] | None] = ContextVar("apexrunner_call_ledger", default=None)
50
+
51
+
52
+ def resolve_signer(private_key: str | None = None, account: Any = None, signer: Any = None) -> Any:
53
+ """Return an x402 EVM signer from exactly one of the three ways to supply a wallet.
54
+
55
+ A private key becomes an eth_account LocalAccount, which holds it only to sign;
56
+ the key string itself is not stored anywhere else.
57
+ """
58
+ supplied = sum(x is not None for x in (private_key, account, signer))
59
+ if supplied != 1:
60
+ raise ValueError("Provide exactly one of private_key, account or signer.")
61
+ if private_key is not None:
62
+ from eth_account import Account
63
+
64
+ account = Account.from_key(private_key)
65
+ if account is not None:
66
+ return EthAccountSigner(account)
67
+ if not isinstance(getattr(signer, "address", None), str) or not callable(
68
+ getattr(signer, "sign_typed_data", None)
69
+ ):
70
+ raise TypeError("signer must have an 'address' string and a 'sign_typed_data' method.")
71
+ return signer
72
+
73
+
74
+ def is_apex_requirement(req: Any, max_atomic: int) -> bool:
75
+ amount = str(getattr(req, "amount", "") or "")
76
+ if not amount.isdigit():
77
+ return False
78
+ return (
79
+ getattr(req, "scheme", None) == "exact"
80
+ and getattr(req, "network", None) == BASE_MAINNET
81
+ and str(getattr(req, "asset", "")).lower() == BASE_USDC.lower()
82
+ and str(getattr(req, "pay_to", "")).lower() == APEX_PAY_TO.lower()
83
+ and 0 < int(amount) <= max_atomic
84
+ )
85
+
86
+
87
+ def apex_only_policy(max_atomic: int):
88
+ def policy(version: int, reqs: list) -> list:
89
+ if version != 2: # APEX speaks x402 v2; never guess at v1 fields
90
+ return []
91
+ return [r for r in reqs if is_apex_requirement(r, max_atomic)]
92
+
93
+ return policy
94
+
95
+
96
+ class SpendBudget:
97
+ """Thread-safe running total of USDC signed by one client."""
98
+
99
+ def __init__(self, limit_usdc: float):
100
+ if limit_usdc < 0:
101
+ raise ValueError("budget_usdc must be >= 0")
102
+ self._limit = to_atomic(limit_usdc)
103
+ self._spent = 0
104
+ self._lock = threading.Lock()
105
+
106
+ def try_spend(self, atomic: int) -> bool:
107
+ with self._lock:
108
+ if self._spent + atomic > self._limit:
109
+ return False
110
+ self._spent += atomic
111
+ return True
112
+
113
+ @property
114
+ def limit_usdc(self) -> float:
115
+ return from_atomic(self._limit)
116
+
117
+ @property
118
+ def spent_usdc(self) -> float:
119
+ with self._lock:
120
+ return from_atomic(self._spent)
121
+
122
+ @property
123
+ def remaining_usdc(self) -> float:
124
+ with self._lock:
125
+ return from_atomic(self._limit - self._spent)
126
+
127
+
128
+ def _before_payment_hook(budget: SpendBudget, max_atomic: int):
129
+ def hook(ctx: Any) -> AbortResult | None:
130
+ req = ctx.selected_requirements
131
+ if ctx.payment_required.x402_version != 2 or not is_apex_requirement(req, max_atomic):
132
+ return AbortResult(reason=ABORT_NOT_APEX)
133
+ amount = int(req.amount)
134
+ if not budget.try_spend(amount):
135
+ return AbortResult(reason=ABORT_BUDGET)
136
+ ledger = call_ledger.get()
137
+ if ledger is not None:
138
+ ledger.append(amount)
139
+ return None
140
+
141
+ return hook
142
+
143
+
144
+ def build_sync_client(signer: Any, budget: SpendBudget, max_atomic: int) -> x402ClientSync:
145
+ client = x402ClientSync()
146
+ register_exact_evm_client(client, signer, networks=BASE_MAINNET)
147
+ client.register_policy(apex_only_policy(max_atomic))
148
+ client.on_before_payment_creation(_before_payment_hook(budget, max_atomic))
149
+ return client
150
+
151
+
152
+ def build_async_client(signer: Any, budget: SpendBudget, max_atomic: int) -> x402Client:
153
+ client = x402Client()
154
+ register_exact_evm_client(client, signer, networks=BASE_MAINNET)
155
+ client.register_policy(apex_only_policy(max_atomic))
156
+ client.on_before_payment_creation(_before_payment_hook(budget, max_atomic))
157
+ return client
158
+
159
+
160
+ def map_payment_exception(exc: BaseException, budget: SpendBudget) -> ApexError:
161
+ """Translate x402's exception (often wrapped by the HTTP adapter) into ours."""
162
+ seen: set[int] = set()
163
+ cur: BaseException | None = exc
164
+ while cur is not None and id(cur) not in seen:
165
+ seen.add(id(cur))
166
+ if isinstance(cur, PaymentAbortedError):
167
+ if cur.reason == ABORT_BUDGET:
168
+ return BudgetExceeded(
169
+ f"Spend budget reached (${budget.spent_usdc:.4f} of ${budget.limit_usdc:.2f} "
170
+ "signed). Nothing was signed for this call."
171
+ )
172
+ return PaymentRefused(
173
+ "The payment request did not match APEX Runner's pinned terms. Nothing was signed."
174
+ )
175
+ if isinstance(cur, NoMatchingRequirementsError):
176
+ return PaymentRefused(
177
+ "No acceptable payment option: APEX payments must be exact USDC on Base to the "
178
+ "pinned address at or under the per-call cap. Nothing was signed."
179
+ )
180
+ cur = cur.__cause__ or cur.__context__
181
+ return ApexError(f"Request to APEX Runner failed: {type(exc).__name__}: {exc}")
@@ -0,0 +1,26 @@
1
+ """Tool inputs, shared by the LangChain and CrewAI adapters.
2
+
3
+ Bounds mirror the server's own validation (apex-signals PARAM_SPECS).
4
+ """
5
+
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ Coin = Literal["BTC", "ETH", "SOL", "AVAX", "LINK", "XRP"]
11
+
12
+
13
+ class RiskAssessmentBundleInput(BaseModel):
14
+ """No input: a market-wide risk snapshot."""
15
+
16
+
17
+ class ExecutionWindowOptimizerInput(BaseModel):
18
+ coin: Coin = Field(default="BTC", description="Asset symbol to score the execution window for.")
19
+
20
+
21
+ class SlippageForecastInput(BaseModel):
22
+ coin: Coin = Field(default="BTC", description="Asset symbol of the order.")
23
+ size_usd: float = Field(
24
+ ge=10, le=1_000_000, description="Order notional in USD, between 10 and 1,000,000. Example: 5000."
25
+ )
26
+ side: Literal["buy", "sell"] = Field(default="buy", description="Order side.")
@@ -0,0 +1,82 @@
1
+ """The three tools, defined once. Both framework adapters read from here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from .schemas import ExecutionWindowOptimizerInput, RiskAssessmentBundleInput, SlippageForecastInput
10
+
11
+ _COST = (
12
+ "Costs about $0.01 in USDC on Base, paid automatically from the agent's wallet. "
13
+ "Repeating the same call while the data is still fresh returns the cached result at no cost."
14
+ )
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SignalSpec:
19
+ name: str
20
+ slug: str
21
+ input_model: type[BaseModel]
22
+ description: str
23
+
24
+ def to_query(self, args: BaseModel) -> dict[str, str]:
25
+ out: dict[str, str] = {}
26
+ for key, value in args.model_dump().items():
27
+ if isinstance(value, float) and value.is_integer():
28
+ value = int(value)
29
+ out[key] = str(value)
30
+ return out
31
+
32
+
33
+ RISK_ASSESSMENT_BUNDLE = SignalSpec(
34
+ name="risk_assessment_bundle",
35
+ slug="risk-assessment-bundle",
36
+ input_model=RiskAssessmentBundleInput,
37
+ description=(
38
+ "Check crypto market risk before sizing or opening a position. One call returns 4 signals: "
39
+ "liquidation cascade risk per coin (liquidation_pressure), the nearest liquidation clusters "
40
+ "and which way price is being pulled (liquidation_magnet), overall market heat with a "
41
+ "suggested action (portfolio_heat), and top-trader long/short positioning (whale_sentiment). "
42
+ "Use it to avoid entering just as leverage is about to unwind. Takes no input. " + _COST
43
+ ),
44
+ )
45
+
46
+ EXECUTION_WINDOW_OPTIMIZER = SignalSpec(
47
+ name="execution_window_optimizer",
48
+ slug="execution-window-optimizer",
49
+ input_model=ExecutionWindowOptimizerInput,
50
+ description=(
51
+ "Decide whether to execute a crypto order now or wait. Scores the current window 0-100 "
52
+ "from trading session, market regime, Fear & Greed and volatility, and returns a verdict "
53
+ "(EXECUTE_NOW, GOOD_WINDOW, WAIT_15MIN or WAIT_SESSION), a recommended order type and the "
54
+ "next favourable window. Input: coin (BTC, ETH, SOL, AVAX, LINK or XRP; default BTC). " + _COST
55
+ ),
56
+ )
57
+
58
+ SLIPPAGE_FORECAST = SignalSpec(
59
+ name="slippage_forecast",
60
+ slug="slippage-forecast",
61
+ input_model=SlippageForecastInput,
62
+ description=(
63
+ "Forecast the hidden market-impact cost of a crypto order before placing it. Returns "
64
+ "expected slippage as a percentage and in USD for the given coin, size and side, plus a "
65
+ "recommendation: EXECUTE_NOW, EXECUTE, SPLIT_ORDER (with a suggested tranche count) or "
66
+ "USE_LIMIT. Use it for any order above a few hundred dollars. Inputs: coin (BTC, ETH, SOL, "
67
+ "AVAX, LINK or XRP), size_usd (10 to 1,000,000), side (buy or sell). "
68
+ "Example: coin=ETH, size_usd=5000, side=sell. " + _COST
69
+ ),
70
+ )
71
+
72
+ SIGNALS: tuple[SignalSpec, ...] = (RISK_ASSESSMENT_BUNDLE, EXECUTION_WINDOW_OPTIMIZER, SLIPPAGE_FORECAST)
73
+
74
+ _BY_KEY = {key: spec for spec in SIGNALS for key in (spec.name, spec.slug)}
75
+
76
+
77
+ def get_spec(signal: str) -> SignalSpec:
78
+ try:
79
+ return _BY_KEY[signal]
80
+ except KeyError:
81
+ names = ", ".join(s.name for s in SIGNALS)
82
+ raise ValueError(f"Unknown signal {signal!r}. Available: {names}") from None
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: apexrunner-tools
3
+ Version: 0.1.0
4
+ Summary: LangChain and CrewAI tools for APEX Runner pre-trade crypto signals (risk, execution timing, slippage), paid per call over x402 in USDC on Base.
5
+ Project-URL: Homepage, https://apexrunner.ai
6
+ Project-URL: Documentation, https://apexrunner.ai/signals/docs/
7
+ Project-URL: Repository, https://github.com/kynto2001-ctrl/apexrunner-tools
8
+ Project-URL: Issues, https://github.com/kynto2001-ctrl/apexrunner-tools/issues
9
+ Author-email: APEX Runner <267811382+kynto2001-ctrl@users.noreply.github.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,base,crewai,crypto,langchain,trading,usdc,x402
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Office/Business :: Financial :: Investment
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: pydantic<3,>=2.6
21
+ Requires-Dist: x402[evm,httpx,requests]<3,>=2.8
22
+ Provides-Extra: crewai
23
+ Requires-Dist: crewai<2,>=1.0; extra == 'crewai'
24
+ Provides-Extra: dev
25
+ Requires-Dist: crewai<2,>=1.0; extra == 'dev'
26
+ Requires-Dist: langchain-core<2,>=1.0; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
28
+ Requires-Dist: pytest>=8; extra == 'dev'
29
+ Provides-Extra: langchain
30
+ Requires-Dist: langchain-core<2,>=1.0; extra == 'langchain'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # apexrunner-tools
34
+
35
+ Pre-trade crypto signals for **LangChain** and **CrewAI** agents. Each call is paid per use over [x402](https://x402.org) from the agent's own wallet: about $0.01 in USDC on Base mainnet. There is no API key, no account and no subscription.
36
+
37
+ | Tool | Input | Returns |
38
+ |---|---|---|
39
+ | `risk_assessment_bundle` | none | 4 signals in one call: liquidation cascade risk, nearest liquidation clusters, market heat, top-trader positioning |
40
+ | `execution_window_optimizer` | `coin` | 0–100 window score and a verdict: EXECUTE_NOW / GOOD_WINDOW / WAIT_15MIN / WAIT_SESSION, plus an order type |
41
+ | `slippage_forecast` | `coin`, `size_usd`, `side` | Expected slippage % and USD, and a recommendation: EXECUTE_NOW / EXECUTE / SPLIT_ORDER / USE_LIMIT |
42
+
43
+ Coins: BTC, ETH, SOL, AVAX, LINK, XRP.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install "apexrunner-tools[langchain]" # or [crewai]
49
+ ```
50
+
51
+ ## LangChain
52
+
53
+ ```python
54
+ import os
55
+ from langchain.agents import create_agent
56
+ from apexrunner_tools.langchain import get_apex_tools
57
+
58
+ tools = get_apex_tools(private_key=os.environ["AGENT_WALLET_KEY"]) # $1.00 budget by default
59
+ agent = create_agent("openai:gpt-4.1-mini", tools=tools)
60
+ ```
61
+
62
+ Each tool returns `content_and_artifact`. The model sees compact JSON. The `ToolMessage.artifact` holds the full data and the payment receipt (amount and transaction hash).
63
+
64
+ ## CrewAI
65
+
66
+ ```python
67
+ import os
68
+ from crewai import Agent
69
+ from apexrunner_tools.crewai import get_apex_tools
70
+
71
+ trader = Agent(
72
+ role="Execution trader",
73
+ goal="Place orders at the lowest total cost",
74
+ backstory="Checks risk, timing and slippage before every order.",
75
+ tools=get_apex_tools(private_key=os.environ["AGENT_WALLET_KEY"]),
76
+ )
77
+ ```
78
+
79
+ ## Without a framework
80
+
81
+ ```python
82
+ from apexrunner_tools import ApexClient
83
+
84
+ with ApexClient(private_key=os.environ["AGENT_WALLET_KEY"]) as apex:
85
+ r = apex.call("slippage_forecast", coin="ETH", size_usd=5000, side="sell")
86
+ print(r.data["recommendation"], r.amount_usdc, r.transaction)
87
+ print(apex.budget.spent_usdc, apex.budget.remaining_usdc)
88
+ ```
89
+
90
+ `acall()` is the async version.
91
+
92
+ ## Wallet
93
+
94
+ Supply exactly one of these:
95
+
96
+ - `private_key="0x…"`. It is turned into an `eth_account` signer, which holds the key only so it can sign. The key never appears in any attribute, `repr()` or a tool's serialised form.
97
+ - `account=LocalAccount`, from `eth_account`.
98
+ - `signer=...`, any object with an `address` and `sign_typed_data(...)`. Use this for KMS, HSM or hosted wallets.
99
+
100
+ The wallet needs **USDC on Base mainnet**, and no ETH. Payments are signed USDC authorizations (EIP-3009), and the facilitator pays the gas. One dollar covers about 100 calls.
101
+
102
+ ## Safeguards
103
+
104
+ | Safeguard | What it does |
105
+ |---|---|
106
+ | **Pinned payment terms** | Before anything is signed, every payment option is checked: it must be x402 v2 `exact` USDC on Base, payable to APEX Runner's pinned address. Anything else is refused and **nothing is signed** (`PaymentRefused`). This holds even if the server, DNS or a proxy is compromised. |
107
+ | **Per-call cap** | `max_payment_usdc`, default **$0.02** and never above $1.00. A higher price is refused before signing. |
108
+ | **Spend budget** | `budget_usdc`, default **$1.00** per client. It is checked at the moment of signing, every signature counts (including the SDK's automatic re-sign on recovery), and it is safe across threads. When the budget is spent, calls raise `BudgetExceeded` and nothing is signed. A signed payment counts as spent even if the call then fails: the budget never assumes a payment was not settled. |
109
+ | **Expiring cache** | Repeating an identical call while its data is still valid (the response's `valid_for_seconds` minus its age, at most 300 s) returns the cached result at no cost. Stale data is never cached. CrewAI's own cache has no expiry, so it is switched off for these tools. |
110
+
111
+ ## Errors
112
+
113
+ All errors subclass `ApexError`:
114
+
115
+ | Error | Meaning | Charged? |
116
+ |---|---|---|
117
+ | `PaymentRefused` | The payment request didn't match the pinned terms | No, nothing was signed |
118
+ | `BudgetExceeded` | The budget is spent | No, nothing was signed |
119
+ | `PaymentRejected` | A payment was signed but refused, usually too little USDC | No settlement |
120
+ | `ApexUnavailable` | The server returned a non-2xx status | x402 settles only on success |
121
+ | `ApexResponseError` | The server returned a 2xx with an unreadable body | Possibly; report it |
122
+
123
+ LangChain turns these into a tool message the model can read. CrewAI tools return `{"success": false, "error": ...}`.
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,13 @@
1
+ apexrunner_tools/__init__.py,sha256=plFzZpLHdOaqs_WfhbzpKbXeaBklB-ykgjls2k3fx8Q,752
2
+ apexrunner_tools/client.py,sha256=1RubhPH3QFo7lrRvEkjnbEkEX-z0zbaZIZqKIg_YeL8,10725
3
+ apexrunner_tools/constants.py,sha256=2FWL2thpd912-izTFeXZS5ARQM_Eo_6RgCPostEJ9dU,1152
4
+ apexrunner_tools/crewai.py,sha256=BgVdXUscRaUX10G9uYVocSg001QOsugLw109tFIeil4,2460
5
+ apexrunner_tools/errors.py,sha256=DG9MjdKaga9K0dE5WQ3383piEaamPB3TsnQlwLtAM7A,911
6
+ apexrunner_tools/langchain.py,sha256=wGpWnO6e9uywdCIhijd8c09Jmw_CcKaR9IK4ztAPHg4,2573
7
+ apexrunner_tools/payment.py,sha256=-wbNGVt3nlo8Qfwg_jk2CFVv2qgcQ_MhNGGqCdbAcjw,6977
8
+ apexrunner_tools/schemas.py,sha256=DOKMcEJjcKDomirj_YgOsbvLlRTyF-TY5axNZi-cqCw,856
9
+ apexrunner_tools/signals.py,sha256=QMTemtZMWLiVAXg1dQO6aStq8U5cQRaY1fm8r4IpWM4,3292
10
+ apexrunner_tools-0.1.0.dist-info/METADATA,sha256=NNC4l6Bt8Byhydm6wQKiIeX3zafgooUsreCzr3D-_xA,6007
11
+ apexrunner_tools-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ apexrunner_tools-0.1.0.dist-info/licenses/LICENSE,sha256=H2oaFd6oS8ncxyFUXpNsLXHRQwb_S7ewMH4dRxCuGCI,1068
13
+ apexrunner_tools-0.1.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 APEX Runner
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.