diff --git a/.env.example b/.env.example index 33197b5..d2bf2ac 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ -# BridgeNode SDK (.env template) — copy to .env (NEVER commit to git) +# BridgeNode SDK (.env template) — copy to .env (NEVER commit to git, §8.3) -# REQUIRED — agent's Solana wallet private key (base58) for payments. -# The SDK uses BRIDGENODE_WALLET_KEY (agent's wallet); do not mix it up -# with the server-side BRIDGENODE_WALLET_PRIVATE. +# REQUIRED — agent's Solana wallet private key (base58) for payments (§8.4: +# the SDK uses BRIDGENODE_WALLET_KEY — agent's wallet; the server uses another, +# BRIDGENODE_WALLET_PRIVATE — do not mix them up) BRIDGENODE_WALLET_KEY= # Optional: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..62b2d93 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..28e4e7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install deps + run: pip install -e .[dev] + - name: Run tests + run: pytest tests/ -q diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..199c0e5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md + +BridgeNode Python SDK (`bridgenode-llm`). AI inference for AI agents — no API keys, pay per request with Solana USDC via x402. + +## Commands + +- `pip install -e .` — install in editable mode +- `pip install -e ".[dev]"` — install with dev deps (pytest) +- `pytest` — run tests +- `python -m build` — build sdist + wheel + +## Structure + +- `bridgenode_llm/` — source (entry: `bridgenode_llm/__init__.py`) +- `tests/` — tests +- `dist/` — build output (GENERATED — never edit by hand) +- `pyproject.toml` — package metadata (name, version, keywords, license) + +## Conventions + +- Python ≥3.11; type hints throughout +- Follow existing patterns in `bridgenode_llm/client.py` +- Keep public exports in `bridgenode_llm/__init__.py` — it's the package API surface + +## Rules + +- ✅ Always: run `pytest` before committing +- ✅ Add tests for new behavior in `tests/` +- ✅ Version bumps live in BOTH `pyproject.toml` AND `bridgenode_llm/__init__.py` +- 🚫 Never: commit `.env` or any secrets (wallet keys!) +- 🚫 Never: edit files in `dist/` (regenerated by build) +- 🚫 Never: hardcode API keys or URLs in code — use `.env` +- 🚫 License is MIT-0 (MIT No Attribution) — never change it diff --git a/README.md b/README.md index df20963..ab74f9f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ [![Downloads](https://img.shields.io/pypi/dm/bridgenode-llm.svg)](https://pypi.org/project/bridgenode-llm/) [![License: MIT-0](https://img.shields.io/badge/License-MIT--0-yellow.svg)](https://opensource.org/license/mit-0/) [![Python versions](https://img.shields.io/pypi/pyversions/bridgenode-llm.svg)](https://pypi.org/project/bridgenode-llm/) +[![CI](https://img.shields.io/github/actions/workflow/status/bridgenode-ai/bridgenode-sdk-python/ci.yml)](https://github.com/bridgenode-ai/bridgenode-sdk-python/actions) +[![BridgeNode on x402-list](https://x402-list.com/badge/bridgenode.svg)](https://x402-list.com/services/bridgenode?utm_source=badge&utm_medium=referral&utm_campaign=embed) BridgeNode Python SDK — AI inference for AI agents, no API keys. Pay per request with **Solana USDC via x402**. The payment handshake is fully automatic, and fees are sponsored — the agent needs no SOL. @@ -17,6 +19,18 @@ BridgeNode Python SDK — AI inference for AI agents, no API keys. Pay per reque - **Smart routing** — `mode: auto / eco / premium` (model selection per prompt complexity) - **MCP support** — BridgeNode is also available as an MCP server +## BridgeNode vs traditional APIs + +| | BridgeNode | Traditional AI APIs | +|---|---|---| +| Sign-up | None | Account + verification | +| API key | Not needed | Required (leak risk) | +| Billing | Pay-per-request (USDC) | Subscription / prepaid credits | +| Setup | Wallet only | SDK + key + quotas | +| Fees | Gas sponsored | Network fees on you | +| Spending control | Fail-closed caps | Account-level limits | +| Identity | Wallet signature (SIWX) | API key | + ## Installation ```bash @@ -72,6 +86,31 @@ BRIDGENODE_WALLET_KEY=... - Python ≥ 3.11 - A Solana wallet with a USDC token account (ATA) +## FAQ + +**Do agents need SOL?** No — fees are sponsored. The agent pays only the USDC request amount. + +**Is there an API key?** No. Your wallet signature is the identity (SIWX); payment is the access. + +**What happens if the spending cap is exceeded?** The request is blocked before signing — fail-closed, no payment is made. + +**Which models are available?** See https://bridgenode.cc/v1/models (live list with prices). + +**How is the payment receipt verified?** Every response includes a receipt; the SDK verifies its signature, network, payer and amount — invalid receipts raise `BridgenodeError`. + +**Can I use this in CI / headless agents?** Yes — no browser, no keys, just a wallet private key in env. + +## Related packages + +The BridgeNode toolkit on PyPI: + +- `bridgenode-llm` — Python SDK (this package): https://pypi.org/project/bridgenode-llm +- `bridgenode-cli` — command-line interface: https://pypi.org/project/bridgenode-cli +- `bridgenode` — full toolkit (SDK + CLI): https://pypi.org/project/bridgenode +- `bridgenode-sdk` — SDK alias package: https://pypi.org/project/bridgenode-sdk +- `bridgenode-mcp` — MCP server package: https://pypi.org/project/bridgenode-mcp +- `bridgenode-skill` — agent skill package: https://pypi.org/project/bridgenode-skill + ## Links - Website: https://bridgenode.cc diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9259920 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +## Reporting a Vulnerability + +BridgeNode is an agent-to-agent (A2A) platform. If you discover a security vulnerability, please report it privately: + +- **Email:** eli.BNx@proton.me + +Please do **not** open a public issue for security vulnerabilities. + +## Supported Versions + +| Version | Supported | +|---|---| +| latest | ✅ | + +## Security Practices + +- No API keys or registration required — payments via x402 (HTTP 402) with Solana USDC +- Wallet keys live only in `.env` — never committed +- Dependencies are audited regularly (pip-audit / npm audit) +- License: MIT-0 (MIT No Attribution) diff --git a/bridgenode_llm/__init__.py b/bridgenode_llm/__init__.py index 95d4afe..9d97508 100644 --- a/bridgenode_llm/__init__.py +++ b/bridgenode_llm/__init__.py @@ -1,11 +1,11 @@ -"""bridgenode_llm — BridgeNode Python SDK (, ). +"""bridgenode_llm — BridgeNode Python SDK (§8.4, fix.md 4.2). LLMClient: automatic x402 V2 handshake (402 → partial TX → PAYMENT-SIGNATURE → 200). Key from `.env` (`BRIDGENODE_WALLET_KEY`) — no arguments, no -interactive prompts (). +interactive prompts (§8.4). """ from .client import BRIDGENODE_BASE_URL, BridgenodeError, LLMClient __all__ = ["LLMClient", "BridgenodeError", "BRIDGENODE_BASE_URL"] -__version__ = "0.2.7" +__version__ = "0.2.16" diff --git a/bridgenode_llm/client.py b/bridgenode_llm/client.py index ea6caf9..b51b1b0 100644 --- a/bridgenode_llm/client.py +++ b/bridgenode_llm/client.py @@ -1,7 +1,7 @@ -"""client.py — LLMClient: automatic x402 V2 handshake (, steps 1–2). +"""client.py — LLMClient: automatic x402 V2 handshake (§8.4, fix.md 4.2 steps 1–2). Flow: POST /v1/chat/completions → 402 (PAYMENT-REQUIRED) → partial TX -(TransferChecked + Memo, –) via the official x402 SVM scheme → +(TransferChecked + Memo, §3.3–§3.4) via the official x402 SVM scheme → PAYMENT-SIGNATURE → retry → 200. step 2 — Receipt verification + spending policy (fail-closed, like ASG): @@ -15,10 +15,10 @@ Rules: - Key from `.env` (`BRIDGENODE_WALLET_KEY`) — no arguments, no interactive - prompts () + prompts (§8.4) - Endpoint: `https://bridgenode.cc/v1` (configurable via `BRIDGENODE_BASE_URL` or argument) -- Two separate timeouts (): initial ≥ 30s (queue until 402, ), +- Two separate timeouts (§4.3/§8.4): initial ≥ 30s (queue until 402, §5.7), retry ≥ 113s (≤ 115s budget) - Uses the official x402 client (x402ClientSync + ExactSvmScheme) — no custom payment code (taisykles.md: don't reinvent the wheel) @@ -27,6 +27,8 @@ from __future__ import annotations import base64 +import base64 +import json import logging import os import time @@ -57,20 +59,20 @@ USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" # Solana mainnet USDC mint USDC_DECIMALS = 6 -# : the initial request waits in the queue until 402 (30s queue + 30s +# §5.7/§8.4: the initial request waits in the queue until 402 (30s queue + 30s # window); retry with PAYMENT-SIGNATURE — up to the 115s budget (settle 20 + provider 30×3) INITIAL_TIMEOUT_S = 60.0 RETRY_TIMEOUT_S = 115.0 -# : total flow timeout ≥ sum of both (initial + retry ≈ 175s) +# §8.4: total flow timeout ≥ sum of both (initial + retry ≈ 175s) FLOW_TIMEOUT_S = INITIAL_TIMEOUT_S + RETRY_TIMEOUT_S -# Spending policy (step 2): fail-closed, like ASG +# Spending policy (fix.md 4.2 step 2): fail-closed, like ASG DEFAULT_MAX_PER_CALL_USD = 0.05 DEFAULT_DAILY_CAP_USD = 1.0 class BridgenodeError(Exception): - """BridgeNode SDK error — server error body (OpenAI format, ).""" + """BridgeNode SDK error — server error body (OpenAI format, §5.6).""" def __init__(self, message: str, status_code: int | None = None, code: str | None = None) -> None: @@ -81,7 +83,7 @@ def __init__(self, message: str, status_code: int | None = None, def _error_message(resp: httpx.Response) -> str: - """Server error message from the OpenAI-format body ().""" + """Server error message from the OpenAI-format body (§5.6).""" try: data = resp.json() err = data.get("error", {}) @@ -92,7 +94,7 @@ def _error_message(resp: httpx.Response) -> str: return f"HTTP {resp.status_code}" -# : server errorReason → agent-readable hint (if known) +# fix.md §3: server errorReason → agent-readable hint (if known) _ERROR_REASON_HINTS = { "insufficient_funds": " — fund your wallet with USDC", } @@ -123,7 +125,7 @@ def __init__( env_path: str = ".env", transport: httpx.BaseTransport | None = None, ) -> None: - """Creates the client. Key ONLY from `.env` ( — no arguments).""" + """Creates the client. Key ONLY from `.env` (§8.4 — no arguments).""" if load_dotenv is not None: load_dotenv(env_path) @@ -146,7 +148,7 @@ def __init__( os.environ.get("BRIDGENODE_DAILY_CAP", DEFAULT_DAILY_CAP_USD)) self._daily_spend: dict[str, float] = {} - # Official x402 client + SVM signer (, P4) + # Official x402 client + SVM signer (§8.2, P4) signer = KeypairSigner.from_base58(self.wallet_key) self._signer = signer self.wallet_address = signer.address @@ -157,7 +159,7 @@ def __init__( ) # Official practice (docs.x402.org lifecycle-hooks): spending policy # enforced IN CODE, next to the payment client — protection remains even - # if env vars are missing; the hook runs BEFORE payload creation () + # if env vars are missing; the hook runs BEFORE payload creation (§8.4) self._x402.on_before_payment_creation(self._spending_policy_hook) self._http_helper = x402HTTPClientSync(self._x402) self._http = httpx.Client(transport=transport) @@ -165,20 +167,52 @@ def __init__( # Last verified receipt (PAYMENT-RESPONSE) — for introspection self.last_receipt: dict[str, Any] | None = None + def _post(self, url: str, *, json: dict | None = None, + headers: dict | None = None, + timeout: float | None = None) -> httpx.Response: + """POST with network errors → BridgenodeError (fix.md 11). + + httpx.ConnectError / TimeoutException would otherwise leak as raw + httpx exceptions — the agent expects BridgenodeError everywhere. + """ + try: + return self._http.post(url, json=json, headers=headers, + timeout=timeout) + except httpx.ConnectError as exc: + raise BridgenodeError(f"Connection failed: {exc}") from exc + except httpx.TimeoutException as exc: + raise BridgenodeError(f"Request timed out: {exc}") from exc + + def _send_stream(self, req: httpx.Request) -> httpx.Response: + """Streaming send with network errors → BridgenodeError (fix.md 11).""" + try: + return self._http.send(req, stream=True) + except httpx.ConnectError as exc: + raise BridgenodeError(f"Connection failed: {exc}") from exc + except httpx.TimeoutException as exc: + raise BridgenodeError(f"Request timed out: {exc}") from exc + # ── API ──────────────────────────────────────────────────────────────── - def chat(self, model: str, messages: str | list[dict], - max_tokens: int | None = None, mode: str | None = None) -> dict[str, Any]: - """Single chat completion via the automatic x402 handshake (). + def chat(self, model: str | None, messages: str | list[dict], + max_tokens: int | None = None, mode: str | None = None, + stream: bool = False) -> dict[str, Any] | Any: + """Single chat completion via the automatic x402 handshake (§4.1). - item 41 ( example): ``messages`` can be a string (automatically + item 41 (§8.4 example): ``messages`` can be a string (automatically converted to ``[{"role": "user", "content": ...}]``) or the OpenAI format (list[dict]) — the server still receives an OpenAI body. + ``stream=True`` (optional, §5.5): returns an iterator of OpenAI SSE + chunks (``dict`` with ``choices[].delta``), terminated by the + ``[DONE]`` marker; the receipt is verified and spend recorded BEFORE + the first chunk is yielded (billing boundary, §5.5). Default (False) + returns the full JSON response — backward-compatible. + step 2: spending policy BEFORE signing; PAYMENT-RESPONSE receipt verification after 200. Errors → BridgenodeError. """ - # item 41: string prompt → OpenAI messages format (client side, ) + # item 41: string prompt → OpenAI messages format (client side, §8.4) if isinstance(messages, str): messages = [{"role": "user", "content": messages}] url = f"{self.base_url}/chat/completions" @@ -191,10 +225,12 @@ def chat(self, model: str, messages: str | list[dict], body["max_tokens"] = max_tokens if mode is not None: body["mode"] = mode + if stream: + body["stream"] = True headers = {"Content-Type": "application/json"} payload = None # PaymentPayload — for step 2 receipt verification - # item 42 (): total flow timeout — the whole handshake (initial + SIWX + # item 42 (§8.4): total flow timeout — the whole handshake (initial + SIWX # + payment retry) must fit within the budget; exceeded → BridgenodeError deadline = time.monotonic() + self.flow_timeout @@ -203,23 +239,45 @@ def _flow_timeout(call_timeout: float) -> float: remaining = deadline - time.monotonic() if remaining <= 0: raise BridgenodeError( - f"Flow timeout exceeded ({self.flow_timeout:.0f}s, )") + f"Flow timeout exceeded ({self.flow_timeout:.0f}s, §8.4)") return min(call_timeout, remaining) - # 1) Initial request (no payment): queue until 402 () - resp = self._http.post(url, json=body, headers=headers, + # 1) Initial request (no payment): queue until 402 (§5.7) + # Client-side retry (§5.7 "Agentas retry'ina"): 503 (queue full / + # wait timeout) and 429 (per-agent queue cap / 402 rate limit) are + # retried with backoff — BEFORE any payment (nothing was charged, + # retry is free). Retry-After header is honoured when present. + # After payment: NO retry (single retry with PAYMENT-SIGNATURE only). + retries = 3 + backoff_s = 1.0 + for attempt in range(retries + 1): + resp = self._post(url, json=body, headers=headers, timeout=_flow_timeout(self.initial_timeout)) - - # 2) 402 → SIWX (step 3) first, then spending policy + payment () + if resp.status_code not in (503, 429): + break + if attempt >= retries: + break + retry_after = None + try: + ra = resp.headers.get("Retry-After") + if ra: + retry_after = float(ra) + except (TypeError, ValueError): + retry_after = None + wait = min(retry_after if retry_after is not None + else backoff_s * (2 ** attempt), 15.0) + time.sleep(wait) + + # 2) 402 → SIWX (step 3) first, then spending policy + payment (§5.7) if resp.status_code == 402: get_header, _body_data = self._resp_headers(resp) payment_required = self._http_helper.get_payment_required_response( get_header, resp.content) # SIWX: 402 with challenge → sign → retry with SIGN-IN-WITH-X - # (official create_siwx_client_hook); auth fails → payment () - siwx_header = self._build_siwx_header(payment_required) + # (official create_siwx_client_hook); auth fails → payment (§5.7) + siwx_header = self._build_siwx_header(payment_required, str(resp.url)) if siwx_header: - resp = self._http.post( + resp = self._post( url, json=body, headers={**headers, SIGN_IN_WITH_X: siwx_header}, timeout=_flow_timeout(self.initial_timeout)) @@ -229,7 +287,7 @@ def _flow_timeout(call_timeout: float) -> float: payment_required = self._http_helper.get_payment_required_response( get_header, resp.content) # item 23: fail-closed — pick a supported accepts entry - # (exact + Solana mainnet + USDC, ); the SDK does not check + # (exact + Solana mainnet + USDC, §3.1); the SDK does not check # asset — verified here, BEFORE signing (no TX for other mint/network) selected = self._select_payment_requirement(payment_required) amount_atomic = int(selected.amount) @@ -237,18 +295,28 @@ def _flow_timeout(call_timeout: float) -> float: self._check_spending(amount_usd) pay_headers, payload = self._http_helper.handle_402_response( - dict(resp.headers), resp.content) + dict(resp.headers), resp.content, str(resp.url)) # item 22: payment retry WITHOUT SIGN-IN-WITH-X — official pattern # "SIWX or payment" (nonce is single-use, already consumed in - # the SIWX retry; ) — hook_headers only for the SIWX retry + # the SIWX retry; §5.7) — hook_headers only for the SIWX retry retry_headers = {**headers, **pay_headers} - resp = self._http.post(url, json=body, + if stream: + # SSE (§5.5): stream the retry — headers are available + # immediately, the body is read chunk-by-chunk below + req = self._http.build_request( + "POST", url, json=body, headers=retry_headers, + timeout=_flow_timeout(self.retry_timeout)) + resp = self._send_stream(req) + else: + resp = self._post(url, json=body, headers=retry_headers, timeout=_flow_timeout(self.retry_timeout)) if resp.status_code != 200: - # : 402 with PAYMENT-RESPONSE — relay the server errorReason + # fix.md §3: 402 with PAYMENT-RESPONSE — relay the server errorReason # (e.g., insufficient_funds) so the agent understands and acts + if stream: + resp.read() # streamed body — materialize before parsing message = _error_message(resp) if resp.status_code == 402: try: @@ -261,27 +329,68 @@ def _flow_timeout(call_timeout: float) -> float: pass # no PAYMENT-RESPONSE — initial 402 (no payment) raise BridgenodeError(message, status_code=resp.status_code) - # P3#19 (item 16): spend recorded ONLY after a successful 200 — retry + # P3#19 (fix.md item 16): spend recorded ONLY after a successful 200 — retry # failure (5xx) → the server refunds, a pessimistic cap is unnecessary - # (step 2: receipt verification afterwards; SIWX-granted 200 without payment → - # payload None → nothing to record) + # (step 2: receipt verification BEFORE recording spend — if the receipt + # is forged, the spend is NOT recorded, daily cap stays intact; R16/Ž16) if payload is not None: - self._record_spend(amount_usd) self._verify_receipt(payload, resp) + self._record_spend(amount_usd) + if stream: + return self._iter_sse(resp) return resp.json() - # ── SIWX (step 3, ) ──────────────────────────────────────────────────── + def _iter_sse(self, resp: httpx.Response) -> Any: + """Yield OpenAI SSE chunks from a streamed response (§5.5). + + Each ``data:`` line is parsed as JSON and yielded as a dict; the + stream ends at ``data: [DONE]``. The response is closed when the + iterator is exhausted (or the caller stops iterating). + """ + try: + for line in resp.iter_lines(): + if not line.startswith("data:"): + continue + data = line[len("data:"):].strip() + if data == "[DONE]": + break + try: + yield json.loads(data) + except json.JSONDecodeError: + continue # keep-alive comment or partial line — skip + finally: + resp.close() + + def list_models(self) -> list[dict[str, Any]]: + """List available models + prices from GET /v1/models (§5.2). + + Public endpoint — no payment, no authentication. Returns the + ``data`` array (model id, pricing.prompt/completion, + context_window, max_output_tokens). Errors → BridgenodeError. + """ + url = f"{self.base_url}/models" + try: + resp = self._http.get(url, timeout=self.initial_timeout) + except httpx.HTTPError as exc: + raise BridgenodeError(f"models request failed: {exc}") from exc + if resp.status_code != 200: + raise BridgenodeError( + _error_message(resp), status_code=resp.status_code) + data = resp.json() + return data.get("data", []) + + # ── SIWX (step 3, §5.7) ──────────────────────────────────────────────────── - def _build_siwx_header(self, payment_required) -> str | None: - """SIGN-IN-WITH-X header from the 402 SIWX challenge (official hook, ). + def _build_siwx_header(self, payment_required, request_url: str) -> str | None: + """SIGN-IN-WITH-X header from the 402 SIWX challenge (official hook, §5.7). Uses the official ``create_siwx_client_hook`` (P4) — our signer is a solders Keypair, so the signature is sync; the hook is async → ``asyncio.run``. Returns None if the 402 has no SIWX extension or the chain is unsupported. - P3#18 (item 16): call from a RUNNING event loop → SIWX skipped - (fallback to payment, ) — ``asyncio.run`` would raise RuntimeError. + P3#18 (fix.md item 16): call from a RUNNING event loop → SIWX skipped + (fallback to payment, §5.7) — ``asyncio.run`` would raise RuntimeError. Documented: the sync SDK targets non-async contexts. """ import asyncio @@ -294,20 +403,22 @@ def _build_siwx_header(self, payment_required) -> str | None: else: logger.warning( "SIWX skipped — called from a running event loop " - "(fallback to payment, ; P3#18)") + "(fallback to payment, §5.7; P3#18)") return None try: hook = create_siwx_client_hook(self._signer) + # x402 2.20.0 (FAZĖ 3 #7.5): the hook context requires request_url result = asyncio.run(hook( - SimpleNamespace(payment_required=payment_required))) + SimpleNamespace(payment_required=payment_required, + request_url=request_url))) except Exception: - return None # no SIWX — fallback to payment () + return None # no SIWX — fallback to payment (§5.7) if result is None: return None return result.headers.get(SIGN_IN_WITH_X) - # ── Supported entry selection (item 23, ) ────────────────────────────── + # ── Supported entry selection (item 23, §3.1) ────────────────────────────── def _select_payment_requirement(self, payment_required): """Fail-closed: supported accepts entry (exact + Solana mainnet + USDC). @@ -316,7 +427,7 @@ def _select_payment_requirement(self, payment_required): SVM, Solana mainnet) — it does not check the asset. So we verify here BEFORE signing: the first SDK-supported entry MUST be USDC; otherwise (different mint, different network, or empty accepts) → - BridgenodeError — no TX ( "agent SDKs automatically select a + BridgenodeError — no TX (§3.1 "agent SDKs automatically select a supported entry"). """ for req in payment_required.accepts: @@ -336,7 +447,7 @@ def _select_payment_requirement(self, payment_required): # ── Spending policy (step 2, fail-closed) ────────────────────────────────── def _spending_policy_hook(self, context) -> AbortResult | None: - """Spending policy as a lifecycle hook (official practice, ). + """Spending policy as a lifecycle hook (official practice, §8.4). Registered as ``on_before_payment_creation`` — runs BEFORE payment payload creation, next to the payment client. Returns AbortResult if @@ -384,7 +495,7 @@ def get_header(name: str) -> str | None: return get_header, None def _verify_receipt(self, payload: Any, resp: httpx.Response) -> None: - """Verifies the PAYMENT-RESPONSE receipt (, Free-Riding protection). + """Verifies the PAYMENT-RESPONSE receipt (§7, Free-Riding protection). Required: success=true, network = Solana mainnet, payer = our wallet, transaction = fee payer signature over OUR TX message (forged/incorrect diff --git a/pyproject.toml b/pyproject.toml index 7c0d0b1..c8e117e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "bridgenode-llm" -version = "0.2.9" -description = "BridgeNode LLM client — AI inference with x402 payment on Solana USDC. No API keys, no registration." +version = "0.2.16" +description = "Agentic AI inference with x402 (HTTP 402) pay-per-request on Solana USDC — no API keys, no registration." readme = "README.md" requires-python = ">=3.11" license = "MIT-0" @@ -9,7 +9,7 @@ license-files = ["LICENSE"] authors = [ { name = "BridgeNode", email = "eli.BNx@proton.me" }, ] -keywords = ["x402", "solana", "usdc", "ai", "llm", "mcp", "agents", "payment", "bridgenode", "openai-compatible", "chat", "inference", "micropayments"] +keywords = ["x402", "http-402", "agentic-commerce", "solana", "usdc", "ai", "llm", "mcp", "agents", "payment", "bridgenode", "openai-compatible", "chat", "inference", "micropayments", "pay-per-request", "no-api-key", "gasless", "deepseek", "groq", "mcp-server", "wallet"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -22,11 +22,14 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Typing :: Typed", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Libraries", + "Natural Language :: English", ] dependencies = [ - "x402[svm]>=2.18.0", - "x402[extensions]>=2.18.0", # client.py imports x402.extensions.sign_in_with_x — clean install fails without it - "solana>=0.36.0,<0.40", # solana 0.40.x removed solana.rpc.api — x402 imports it (broken combo) + "x402[svm]>=2.19.0", + "x402[extensions]>=2.19.0", # client.py imports x402.extensions.sign_in_with_x — clean install fails without it + "solana>=0.36.0,<0.41", # solana 0.40.x removed solana.rpc.api — x402 imports it (broken combo) "httpx>=0.28.1", "python-dotenv>=1.2.2", ] @@ -38,8 +41,11 @@ dev = [ [project.urls] Homepage = "https://bridgenode.cc" -Repository = "https://github.com/applefanaimail-blip/bridgenode-sdk-python" +Repository = "https://github.com/bridgenode-ai/bridgenode-sdk-python" Documentation = "https://bridgenode.cc/llms.txt" +Changelog = "https://github.com/bridgenode-ai/bridgenode-sdk-python/releases" +Issues = "https://github.com/bridgenode-ai/bridgenode-sdk-python/issues" +Models = "https://bridgenode.cc/models" [build-system] requires = ["setuptools>=75"] diff --git a/tests/test_client.py b/tests/test_client.py index a6408ed..15a463b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,12 +1,12 @@ -"""test_client.py — LLMClient tests (–). +"""test_client.py — LLMClient tests (fix.md 4.2 z1–z2). -: automatic x402 handshake (402 → PAYMENT-SIGNATURE → 200). -: PAYMENT-RESPONSE receipt verification (success/network/payer/TX signature/ +z1: automatic x402 handshake (402 → PAYMENT-SIGNATURE → 200). +z2: PAYMENT-RESPONSE receipt verification (success/network/payer/TX signature/ amount) + spending policy (MAX_PER_CALL, DAILY_CAP — fail-closed). Mocked HTTP transport: first request → 402 with PAYMENT-REQUIRED; retry with PAYMENT-SIGNATURE → 200 with a valid receipt (the mock server signs -the TX message as fee payer — like a real facilitator ). No real +the TX message as fee payer — like a real facilitator §8.2). No real network, no real RPC (mint metadata mocked). """ @@ -64,7 +64,7 @@ def _test_wallet_key(kp: Keypair) -> str: def _envelope(pay_to: str, fee_payer: str, memo: str = "pi_test123", amount: str = "2000", siwx: bool = False, asset: str = USDC, network: str = NETWORK) -> dict: - """402 V2 envelope () — as the server sends it.""" + """402 V2 envelope (§3.1) — as the server sends it.""" env = { "x402Version": 2, "error": "PAYMENT-SIGNATURE header is required", @@ -92,17 +92,21 @@ def _envelope(pay_to: str, fee_payer: str, memo: str = "pi_test123", } if siwx: now = datetime.now(timezone.utc) + # x402 2.20.0 (FAZĖ 3 #7.5): SIWX challenge MUST match the response URL + # origin (assert_siwx_challenge_bound_to_origin — fail-closed). The mock + # server lives at http://test/v1, so the challenge is bound to it. + siwx_uri = "http://test/v1/chat/completions" env["extensions"] = { "sign-in-with-x": { "info": { - "domain": "bridgenode.cc", - "uri": "https://bridgenode.cc/v1/chat/completions", + "domain": "test", + "uri": siwx_uri, "version": "1", "nonce": "nonce1234567890abcdef", "issuedAt": now.isoformat().replace("+00:00", "Z"), "expirationTime": (now + timedelta(minutes=5)) .isoformat().replace("+00:00", "Z"), - "resources": ["https://bridgenode.cc/v1/chat/completions"], + "resources": [siwx_uri], }, "supportedChains": [ {"chainId": NETWORK, "type": "ed25519"}, @@ -163,7 +167,7 @@ def _make_server(fee_payer_kp: Keypair, client_wallet: str, """Creates a mock server handler: 402 → valid receipt → 200. Returns (handler, seen) — seen: list of requests (for tests). - ``siwx``: 402 with SIWX challenge (); ``siwx_granted``: SIWX retry + ``siwx``: 402 with SIWX challenge (§5.7); ``siwx_granted``: SIWX retry answered with 200 immediately (known agent without payment). """ seen: list[dict] = [] @@ -177,7 +181,7 @@ def handler(request: httpx.Request) -> httpx.Response: "body": json.loads(request.content), }) if not request.headers.get("PAYMENT-SIGNATURE"): - # SIWX retry with a known agent → 200 without payment () + # SIWX retry with a known agent → 200 without payment (§5.7) if siwx_granted and request.headers.get("SIGN-IN-WITH-X"): return httpx.Response(200, json=_openai_response()) env = _envelope(pay_to=client_wallet, @@ -204,7 +208,7 @@ def handler(request: httpx.Request) -> httpx.Response: def _make_client(handler, wallet_key=None, **kwargs) -> LLMClient: - """LLMClient with BRIDGENODE_WALLET_KEY env (key ONLY from .env, ).""" + """LLMClient with BRIDGENODE_WALLET_KEY env (key ONLY from .env, §8.4).""" transport = httpx.MockTransport(handler) saved = os.environ.get("BRIDGENODE_WALLET_KEY") os.environ["BRIDGENODE_WALLET_KEY"] = ( @@ -222,7 +226,7 @@ def _make_client(handler, wallet_key=None, **kwargs) -> LLMClient: os.environ["BRIDGENODE_WALLET_KEY"] = saved -# ── Handshake () ─────────────────────────────────────────────────────────── +# ── Handshake (z1) ─────────────────────────────────────────────────────────── def test_chat_handshake_success(): """402 → partial TX → PAYMENT-SIGNATURE → retry → 200 + valid receipt.""" @@ -239,7 +243,7 @@ def test_chat_handshake_success(): assert len(seen) == 2 assert seen[0]["has_payment"] is False assert seen[1]["has_payment"] is True - assert seen[0]["body"] == seen[1]["body"] # price-bind () + assert seen[0]["body"] == seen[1]["body"] # price-bind (§4.1.1) assert client.last_receipt["success"] is True assert client.last_receipt["network"] == NETWORK assert client.last_receipt["payer"] == client_wallet @@ -258,7 +262,7 @@ def test_chat_with_mode_and_max_tokens(): def test_chat_mode_without_model_omits_model_key(): - """: `model=None` + `mode` → body WITHOUT `model` key (not JSON null).""" + """Z25: `model=None` + `mode` → body WITHOUT `model` key (not JSON null).""" fee_kp = _test_keypair() client_kp = _test_keypair() handler, seen = _make_server(fee_kp, str(client_kp.pubkey())) @@ -274,8 +278,8 @@ def test_chat_mode_without_model_omits_model_key(): def test_chat_string_prompt_same_body_as_list(): - """: string prompt is converted to OpenAI messages — identical body - as with list[dict] (protocol example `chat(model, "Hello!")`).""" + """Z41: string prompt is converted to OpenAI messages — identical body + as with list[dict] (protocol §8.4 example `chat(model, "Hello!")`).""" fee_kp = _test_keypair() client_kp = _test_keypair() handler, seen = _make_server(fee_kp, str(client_kp.pubkey())) @@ -290,7 +294,7 @@ def test_chat_string_prompt_same_body_as_list(): def test_server_error_raises(): - """Non-402 error → BridgenodeError with the server message ().""" + """Non-402 error → BridgenodeError with the server message (§5.6).""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(400, json={ "error": {"message": "Unknown model", "type": "invalid_request_error", @@ -303,10 +307,10 @@ def handler(request: httpx.Request) -> httpx.Response: assert "Unknown model" in str(exc.value) -# ── Receipt verification () ────────────────────────────────────────────────── +# ── Receipt verification (z2) ────────────────────────────────────────────────── def test_receipt_missing_header_raises(): - """200 WITHOUT PAYMENT-RESPONSE receipt → error (not silent, ).""" + """200 WITHOUT PAYMENT-RESPONSE receipt → error (not silent, §4.2 z2).""" fee_kp = _test_keypair() client_kp = _test_keypair() handler, _seen = _make_server(fee_kp, str(client_kp.pubkey()), @@ -331,7 +335,7 @@ def test_receipt_success_false_raises(): def test_402_error_reason_passthrough(): """402 retry with PAYMENT-RESPONSE errorReason → the agent sees the reason - (: the server errorReason is passed through, with guidance if known).""" + (fix.md §3: the server errorReason is passed through, with guidance if known).""" fee_kp = _test_keypair() client_kp = _test_keypair() client_wallet = str(client_kp.pubkey()) @@ -346,7 +350,7 @@ def handler(request: httpx.Request) -> httpx.Response: json.dumps(env).encode()).decode()}, json=env, ) - # Retry with payment → the server rejects (insufficient balance, ) + # Retry with payment → the server rejects (insufficient balance, §5.6) settle = SettleResponse( success=False, error_reason="insufficient_funds", transaction="", network=NETWORK, payer=client_wallet) @@ -420,10 +424,10 @@ def test_receipt_wrong_amount_raises(): client.chat("deepseek-v4-flash", [{"role": "user", "content": "hi"}]) -# ── Supported entry selection (, ) ─────────────────────────────── +# ── Supported entry selection (Z23, §3.1) ─────────────────────────────── def test_payment_wrong_asset_blocks(): - """402 with another mint (not USDC) → error BEFORE signing (no TX, ).""" + """402 with another mint (not USDC) → error BEFORE signing (no TX, Z23).""" fee_kp = _test_keypair() client_kp = _test_keypair() other_mint = "So11111111111111111111111111111111111111112" # SOL mint @@ -439,7 +443,7 @@ def test_payment_wrong_asset_blocks(): def test_payment_wrong_network_blocks(): - """402 with another network → error BEFORE signing (no TX, ).""" + """402 with another network → error BEFORE signing (no TX, Z23).""" fee_kp = _test_keypair() client_kp = _test_keypair() other_network = "solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ" # devnet @@ -455,7 +459,7 @@ def test_payment_wrong_network_blocks(): def test_payment_empty_accepts_blocks(): - """Empty accepts → error BEFORE signing (no TX, ).""" + """Empty accepts → error BEFORE signing (no TX, Z23).""" fee_kp = _test_keypair() client_kp = _test_keypair() @@ -475,7 +479,7 @@ def handler(request: httpx.Request) -> httpx.Response: client.chat("deepseek-v4-flash", [{"role": "user", "content": "hi"}]) -# ── SIWX (, ) ──────────────────────────────────────────────────────── +# ── SIWX (z3, §5.7) ──────────────────────────────────────────────────────── def test_siwx_header_sent_on_402(): """402 with SIWX challenge → client signs → retry with SIGN-IN-WITH-X.""" @@ -491,8 +495,8 @@ def test_siwx_header_sent_on_402(): assert seen[0]["has_siwx"] is False assert seen[1]["has_siwx"] is True assert seen[1]["has_payment"] is False - # : payment retry WITHOUT SIGN-IN-WITH-X — official pattern "SIWX or - # payment" (nonce is one-time, already used; ) + # Z22: payment retry WITHOUT SIGN-IN-WITH-X — official pattern "SIWX or + # payment" (nonce is one-time, already used; §5.7) assert seen[2]["has_payment"] is True assert seen[2]["has_siwx"] is False @@ -512,7 +516,7 @@ def test_siwx_no_challenge_normal_payment(): def test_siwx_granted_direct_200(): - """Known agent: SIWX retry → 200 without payment () — payload None, no receipt.""" + """Known agent: SIWX retry → 200 without payment (§5.7) — payload None, no receipt.""" fee_kp = _test_keypair() client_kp = _test_keypair() handler, seen = _make_server(fee_kp, str(client_kp.pubkey()), @@ -528,7 +532,7 @@ def test_siwx_granted_direct_200(): def test_siwx_header_cryptographically_valid(): - """SIGN-IN-WITH-X header — officially verifiable (Ed25519, ).""" + """SIGN-IN-WITH-X header — officially verifiable (Ed25519, §5.7).""" import asyncio from x402.extensions.sign_in_with_x import parse_siwx_header, verify_siwx_signature @@ -544,14 +548,15 @@ def test_siwx_header_cryptographically_valid(): assert header payload = parse_siwx_header(header) assert payload.address == str(client_kp.pubkey()) - assert payload.domain == "bridgenode.cc" + # x402 2.20.0 (FAZĖ 3 #7.5): challenge bound to response origin (http://test) + assert payload.domain == "test" assert payload.nonce == "nonce1234567890abcdef" result = asyncio.run(verify_siwx_signature(payload)) assert result.is_valid is True assert result.payer == str(client_kp.pubkey()) -# ── Spending policy (, fail-closed) ──────────────────────────────────────── +# ── Spending policy (z2, fail-closed) ──────────────────────────────────────── def test_spending_max_per_call_blocks(): """402 amount > MAX_PER_CALL → blocked BEFORE signing (no payment).""" @@ -589,7 +594,7 @@ def test_spending_daily_cap_blocks(): def test_spending_env_overrides(monkeypatch): - """BRIDGENODE_MAX_PER_CALL/DAILY_CAP from env (configurable, ).""" + """BRIDGENODE_MAX_PER_CALL/DAILY_CAP from env (configurable, §8.5).""" monkeypatch.setenv("BRIDGENODE_MAX_PER_CALL", "0.5") monkeypatch.setenv("BRIDGENODE_DAILY_CAP", "5.0") monkeypatch.setenv("BRIDGENODE_WALLET_KEY", _test_wallet_key(_test_keypair())) @@ -599,12 +604,12 @@ def test_spending_env_overrides(monkeypatch): assert client.daily_cap == 5.0 -# ── Configuration () ───────────────────────────────────────────────────── +# ── Configuration (§8.4) ───────────────────────────────────────────────────── def test_missing_wallet_key_raises(monkeypatch): monkeypatch.delenv("BRIDGENODE_WALLET_KEY", raising=False) with pytest.raises(BridgenodeError, match="BRIDGENODE_WALLET_KEY"): - LLMClient(base_url="http://test/v1") + LLMClient(base_url="http://test/v1", env_path="/nonexistent/.env") def test_default_base_url(): @@ -613,8 +618,8 @@ def test_default_base_url(): def test_timeouts_defaults(): - """: initial ≥ 30s (queue until 402), retry ≥ 113s (≤115s budget), - total flow timeout ≥ initial + retry ().""" + """§8.4: initial ≥ 30s (queue until 402), retry ≥ 113s (≤115s budget), + total flow timeout ≥ initial + retry (Z42).""" with _make_client(lambda r: httpx.Response(500)) as client: assert client.initial_timeout >= 30.0 assert client.retry_timeout >= 113.0 @@ -623,8 +628,8 @@ def test_timeouts_defaults(): def test_flow_timeout_triggers(): - """: flow timeout exceeded → BridgenodeError BEFORE any request - (total budget = 0 → immediate error, ).""" + """Z42: flow timeout exceeded → BridgenodeError BEFORE any request + (total budget = 0 → immediate error, §8.4).""" fee_kp = _test_keypair() client_kp = _test_keypair() handler, seen = _make_server(fee_kp, str(client_kp.pubkey())) @@ -644,3 +649,180 @@ def test_env_override(monkeypatch): transport = httpx.MockTransport(lambda r: httpx.Response(500)) with LLMClient(transport=transport) as client: assert client.base_url == "https://alt.example/v1" + + +def test_chat_stream_sse(): + """stream=True (§5.5): SSE chunks yielded, receipt verified, [DONE] ends. + + The 402 handshake is identical to non-stream; the paid retry returns + a text/event-stream body that the client parses chunk-by-chunk. + """ + fee_kp = _test_keypair() + client_kp = _test_keypair() + seen: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append({ + "has_payment": request.headers.get("PAYMENT-SIGNATURE") is not None, + "body": json.loads(request.content), + }) + if not request.headers.get("PAYMENT-SIGNATURE"): + env = _envelope(pay_to=str(client_kp.pubkey()), + fee_payer=str(fee_kp.pubkey()), + amount="2000") + return httpx.Response( + 402, + headers={"PAYMENT-REQUIRED": base64.b64encode( + json.dumps(env).encode()).decode()}, + json=env, + ) + headers = {"PAYMENT-RESPONSE": _receipt_header( + request.headers["PAYMENT-SIGNATURE"], fee_kp, + str(client_kp.pubkey()))} + sse = ( + 'data: {"id":"1","choices":[{"delta":{"content":"Hel"}}]}\n\n' + 'data: {"id":"1","choices":[{"delta":{"content":"lo"}}]}\n\n' + 'data: [DONE]\n\n' + ) + return httpx.Response(200, text=sse, headers=headers) + + with _make_client(handler, _test_wallet_key(client_kp)) as client: + chunks = list(client.chat("deepseek-v4-flash", "hi", stream=True)) + + # stream: true was sent in the body + assert seen[0]["body"].get("stream") is True + assert seen[1]["body"].get("stream") is True + # SSE chunks parsed, [DONE] stops the iteration + assert len(chunks) == 2 + assert chunks[0]["choices"][0]["delta"]["content"] == "Hel" + assert chunks[1]["choices"][0]["delta"]["content"] == "lo" + # receipt verified (Free-Riding, §8.4) — last_receipt populated + assert client.last_receipt is not None + assert client.last_receipt["success"] is True + assert client.last_receipt["payer"] == str(client_kp.pubkey()) + + +def test_chat_stream_sse(): + """stream=True (§5.5): SSE chunks parsed, [DONE] stops, receipt verified.""" + fee_kp = _test_keypair() + client_kp = _test_keypair() + seen: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append({ + "has_payment": request.headers.get("PAYMENT-SIGNATURE") is not None, + "body": json.loads(request.content), + }) + if not request.headers.get("PAYMENT-SIGNATURE"): + env = _envelope(pay_to=str(client_kp.pubkey()), + fee_payer=str(fee_kp.pubkey()), + amount="2000") + return httpx.Response( + 402, + headers={"PAYMENT-REQUIRED": base64.b64encode( + json.dumps(env).encode()).decode()}, + json=env, + ) + headers = {"PAYMENT-RESPONSE": _receipt_header( + request.headers["PAYMENT-SIGNATURE"], fee_kp, + str(client_kp.pubkey()))} + sse = ( + 'data: {"id":"1","choices":[{"delta":{"content":"Hel"}}]}\n\n' + 'data: {"id":"1","choices":[{"delta":{"content":"lo"}}]}\n\n' + 'data: [DONE]\n\n' + ) + return httpx.Response(200, text=sse, headers=headers) + + with _make_client(handler, _test_wallet_key(client_kp)) as client: + chunks = list(client.chat("deepseek-v4-flash", "hi", stream=True)) + + # stream: true was sent; payment handshake happened + assert seen[0]["body"].get("stream") is True + assert seen[1]["body"].get("stream") is True + assert seen[1]["has_payment"] is True + # SSE chunks parsed until [DONE] + assert len(chunks) == 2 + assert chunks[0]["choices"][0]["delta"]["content"] == "Hel" + assert chunks[1]["choices"][0]["delta"]["content"] == "lo" + # receipt verified (last_receipt set by _verify_receipt) + assert client.last_receipt is not None + assert client.last_receipt["success"] is True + + +def test_chat_retries_503_then_succeeds(): + """§5.7 client-side retry: 503 (queue full) → backoff → 402 → payment → 200. + + Retry happens BEFORE any payment — nothing was charged on 503, retry is + free; after payment there is NO retry (single PAYMENT-SIGNATURE retry). + """ + fee_kp = _test_keypair() + client_kp = _test_keypair() + seen: list[dict] = [] + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get("PAYMENT-SIGNATURE") is not None) + if not request.headers.get("PAYMENT-SIGNATURE"): + calls["n"] += 1 + # First call → 503 (queue full, §5.7); second → 402 challenge + if calls["n"] == 1: + return httpx.Response(503, headers={"Retry-After": "1"}, + json={"error": {"message": "queue full"}}) + env = _envelope(pay_to=str(client_kp.pubkey()), + fee_payer=str(fee_kp.pubkey()), + amount="2000") + return httpx.Response( + 402, + headers={"PAYMENT-REQUIRED": base64.b64encode( + json.dumps(env).encode()).decode()}, + json=env, + ) + headers = {"PAYMENT-RESPONSE": _receipt_header( + request.headers["PAYMENT-SIGNATURE"], fee_kp, + str(client_kp.pubkey()))} + return httpx.Response(200, json=_openai_response(), headers=headers) + + with _make_client(handler, _test_wallet_key(client_kp)) as client: + resp = client.chat("deepseek-v4-flash", "hi") + + # 503 retried (3 initial attempts possible; here 2nd succeeds to 402) + assert calls["n"] == 2, f"expected 2 unpaid attempts, got {calls['n']}" + assert resp["choices"][0]["message"]["content"] == "Hello!" + # exactly one payment retry (PAYMENT-SIGNATURE) — no retry after payment + assert seen.count(True) == 1 + + +def test_list_models(): + """§5.2: list_models() — public GET /models, no payment, prices returned.""" + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(str(request.url)) + return httpx.Response(200, json={"object": "list", "data": [ + {"id": "deepseek-v4-flash", + "pricing": {"prompt": 0.00000020, "completion": 0.00000040}, + "context_window": 1048576, "max_output_tokens": 8192}, + {"id": "groq-llama-3.3-70b", + "pricing": {"prompt": 0.00000079, "completion": 0.00000099}, + "context_window": 131072, "max_output_tokens": 32768}, + ]}) + + with _make_client(handler) as client: + models = client.list_models() + + assert len(models) == 2 + assert models[0]["id"] == "deepseek-v4-flash" + assert models[0]["pricing"]["prompt"] == 0.00000020 + assert seen == ["http://test/v1/models"] + + +def test_list_models_error(): + """§5.2: non-200 from /models → BridgenodeError with status.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(503, json={"error": {"message": "down"}}) + + with _make_client(handler) as client: + with pytest.raises(BridgenodeError) as exc: + client.list_models() + assert exc.value.status_code == 503