Skip to content

Commit 4fbdbcb

Browse files
sync: monorepo b55a927 (bridgenode-llm-v0.2.19)
1 parent 436c12f commit 4fbdbcb

5 files changed

Lines changed: 113 additions & 99 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## [0.2.19](https://github.com/bridgenode-ai/bridgenode-llm/compare/bridgenode-llm-v0.2.18...bridgenode-llm-v0.2.19) (2026-08-30)
4+
5+
6+
### Bug Fixes
7+
8+
* **release:** sync manifest versions + CI check-version-drift (P1-1) ([4e581ad](https://github.com/bridgenode-ai/bridgenode-llm/commit/4e581ad6a31641e5a83ac27251de27441801c918))
9+
* **sdk-python:** malformed 402 amount → BridgenodeError, no raw ValueError (B-4, §8.4) ([2298078](https://github.com/bridgenode-ai/bridgenode-llm/commit/22980780cd6763deac2ead552efb50c5d6df9fa1))
10+
* **sdk-python:** x402 bounds >=2.20.0,<3 — handle_402_response requires request_url (B-5) ([48a2ba8](https://github.com/bridgenode-ai/bridgenode-llm/commit/48a2ba8492ff7cb5a24029cabdf9225b369de8e2))
11+
12+
13+
### Documentation
14+
15+
* **readme:** add star CTA to sdk-python, sdk-ts, mcp READMEs (A1) ([17fd97b](https://github.com/bridgenode-ai/bridgenode-llm/commit/17fd97b31b80ce827fce115374adb1776cdff946))
16+
317
## [0.2.18](https://github.com/bridgenode-ai/bridgenode-llm/compare/bridgenode-llm-v0.2.17...bridgenode-llm-v0.2.18) (2026-08-30)
418

519

bridgenode_llm/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
from .client import BRIDGENODE_BASE_URL, BridgenodeError, LLMClient
99

1010
__all__ = ["LLMClient", "BridgenodeError", "BRIDGENODE_BASE_URL"]
11-
__version__ = "0.2.18"
11+
__version__ = "0.2.19"

bridgenode_llm/client.py

Lines changed: 54 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
"""client.py — LLMClient: automatic x402 V2 handshake (§8.4, fix.md 4.2 steps 1–2).
1+
"""client.py — LLMClient: automatic x402 V2 handshake.
22
33
Flow: POST /v1/chat/completions → 402 (PAYMENT-REQUIRED) → partial TX
4-
(TransferChecked + Memo, §3.3–§3.4) via the official x402 SVM scheme →
4+
(TransferChecked + Memo) via the official x402 SVM scheme →
55
PAYMENT-SIGNATURE → retry → 200.
66
7-
step 2 — Receipt verification + spending policy (fail-closed, like ASG):
7+
step 2 — Receipt verification + spending policy (fail-closed):
88
- After 200, the `PAYMENT-RESPONSE` receipt is verified: success=true,
99
network = Solana mainnet, payer = our wallet, transaction = fee payer
1010
signature over OUR TX message (Free-Riding protection: a forged receipt
@@ -15,13 +15,13 @@
1515
1616
Rules:
1717
- Key from `.env` (`BRIDGENODE_WALLET_KEY`) — no arguments, no interactive
18-
prompts (§8.4)
18+
prompts
1919
- Endpoint: `https://bridgenode.cc/v1` (configurable via
2020
`BRIDGENODE_BASE_URL` or argument)
21-
- Two separate timeouts (§4.3/§8.4): initial ≥ 30s (queue until 402, §5.7),
21+
- Two separate timeouts: initial ≥ 30s (queue until 402),
2222
retry ≥ 113s (≤ 115s budget)
2323
- Uses the official x402 client (x402ClientSync + ExactSvmScheme) — no custom
24-
payment code (taisykles.md: don't reinvent the wheel)
24+
payment code (don't reinvent the wheel)
2525
"""
2626

2727
from __future__ import annotations
@@ -59,20 +59,20 @@
5959
USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" # Solana mainnet USDC mint
6060
USDC_DECIMALS = 6
6161

62-
# §5.7/§8.4: the initial request waits in the queue until 402 (30s queue + 30s
63-
# window); retry with PAYMENT-SIGNATURE — up to the 115s budget (settle 20 + provider 30×3)
62+
# The initial request waits in the queue until 402 (30s queue + 30s
63+
# window); retry with PAYMENT-SIGNATURE — up to the 115s retry budget
6464
INITIAL_TIMEOUT_S = 60.0
6565
RETRY_TIMEOUT_S = 115.0
66-
# §8.4: total flow timeout ≥ sum of both (initial + retry ≈ 175s)
66+
# Total flow timeout ≥ sum of both (initial + retry ≈ 175s)
6767
FLOW_TIMEOUT_S = INITIAL_TIMEOUT_S + RETRY_TIMEOUT_S
6868

69-
# Spending policy (fix.md 4.2 step 2): fail-closed, like ASG
69+
# Spending policy: fail-closed
7070
DEFAULT_MAX_PER_CALL_USD = 0.05
7171
DEFAULT_DAILY_CAP_USD = 1.0
7272

7373

7474
class BridgenodeError(Exception):
75-
"""BridgeNode SDK error — server error body (OpenAI format, §5.6)."""
75+
"""BridgeNode SDK error — server error body (OpenAI format)."""
7676

7777
def __init__(self, message: str, status_code: int | None = None,
7878
code: str | None = None) -> None:
@@ -83,7 +83,7 @@ def __init__(self, message: str, status_code: int | None = None,
8383

8484

8585
def _error_message(resp: httpx.Response) -> str:
86-
"""Server error message from the OpenAI-format body (§5.6)."""
86+
"""Server error message from the OpenAI-format body."""
8787
try:
8888
data = resp.json()
8989
err = data.get("error", {})
@@ -94,7 +94,7 @@ def _error_message(resp: httpx.Response) -> str:
9494
return f"HTTP {resp.status_code}"
9595

9696

97-
# fix.md §3: server errorReason → agent-readable hint (if known)
97+
# server errorReason → agent-readable hint (if known)
9898
_ERROR_REASON_HINTS = {
9999
"insufficient_funds": " — fund your wallet with USDC",
100100
}
@@ -125,7 +125,7 @@ def __init__(
125125
env_path: str = ".env",
126126
transport: httpx.BaseTransport | None = None,
127127
) -> None:
128-
"""Creates the client. Key ONLY from `.env` (§8.4 — no arguments)."""
128+
"""Creates the client. Key ONLY from `.env` (no arguments)."""
129129
if load_dotenv is not None:
130130
load_dotenv(env_path)
131131

@@ -148,7 +148,7 @@ def __init__(
148148
os.environ.get("BRIDGENODE_DAILY_CAP", DEFAULT_DAILY_CAP_USD))
149149
self._daily_spend: dict[str, float] = {}
150150

151-
# Official x402 client + SVM signer (§8.2, P4)
151+
# Official x402 client + SVM signer
152152
signer = KeypairSigner.from_base58(self.wallet_key)
153153
self._signer = signer
154154
self.wallet_address = signer.address
@@ -159,7 +159,7 @@ def __init__(
159159
)
160160
# Official practice (docs.x402.org lifecycle-hooks): spending policy
161161
# enforced IN CODE, next to the payment client — protection remains even
162-
# if env vars are missing; the hook runs BEFORE payload creation (§8.4)
162+
# if env vars are missing; the hook runs BEFORE payload creation
163163
self._x402.on_before_payment_creation(self._spending_policy_hook)
164164
self._http_helper = x402HTTPClientSync(self._x402)
165165
self._http = httpx.Client(transport=transport)
@@ -170,7 +170,7 @@ def __init__(
170170
def _post(self, url: str, *, json: dict | None = None,
171171
headers: dict | None = None,
172172
timeout: float | None = None) -> httpx.Response:
173-
"""POST with network errors → BridgenodeError (fix.md 11).
173+
"""POST with network errors → BridgenodeError.
174174
175175
httpx.ConnectError / TimeoutException would otherwise leak as raw
176176
httpx exceptions — the agent expects BridgenodeError everywhere.
@@ -184,7 +184,7 @@ def _post(self, url: str, *, json: dict | None = None,
184184
raise BridgenodeError(f"Request timed out: {exc}") from exc
185185

186186
def _send_stream(self, req: httpx.Request) -> httpx.Response:
187-
"""Streaming send with network errors → BridgenodeError (fix.md 11)."""
187+
"""Streaming send with network errors → BridgenodeError."""
188188
try:
189189
return self._http.send(req, stream=True)
190190
except httpx.ConnectError as exc:
@@ -197,26 +197,26 @@ def _send_stream(self, req: httpx.Request) -> httpx.Response:
197197
def chat(self, model: str | None, messages: str | list[dict],
198198
max_tokens: int | None = None, mode: str | None = None,
199199
stream: bool = False) -> dict[str, Any] | Any:
200-
"""Single chat completion via the automatic x402 handshake (§4.1).
200+
"""Single chat completion via the automatic x402 handshake.
201201
202-
item 41 (§8.4 example): ``messages`` can be a string (automatically
202+
``messages`` can be a string (automatically
203203
converted to ``[{"role": "user", "content": ...}]``) or the
204204
OpenAI format (list[dict]) — the server still receives an OpenAI body.
205205
206-
``stream=True`` (optional, §5.5): returns an iterator of OpenAI SSE
206+
``stream=True`` (optional): returns an iterator of OpenAI SSE
207207
chunks (``dict`` with ``choices[].delta``), terminated by the
208208
``[DONE]`` marker; the receipt is verified and spend recorded BEFORE
209-
the first chunk is yielded (billing boundary, §5.5). Default (False)
209+
the first chunk is yielded (billing boundary). Default (False)
210210
returns the full JSON response — backward-compatible.
211211
212212
step 2: spending policy BEFORE signing; PAYMENT-RESPONSE receipt
213213
verification after 200. Errors → BridgenodeError.
214214
"""
215-
# item 41: string prompt → OpenAI messages format (client side, §8.4)
215+
# string prompt → OpenAI messages format (client side)
216216
if isinstance(messages, str):
217217
messages = [{"role": "user", "content": messages}]
218218
url = f"{self.base_url}/chat/completions"
219-
# item 25: `model` omitted when None — body without `model: null`
219+
# `model` omitted when None — body without `model: null`
220220
# (when sending only `mode`, the server would get JSON null → possible 400)
221221
body: dict[str, Any] = {"messages": messages}
222222
if model is not None:
@@ -230,7 +230,7 @@ def chat(self, model: str | None, messages: str | list[dict],
230230
headers = {"Content-Type": "application/json"}
231231
payload = None # PaymentPayload — for step 2 receipt verification
232232

233-
# item 42 (§8.4): total flow timeout — the whole handshake (initial + SIWX
233+
# total flow timeout — the whole handshake (initial + SIWX
234234
# + payment retry) must fit within the budget; exceeded → BridgenodeError
235235
deadline = time.monotonic() + self.flow_timeout
236236

@@ -239,11 +239,11 @@ def _flow_timeout(call_timeout: float) -> float:
239239
remaining = deadline - time.monotonic()
240240
if remaining <= 0:
241241
raise BridgenodeError(
242-
f"Flow timeout exceeded ({self.flow_timeout:.0f}s, §8.4)")
242+
f"Flow timeout exceeded ({self.flow_timeout:.0f}s)")
243243
return min(call_timeout, remaining)
244244

245-
# 1) Initial request (no payment): queue until 402 (§5.7)
246-
# Client-side retry (§5.7 "Agentas retry'ina"): 503 (queue full /
245+
# 1) Initial request (no payment): queue until 402
246+
# Client-side retry (503 queue full /
247247
# wait timeout) and 429 (per-agent queue cap / 402 rate limit) are
248248
# retried with backoff — BEFORE any payment (nothing was charged,
249249
# retry is free). Retry-After header is honoured when present.
@@ -268,13 +268,13 @@ def _flow_timeout(call_timeout: float) -> float:
268268
else backoff_s * (2 ** attempt), 15.0)
269269
time.sleep(wait)
270270

271-
# 2) 402 → SIWX (step 3) first, then spending policy + payment (§5.7)
271+
# 2) 402 → SIWX first, then spending policy + payment
272272
if resp.status_code == 402:
273273
get_header, _body_data = self._resp_headers(resp)
274274
payment_required = self._http_helper.get_payment_required_response(
275275
get_header, resp.content)
276276
# SIWX: 402 with challenge → sign → retry with SIGN-IN-WITH-X
277-
# (official create_siwx_client_hook); auth fails → payment (§5.7)
277+
# (official create_siwx_client_hook); auth fails → payment
278278
siwx_header = self._build_siwx_header(payment_required, str(resp.url))
279279
if siwx_header:
280280
resp = self._post(
@@ -286,13 +286,13 @@ def _flow_timeout(call_timeout: float) -> float:
286286
get_header, _body_data = self._resp_headers(resp)
287287
payment_required = self._http_helper.get_payment_required_response(
288288
get_header, resp.content)
289-
# item 23: fail-closed — pick a supported accepts entry
290-
# (exact + Solana mainnet + USDC, §3.1); the SDK does not check
289+
# fail-closed — pick a supported accepts entry
290+
# (exact + Solana mainnet + USDC); the SDK does not check
291291
# asset — verified here, BEFORE signing (no TX for other mint/network)
292292
selected = self._select_payment_requirement(payment_required)
293-
# B-4: malformed server amount (decimal/garbage/negative) must
293+
# malformed server amount (decimal/garbage/negative) must
294294
# surface as BridgenodeError, not a raw ValueError crash
295-
# (§8.4 SDK fail-closed).
295+
# (SDK fail-closed).
296296
try:
297297
amount_atomic = int(selected.amount)
298298
except (TypeError, ValueError):
@@ -308,12 +308,12 @@ def _flow_timeout(call_timeout: float) -> float:
308308

309309
pay_headers, payload = self._http_helper.handle_402_response(
310310
dict(resp.headers), resp.content, str(resp.url))
311-
# item 22: payment retry WITHOUT SIGN-IN-WITH-X — official pattern
311+
# payment retry WITHOUT SIGN-IN-WITH-X — official pattern
312312
# "SIWX or payment" (nonce is single-use, already consumed in
313-
# the SIWX retry; §5.7) — hook_headers only for the SIWX retry
313+
# the SIWX retry) — hook_headers only for the SIWX retry
314314
retry_headers = {**headers, **pay_headers}
315315
if stream:
316-
# SSE (§5.5): stream the retry — headers are available
316+
# SSE: stream the retry — headers are available
317317
# immediately, the body is read chunk-by-chunk below
318318
req = self._http.build_request(
319319
"POST", url, json=body, headers=retry_headers,
@@ -325,7 +325,7 @@ def _flow_timeout(call_timeout: float) -> float:
325325
timeout=_flow_timeout(self.retry_timeout))
326326

327327
if resp.status_code != 200:
328-
# fix.md §3: 402 with PAYMENT-RESPONSE — relay the server errorReason
328+
# 402 with PAYMENT-RESPONSE — relay the server errorReason
329329
# (e.g., insufficient_funds) so the agent understands and acts
330330
if stream:
331331
resp.read() # streamed body — materialize before parsing
@@ -341,10 +341,10 @@ def _flow_timeout(call_timeout: float) -> float:
341341
pass # no PAYMENT-RESPONSE — initial 402 (no payment)
342342
raise BridgenodeError(message, status_code=resp.status_code)
343343

344-
# P3#19 (fix.md item 16): spend recorded ONLY after a successful 200 — retry
344+
# Spend recorded ONLY after a successful 200 — retry
345345
# failure (5xx) → the server refunds, a pessimistic cap is unnecessary
346346
# (step 2: receipt verification BEFORE recording spend — if the receipt
347-
# is forged, the spend is NOT recorded, daily cap stays intact; R16/Ž16)
347+
# is forged, the spend is NOT recorded, daily cap stays intact)
348348
if payload is not None:
349349
self._verify_receipt(payload, resp)
350350
self._record_spend(amount_usd)
@@ -353,7 +353,7 @@ def _flow_timeout(call_timeout: float) -> float:
353353
return resp.json()
354354

355355
def _iter_sse(self, resp: httpx.Response) -> Any:
356-
"""Yield OpenAI SSE chunks from a streamed response (§5.5).
356+
"""Yield OpenAI SSE chunks from a streamed response.
357357
358358
Each ``data:`` line is parsed as JSON and yielded as a dict; the
359359
stream ends at ``data: [DONE]``. The response is closed when the
@@ -374,7 +374,7 @@ def _iter_sse(self, resp: httpx.Response) -> Any:
374374
resp.close()
375375

376376
def list_models(self) -> list[dict[str, Any]]:
377-
"""List available models + prices from GET /v1/models (§5.2).
377+
"""List available models + prices from GET /v1/models.
378378
379379
Public endpoint — no payment, no authentication. Returns the
380380
``data`` array (model id, pricing.prompt/completion,
@@ -391,18 +391,18 @@ def list_models(self) -> list[dict[str, Any]]:
391391
data = resp.json()
392392
return data.get("data", [])
393393

394-
# ── SIWX (step 3, §5.7) ────────────────────────────────────────────────────
394+
# ── SIWX ────────────────────────────────────────────────────
395395

396396
def _build_siwx_header(self, payment_required, request_url: str) -> str | None:
397-
"""SIGN-IN-WITH-X header from the 402 SIWX challenge (official hook, §5.7).
397+
"""SIGN-IN-WITH-X header from the 402 SIWX challenge (official hook).
398398
399-
Uses the official ``create_siwx_client_hook`` (P4) — our signer is a
399+
Uses the official ``create_siwx_client_hook`` — our signer is a
400400
solders Keypair, so the signature is sync; the hook is async →
401401
``asyncio.run``. Returns None if the 402 has no SIWX extension or the
402402
chain is unsupported.
403403
404-
P3#18 (fix.md item 16): call from a RUNNING event loop → SIWX skipped
405-
(fallback to payment, §5.7) — ``asyncio.run`` would raise RuntimeError.
404+
Call from a RUNNING event loop → SIWX skipped
405+
(fallback to payment) — ``asyncio.run`` would raise RuntimeError.
406406
Documented: the sync SDK targets non-async contexts.
407407
"""
408408
import asyncio
@@ -415,22 +415,22 @@ def _build_siwx_header(self, payment_required, request_url: str) -> str | None:
415415
else:
416416
logger.warning(
417417
"SIWX skipped — called from a running event loop "
418-
"(fallback to payment, §5.7; P3#18)")
418+
"(fallback to payment)")
419419
return None
420420

421421
try:
422422
hook = create_siwx_client_hook(self._signer)
423-
# x402 2.20.0 (FAZĖ 3 #7.5): the hook context requires request_url
423+
# the hook context requires request_url
424424
result = asyncio.run(hook(
425425
SimpleNamespace(payment_required=payment_required,
426426
request_url=request_url)))
427427
except Exception:
428-
return None # no SIWX — fallback to payment (§5.7)
428+
return None # no SIWX — fallback to payment
429429
if result is None:
430430
return None
431431
return result.headers.get(SIGN_IN_WITH_X)
432432

433-
# ── Supported entry selection (item 23, §3.1) ──────────────────────────────
433+
# ── Supported entry selection ──────────────────────────────
434434

435435
def _select_payment_requirement(self, payment_required):
436436
"""Fail-closed: supported accepts entry (exact + Solana mainnet + USDC).
@@ -439,7 +439,7 @@ def _select_payment_requirement(self, payment_required):
439439
SVM, Solana mainnet) — it does not check the asset. So we verify here
440440
BEFORE signing: the first SDK-supported entry MUST be USDC; otherwise
441441
(different mint, different network, or empty accepts) →
442-
BridgenodeError — no TX (§3.1 "agent SDKs automatically select a
442+
BridgenodeError — no TX (agent SDKs automatically select a
443443
supported entry").
444444
"""
445445
for req in payment_required.accepts:
@@ -459,7 +459,7 @@ def _select_payment_requirement(self, payment_required):
459459
# ── Spending policy (step 2, fail-closed) ──────────────────────────────────
460460

461461
def _spending_policy_hook(self, context) -> AbortResult | None:
462-
"""Spending policy as a lifecycle hook (official practice, §8.4).
462+
"""Spending policy as a lifecycle hook (official practice).
463463
464464
Registered as ``on_before_payment_creation`` — runs BEFORE payment
465465
payload creation, next to the payment client. Returns AbortResult if
@@ -507,7 +507,7 @@ def get_header(name: str) -> str | None:
507507
return get_header, None
508508

509509
def _verify_receipt(self, payload: Any, resp: httpx.Response) -> None:
510-
"""Verifies the PAYMENT-RESPONSE receipt (§7, Free-Riding protection).
510+
"""Verifies the PAYMENT-RESPONSE receipt (Free-Riding protection).
511511
512512
Required: success=true, network = Solana mainnet, payer = our wallet,
513513
transaction = fee payer signature over OUR TX message (forged/incorrect

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "bridgenode-llm"
3-
version = "0.2.18"
3+
version = "0.2.19"
44
description = "Agentic AI inference with x402 (HTTP 402) pay-per-request on Solana USDC — no API keys, no registration."
55
readme = "README.md"
66
requires-python = ">=3.11"

0 commit comments

Comments
 (0)