From 75541b9a34f125b1bc057a4be48ac3aeb9768b8a Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 27 Jul 2026 22:40:51 -0700 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20adopt=20ruff=200.16=20=E2=80=94=20?= =?UTF-8?q?1631=20typing=20fixes,=20verified=20against=20Python=203.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #36 pinned ruff at 0.14.11 because 0.16 reported 1903 findings and turning CI green by absorbing them silently would have been the wrong move. This is that change, made on purpose. The trap was real. 1529 of the fixes are classified UNSAFE, and this package declares requires-python = ">=3.9" — PEP 604 (`X | None`) is a runtime error there. Applying them blind would have broken the published 3.9 support while every test passed on 3.13. What made them safe was the rule ruff was already pointing at: FA100, 341 occurrences of "this file could use `from __future__ import annotations`". With that import every annotation is a string, so PEP 585/604 syntax never executes. Added to the 16 files that lacked it, THEN the fixes applied. Verified rather than assumed: - grepped for union syntax outside annotations — cast(), TypeAlias, isinstance(). None. Every union is in an annotation, where laziness applies. - compiled the whole package under a real Python 3.9.21. Exit 0. - 436 tests pass. types.py is pydantic, which resolves string annotations, so that one was checked by import as well as by suite. The 227 remaining findings are behaviour, not typing, and are ignored with a written reason each rather than silently: 157 blind excepts where some are deliberate best-effort paths, 54 try/except/pass, and 4 naive datetimes in cache/tx_log/wallet. That last one is worth doing — a transaction log without a timezone is genuinely ambiguous — but it changes recorded values and needs its own migration thought. Two one-offs got a targeted fix instead of a blanket ignore: the example file is now executable, and `x != x` carries a noqa saying it is the NaN test, which it is. --- blockrun_llm/__init__.py | 362 +++---- blockrun_llm/anthropic_client.py | 15 +- blockrun_llm/billing.py | 3 +- blockrun_llm/cache.py | 98 +- blockrun_llm/client.py | 556 +++++------ blockrun_llm/image.py | 42 +- blockrun_llm/music.py | 31 +- blockrun_llm/phone.py | 37 +- blockrun_llm/portrait.py | 39 +- blockrun_llm/price.py | 51 +- blockrun_llm/realface.py | 47 +- blockrun_llm/router.py | 35 +- blockrun_llm/rpc.py | 35 +- blockrun_llm/search.py | 39 +- blockrun_llm/solana_client.py | 881 +++++++++--------- blockrun_llm/solana_wallet.py | 27 +- blockrun_llm/speech.py | 47 +- blockrun_llm/surf.py | 49 +- blockrun_llm/tx_log.py | 33 +- blockrun_llm/types.py | 293 +++--- blockrun_llm/validation.py | 30 +- blockrun_llm/video.py | 65 +- blockrun_llm/voice.py | 47 +- blockrun_llm/wallet.py | 32 +- blockrun_llm/x402.py | 26 +- examples/arbitrage_analyzer.py | 3 +- examples/benchmark_claude.py | 26 +- examples/sweep_all_chat_models.py | 47 +- examples/sweep_all_media_models.py | 39 +- pyproject.toml | 19 +- tests/helpers.py | 23 +- tests/integration/conftest.py | 1 + tests/integration/test_production_api.py | 3 +- tests/unit/test_client.py | 9 +- tests/unit/test_cost_log.py | 3 +- tests/unit/test_image_edit.py | 11 +- tests/unit/test_image_poll.py | 6 +- tests/unit/test_invalid_message_fail_fast.py | 2 +- tests/unit/test_passthrough_defi_dex_modal.py | 1 + tests/unit/test_payment_error_helper.py | 5 +- tests/unit/test_portrait.py | 1 + tests/unit/test_realface.py | 1 + tests/unit/test_rpc.py | 3 +- tests/unit/test_solana_client.py | 6 +- tests/unit/test_solana_max_tokens.py | 6 +- tests/unit/test_solana_media.py | 59 +- tests/unit/test_solana_settled_payment.py | 6 +- tests/unit/test_solana_timeout_routing.py | 34 +- tests/unit/test_solana_wallet.py | 3 +- tests/unit/test_speech.py | 1 + tests/unit/test_streaming.py | 76 +- tests/unit/test_streaming_solana.py | 26 +- tests/unit/test_tx_log.py | 2 - tests/unit/test_validation.py | 9 +- tests/unit/test_video_params.py | 1 + tests/unit/test_x402.py | 9 +- 56 files changed, 1712 insertions(+), 1649 deletions(-) mode change 100644 => 100755 examples/benchmark_claude.py diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py index 14a4f3f..97e06e0 100644 --- a/blockrun_llm/__init__.py +++ b/blockrun_llm/__init__.py @@ -54,252 +54,256 @@ - Solana (USDC): Use SolanaLLMClient (pip install blockrun-llm[solana]) """ +from __future__ import annotations + +from .anthropic_client import AnthropicClient +from .cache import ( + clear_cache, + export_cost_log_csv, + export_cost_log_json, + get_cost_log_summary, +) from .client import ( - LLMClient, AsyncLLMClient, - list_models, + LLMClient, + async_testnet_client, list_image_models, + list_models, testnet_client, - async_testnet_client, ) -from .anthropic_client import AnthropicClient -from .solana_client import AsyncSolanaLLMClient, SolanaLLMClient from .image import ImageClient from .music import MusicClient -from .speech import SpeechClient -from .video import VideoClient +from .phone import PhoneClient from .portrait import PortraitClient +from .price import PriceClient from .realface import RealFaceClient -from .voice import VoiceClient -from .phone import PhoneClient -from .surf import SurfClient +from .rpc import NETWORK_ALIASES, SUPPORTED_NETWORKS, RpcClient from .search import SearchClient -from .price import PriceClient -from .rpc import RpcClient, SUPPORTED_NETWORKS, NETWORK_ALIASES +from .solana_client import AsyncSolanaLLMClient, SolanaLLMClient +from .solana_wallet import ( + create_solana_wallet, + format_solana_wallet_migration_notice, + generate_solana_qr_ascii, + get_or_create_solana_wallet, + get_solana_public_key, + get_solana_usdc_balance, + import_solana_wallet, + list_discovered_solana_wallets, + load_solana_wallet, + open_solana_wallet_qr, + scan_solana_wallets, + setup_agent_solana_wallet, +) +from .speech import SpeechClient +from .surf import SurfClient +from .tx_log import TransactionLogger, decode_settlement_header, format_row from .types import ( - ChatMessage, - ChatResponse, - ChatCompletionChunk, + APIError, + AudioModel, + AudioTrack, ChatChunkChoice, ChatChunkDelta, - ChatChunkToolCall, ChatChunkFunctionCall, - Model, - APIError, - PaymentError, - SpendLimitError, - ImageResponse, + ChatChunkToolCall, + ChatCompletionChunk, + ChatMessage, + ChatResponse, ImageData, ImageModel, + ImageResponse, + Model, # Music / Audio types MusicResponse, - AudioTrack, - AudioModel, - # Speech (TTS / sound effects) types - SpeechResponse, - SpeechAudio, - # Video types - VideoResponse, - VideoClip, - VideoModel, + NewsSearchSource, + PaymentError, # Virtual Portrait types PortraitEnrollment, - PortraitUsage, - PortraitSettlement, PortraitList, PortraitListItem, + PortraitSettlement, + PortraitUsage, + PriceBar, + PriceHistoryResponse, + # Pyth market data types + PricePoint, + RealFaceEnrollment, # RealFace types RealFaceInit, - RealFaceStatus, - RealFaceEnrollment, RealFaceList, RealFaceListItem, - # Live Search types - SearchParameters, - WebSearchSource, - XSearchSource, - NewsSearchSource, - RssSearchSource, + RealFaceStatus, # Smart routing types RoutingDecision, - SmartChatResponse, + RpcError, + # Multi-chain RPC types + RpcResponse, + RssSearchSource, + # Live Search types + SearchParameters, # Standalone search SearchResult, - # Pyth market data types - PricePoint, - PriceBar, - PriceHistoryResponse, + SmartChatResponse, + SpeechAudio, + # Speech (TTS / sound effects) types + SpeechResponse, + SpendLimitError, SymbolListResponse, - # Multi-chain RPC types - RpcResponse, - RpcError, + VideoClip, + VideoModel, + # Video types + VideoResponse, + WebSearchSource, + XSearchSource, ) +from .video import VideoClient +from .voice import VoiceClient from .wallet import ( - setup_agent_wallet, # Entry point for agents (auto-creates wallet) - status, # One-command verification - get_or_create_wallet, - get_wallet_address, - format_wallet_created_message, - format_needs_funding_message, - format_funding_message_compact, + WALLET_DIR, + WALLET_FILE, format_error_message, + format_funding_message_compact, + format_needs_funding_message, + format_wallet_created_message, + format_wallet_migration_notice, generate_wallet_qr_ascii, - get_payment_links, get_eip681_uri, - save_wallet_qr, - open_wallet_qr, + get_or_create_wallet, + get_payment_links, + get_wallet_address, + import_wallet, + list_discovered_wallets, load_wallet, + open_wallet_qr, + save_wallet_qr, scan_wallets, - list_discovered_wallets, - import_wallet, - format_wallet_migration_notice, - create_wallet as generate_wallet, # User-friendly alias - WALLET_FILE, - WALLET_DIR, -) -from .solana_wallet import ( - setup_agent_solana_wallet, - get_solana_usdc_balance, - generate_solana_qr_ascii, - open_solana_wallet_qr, - get_or_create_solana_wallet, - create_solana_wallet, - load_solana_wallet, - scan_solana_wallets, - list_discovered_solana_wallets, - import_solana_wallet, - format_solana_wallet_migration_notice, - get_solana_public_key, + setup_agent_wallet, # Entry point for agents (auto-creates wallet) + status, # One-command verification ) -from .cache import ( - clear_cache, - export_cost_log_csv, - export_cost_log_json, - get_cost_log_summary, +from .wallet import ( + create_wallet as generate_wallet, # User-friendly alias ) -from .tx_log import TransactionLogger, decode_settlement_header, format_row __version__ = "1.9.0" __all__ = [ - "LLMClient", - "AsyncLLMClient", + "NETWORK_ALIASES", + "SUPPORTED_NETWORKS", + "WALLET_DIR", + "WALLET_FILE", + "APIError", "AnthropicClient", - "SolanaLLMClient", + "AsyncLLMClient", "AsyncSolanaLLMClient", - # Testnet convenience functions - "testnet_client", - "async_testnet_client", - # Entry point for agents (auto-creates wallet) - "setup_agent_wallet", - "status", - # Standalone functions (no wallet required) - "list_models", - "list_image_models", - "ImageClient", - "MusicClient", - "SpeechClient", - "VideoClient", - "PortraitClient", - "RealFaceClient", - "VoiceClient", - "PhoneClient", - "SurfClient", - "SearchClient", - "PriceClient", - "RpcClient", - "SUPPORTED_NETWORKS", - "NETWORK_ALIASES", - "ChatMessage", - "ChatResponse", - "ChatCompletionChunk", + "AudioModel", + "AudioTrack", "ChatChunkChoice", "ChatChunkDelta", - "ChatChunkToolCall", "ChatChunkFunctionCall", - "Model", - "APIError", - "PaymentError", - "SpendLimitError", - "ImageResponse", + "ChatChunkToolCall", + "ChatCompletionChunk", + "ChatMessage", + "ChatResponse", + "ImageClient", "ImageData", "ImageModel", + "ImageResponse", + "LLMClient", + "Model", + "MusicClient", "MusicResponse", - "AudioTrack", - "AudioModel", - "SpeechResponse", - "SpeechAudio", - "VideoResponse", - "VideoClip", - "VideoModel", + "NewsSearchSource", + "PaymentError", + "PhoneClient", + "PortraitClient", "PortraitEnrollment", - "PortraitUsage", - "PortraitSettlement", "PortraitList", "PortraitListItem", - "RealFaceInit", - "RealFaceStatus", + "PortraitSettlement", + "PortraitUsage", + "PriceBar", + "PriceClient", + "PriceHistoryResponse", + # Pyth market data types + "PricePoint", + "RealFaceClient", "RealFaceEnrollment", + "RealFaceInit", "RealFaceList", "RealFaceListItem", - # Live Search types - "SearchParameters", - "WebSearchSource", - "XSearchSource", - "NewsSearchSource", - "RssSearchSource", + "RealFaceStatus", # Smart routing types "RoutingDecision", - "SmartChatResponse", + "RpcClient", + "RpcError", + # Multi-chain RPC types + "RpcResponse", + "RssSearchSource", + "SearchClient", + # Live Search types + "SearchParameters", # Standalone search "SearchResult", - # Pyth market data types - "PricePoint", - "PriceBar", - "PriceHistoryResponse", + "SmartChatResponse", + "SolanaLLMClient", + "SpeechAudio", + "SpeechClient", + "SpeechResponse", + "SpendLimitError", + "SurfClient", "SymbolListResponse", - # Multi-chain RPC types - "RpcResponse", - "RpcError", - # Wallet utilities - "get_or_create_wallet", - "get_wallet_address", - "generate_wallet", - "format_wallet_created_message", - "format_needs_funding_message", - "format_funding_message_compact", + # Per-transaction log (opt-in, project-local ./log/) + "TransactionLogger", + "VideoClient", + "VideoClip", + "VideoModel", + "VideoResponse", + "VoiceClient", + "WebSearchSource", + "XSearchSource", + "async_testnet_client", + # Cache + billing utilities + "clear_cache", + "create_solana_wallet", + "decode_settlement_header", + "export_cost_log_csv", + "export_cost_log_json", "format_error_message", + "format_funding_message_compact", + "format_needs_funding_message", + "format_row", + "format_solana_wallet_migration_notice", + "format_wallet_created_message", + "format_wallet_migration_notice", + "generate_solana_qr_ascii", + "generate_wallet", "generate_wallet_qr_ascii", - "get_payment_links", + "get_cost_log_summary", "get_eip681_uri", - "save_wallet_qr", - "open_wallet_qr", + "get_or_create_solana_wallet", + # Wallet utilities + "get_or_create_wallet", + "get_payment_links", + "get_solana_public_key", + "get_solana_usdc_balance", + "get_wallet_address", + "import_solana_wallet", + "import_wallet", + "list_discovered_solana_wallets", + "list_discovered_wallets", + "list_image_models", + # Standalone functions (no wallet required) + "list_models", + "load_solana_wallet", "load_wallet", + "open_solana_wallet_qr", + "open_wallet_qr", + "save_wallet_qr", + "scan_solana_wallets", "scan_wallets", - "list_discovered_wallets", - "import_wallet", - "format_wallet_migration_notice", - "WALLET_FILE", - "WALLET_DIR", # Solana wallet utilities "setup_agent_solana_wallet", - "get_solana_usdc_balance", - "generate_solana_qr_ascii", - "open_solana_wallet_qr", - "get_or_create_solana_wallet", - "create_solana_wallet", - "load_solana_wallet", - "scan_solana_wallets", - "list_discovered_solana_wallets", - "import_solana_wallet", - "format_solana_wallet_migration_notice", - "get_solana_public_key", - # Cache + billing utilities - "clear_cache", - "get_cost_log_summary", - "export_cost_log_csv", - "export_cost_log_json", - # Per-transaction log (opt-in, project-local ./log/) - "TransactionLogger", - "decode_settlement_header", - "format_row", + # Entry point for agents (auto-creates wallet) + "setup_agent_wallet", + "status", + # Testnet convenience functions + "testnet_client", ] diff --git a/blockrun_llm/anthropic_client.py b/blockrun_llm/anthropic_client.py index b0b418b..180cdc8 100644 --- a/blockrun_llm/anthropic_client.py +++ b/blockrun_llm/anthropic_client.py @@ -16,16 +16,17 @@ print(response.content[0].text) """ +from __future__ import annotations + import os -from typing import Optional import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account +from .validation import validate_api_url, validate_private_key from .wallet import load_wallet -from .validation import validate_private_key, validate_api_url -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required load_dotenv() @@ -39,7 +40,7 @@ class _BlockRunX402Transport(httpx.BaseTransport): """Custom httpx transport that intercepts 402 responses and signs x402 payments.""" def __init__( - self, account: Account, api_url: str, base_transport: Optional[httpx.BaseTransport] = None + self, account: Account, api_url: str, base_transport: httpx.BaseTransport | None = None ): self._account = account self._api_url = api_url @@ -126,8 +127,8 @@ class AnthropicClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = DEFAULT_CHAT_TIMEOUT, **kwargs, ): diff --git a/blockrun_llm/billing.py b/blockrun_llm/billing.py index 6199e3c..d5dfb56 100644 --- a/blockrun_llm/billing.py +++ b/blockrun_llm/billing.py @@ -26,7 +26,6 @@ import argparse import sys -from typing import List, Optional from .cache import ( COST_LOG_PATH, @@ -174,7 +173,7 @@ def build_parser() -> argparse.ArgumentParser: return parser -def main(argv: Optional[List[str]] = None) -> int: +def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: diff --git a/blockrun_llm/cache.py b/blockrun_llm/cache.py index a64ae4f..2f9f079 100644 --- a/blockrun_llm/cache.py +++ b/blockrun_llm/cache.py @@ -20,13 +20,13 @@ import json import re import time +from collections.abc import Iterator from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Union - +from typing import Any # Default TTL in seconds per endpoint pattern -DEFAULT_TTL: Dict[str, int] = { +DEFAULT_TTL: dict[str, int] = { # X/Twitter data — cache 1 hour (followers/tweets don't change every minute) "/v1/partner/": 3600, # Prediction markets — cache 30 minutes @@ -68,7 +68,7 @@ def _get_ttl(endpoint: str) -> int: return 3600 -def _cache_key(endpoint: str, body: Dict[str, Any]) -> str: +def _cache_key(endpoint: str, body: dict[str, Any]) -> str: """Generate a deterministic cache key from endpoint + request body.""" key_data = json.dumps({"endpoint": endpoint, "body": body}, sort_keys=True) return hashlib.sha256(key_data.encode()).hexdigest()[:16] @@ -79,7 +79,7 @@ def _cache_path(key: str) -> Path: return CACHE_DIR / f"{key}.json" -def get_cached(endpoint: str, body: Dict[str, Any]) -> Optional[Dict[str, Any]]: +def get_cached(endpoint: str, body: dict[str, Any]) -> dict[str, Any] | None: """ Check if a cached response exists and is still fresh. @@ -109,7 +109,7 @@ def get_cached(endpoint: str, body: Dict[str, Any]) -> Optional[Dict[str, Any]]: return None -def _readable_filename(endpoint: str, body: Dict[str, Any]) -> str: +def _readable_filename(endpoint: str, body: dict[str, Any]) -> str: """ Generate a human-readable filename from endpoint + request body. """ @@ -141,14 +141,14 @@ def _readable_filename(endpoint: str, body: Dict[str, Any]) -> str: def save_to_cache( endpoint: str, - body: Dict[str, Any], - response: Dict[str, Any], + body: dict[str, Any], + response: dict[str, Any], cost_usd: float = 0.0, *, - model: Optional[str] = None, - wallet: Optional[str] = None, - network: Optional[str] = None, - client_kind: Optional[str] = None, + model: str | None = None, + wallet: str | None = None, + network: str | None = None, + client_kind: str | None = None, ) -> None: """ Save a paid API response locally. @@ -190,8 +190,8 @@ def save_to_cache( def _save_readable( endpoint: str, - body: Dict[str, Any], - response: Dict[str, Any], + body: dict[str, Any], + response: dict[str, Any], cost_usd: float, ) -> None: """Save a human-readable JSON file to ~/.blockrun/data/.""" @@ -214,10 +214,10 @@ def _append_cost_log( endpoint: str, cost_usd: float, *, - model: Optional[str] = None, - wallet: Optional[str] = None, - network: Optional[str] = None, - client_kind: Optional[str] = None, + model: str | None = None, + wallet: str | None = None, + network: str | None = None, + client_kind: str | None = None, ) -> None: """Append one JSONL row to ``~/.blockrun/cost_log.jsonl``. @@ -234,7 +234,7 @@ def _append_cost_log( try: COST_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) with open(COST_LOG_PATH, "a") as f: - entry: Dict[str, Any] = { + entry: dict[str, Any] = { "ts": time.time(), "endpoint": endpoint, "cost_usd": cost_usd, @@ -268,7 +268,7 @@ def clear_cache() -> int: # --------------------------------------------------------------------------- -def _parse_date(value: Optional[str]) -> Optional[float]: +def _parse_date(value: str | None) -> float | None: """Parse a YYYY-MM-DD or ISO 8601 date into a unix timestamp. Bare dates (YYYY-MM-DD) anchor to UTC midnight. Returns ``None`` when @@ -292,11 +292,11 @@ def _parse_date(value: Optional[str]) -> Optional[float]: def _iter_cost_log( *, - from_ts: Optional[float] = None, - to_ts: Optional[float] = None, - wallet: Optional[str] = None, - network: Optional[str] = None, -) -> Iterator[Dict[str, Any]]: + from_ts: float | None = None, + to_ts: float | None = None, + wallet: str | None = None, + network: str | None = None, +) -> Iterator[dict[str, Any]]: """Yield cost-log entries that match the optional filters.""" if not COST_LOG_PATH.exists(): return @@ -325,7 +325,7 @@ def _iter_cost_log( yield entry -def _group_key(entry: Dict[str, Any], group_by: str) -> str: +def _group_key(entry: dict[str, Any], group_by: str) -> str: """Compute the bucket key for a given grouping field.""" if group_by == "day": ts = entry.get("ts") @@ -345,12 +345,12 @@ def _group_key(entry: Dict[str, Any], group_by: str) -> str: def get_cost_log_summary( *, - from_date: Optional[str] = None, - to_date: Optional[str] = None, - wallet: Optional[str] = None, - network: Optional[str] = None, + from_date: str | None = None, + to_date: str | None = None, + wallet: str | None = None, + network: str | None = None, group_by: str = "endpoint", -) -> Dict[str, Any]: +) -> dict[str, Any]: """Read the cost log and return an aggregated summary. Args: @@ -377,7 +377,7 @@ def get_cost_log_summary( total = 0.0 calls = 0 - groups: Dict[str, Dict[str, Any]] = {} + groups: dict[str, dict[str, Any]] = {} for entry in _iter_cost_log(from_ts=from_ts, to_ts=to_ts, wallet=wallet, network=network): cost = float(entry.get("cost_usd") or 0.0) @@ -388,7 +388,7 @@ def get_cost_log_summary( slot["calls"] += 1 slot["cost_usd"] += cost - result: Dict[str, Any] = { + result: dict[str, Any] = { "from_date": from_date, "to_date": to_date, "total_usd": total, @@ -420,7 +420,7 @@ def get_cost_log_summary( ) -def _entry_to_record(entry: Dict[str, Any]) -> Dict[str, Any]: +def _entry_to_record(entry: dict[str, Any]) -> dict[str, Any]: """Normalize one raw cost-log entry into the export record shape.""" ts = entry.get("ts") ts_iso = ( @@ -441,11 +441,11 @@ def _entry_to_record(entry: Dict[str, Any]) -> Dict[str, Any]: def _filtered_records( *, - from_date: Optional[str], - to_date: Optional[str], - wallet: Optional[str], - network: Optional[str], -) -> List[Dict[str, Any]]: + from_date: str | None, + to_date: str | None, + wallet: str | None, + network: str | None, +) -> list[dict[str, Any]]: from_ts = _parse_date(from_date) to_ts = _parse_date(to_date) return [ @@ -455,12 +455,12 @@ def _filtered_records( def export_cost_log_csv( - output_path: Optional[Union[str, Path]] = None, + output_path: str | Path | None = None, *, - from_date: Optional[str] = None, - to_date: Optional[str] = None, - wallet: Optional[str] = None, - network: Optional[str] = None, + from_date: str | None = None, + to_date: str | None = None, + wallet: str | None = None, + network: str | None = None, ) -> str: """Render filtered cost-log entries as CSV. @@ -487,12 +487,12 @@ def export_cost_log_csv( def export_cost_log_json( - output_path: Optional[Union[str, Path]] = None, + output_path: str | Path | None = None, *, - from_date: Optional[str] = None, - to_date: Optional[str] = None, - wallet: Optional[str] = None, - network: Optional[str] = None, + from_date: str | None = None, + to_date: str | None = None, + wallet: str | None = None, + network: str | None = None, ) -> str: """Render filtered cost-log entries as a JSON array of records. diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index c3ef64f..1379b29 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -37,39 +37,42 @@ print(result.choices[0].message.content) """ +from __future__ import annotations + +import json as _json import os import re import sys -import json as _json -from typing import AsyncIterator, Iterator, List, Dict, Any, Optional, Tuple, Union +from collections.abc import AsyncIterator, Iterator +from typing import Any + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account +from .router import route as route_request +from .tx_log import ( + TransactionLogger, + _resolve_log_dir, + decode_settlement_header, + paid_request_error_prefix, + read_settlement_header, +) from .types import ( - ChatResponse, + APIError, ChatCompletionChunk, + ChatResponse, ImageResponse, - APIError, PaymentError, RoutingDecision, - SmartChatResponse, RoutingProfile, SearchResult, - stream_choice_content, - stream_choice_finish_reason, + SmartChatResponse, chunk_meta, chunk_usage_dict, + stream_choice_content, + stream_choice_finish_reason, ) -from .router import route as route_request -from .tx_log import ( - TransactionLogger, - decode_settlement_header, - paid_request_error_prefix, - read_settlement_header, - _resolve_log_dir, -) -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details from .validation import ( check_spend_limits, resolve_spend_limit, @@ -83,6 +86,7 @@ validate_temperature, validate_top_p, ) +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required # Load environment variables load_dotenv() @@ -106,7 +110,7 @@ def _get_user_agent() -> str: # ============================================================================= -def list_models(api_url: str = "https://blockrun.ai/api") -> List[Dict[str, Any]]: +def list_models(api_url: str = "https://blockrun.ai/api") -> list[dict[str, Any]]: """ List available LLM models with pricing (no wallet required). @@ -138,7 +142,7 @@ def list_models(api_url: str = "https://blockrun.ai/api") -> List[Dict[str, Any] return data.get("models", []) -def list_image_models(api_url: str = "https://blockrun.ai/api") -> List[Dict[str, Any]]: +def list_image_models(api_url: str = "https://blockrun.ai/api") -> list[dict[str, Any]]: """ List available image generation models without requiring a wallet. @@ -208,9 +212,7 @@ def _should_fallback(exc: Exception) -> bool: return True if isinstance(exc, httpx.NetworkError): return True - if isinstance(exc, APIError) and exc.status_code in (502, 503, 504, 522, 524): - return True - return False + return bool(isinstance(exc, APIError) and exc.status_code in (502, 503, 504, 522, 524)) # The gateway states the output-token ceiling it actually quoted in the 402's @@ -233,7 +235,7 @@ def _should_fallback(exc: Exception) -> bool: _DESCRIPTION_SCAN_LIMIT = 512 -def _warn_if_clamped(body: Dict[str, Any], resource_description: Optional[str]) -> None: +def _warn_if_clamped(body: dict[str, Any], resource_description: str | None) -> None: """Warn when the gateway quoted fewer output tokens than the caller asked for. An over-ceiling ``max_tokens`` is not rejected. The gateway silently clamps @@ -279,7 +281,7 @@ def _warn_if_clamped(body: Dict[str, Any], resource_description: Optional[str]) return -def _enforce_spend_limits(client: Any, cost_usd: float, model: Optional[str] = None) -> None: +def _enforce_spend_limits(client: Any, cost_usd: float, model: str | None = None) -> None: """Refuse a quote that breaches a limit the caller configured, before the paid request is sent. @@ -352,13 +354,13 @@ class LLMClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = DEFAULT_CHAT_TIMEOUT, search_timeout: float = 300.0, - transaction_log: Union[bool, str, "os.PathLike[str]", None] = None, - max_cost_per_call: Optional[float] = None, - max_session_cost: Optional[float] = None, + transaction_log: bool | str | os.PathLike[str] | None = None, + max_cost_per_call: float | None = None, + max_session_cost: float | None = None, ): """ Initialize the BlockRun LLM client. @@ -442,19 +444,19 @@ def __init__( self._last_call_cost: float = 0.0 # Model pricing cache for smart routing - self._model_pricing_cache: Optional[Dict[str, Dict[str, float]]] = None + self._model_pricing_cache: dict[str, dict[str, float]] | None = None # Opt-in transaction log + last on-chain settlement payload. The # settlement is populated from PAYMENT-RESPONSE on every paid retry # and cleared right before save_to_cache fires so it can't bleed # across calls when logging is disabled. log_dir = _resolve_log_dir(transaction_log) - self._tx_logger: Optional[TransactionLogger] = ( + self._tx_logger: TransactionLogger | None = ( TransactionLogger(log_dir) if log_dir is not None else None ) - self._last_settlement: Optional[Dict[str, Any]] = None + self._last_settlement: dict[str, Any] | None = None - def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, Any]]: + def _capture_settlement(self, response: httpx.Response) -> dict[str, Any] | None: """Decode the x402 settlement header on a successful paid response. Returns the decoded settlement dict (also stashed on @@ -467,7 +469,7 @@ def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, An self._last_settlement = settlement return settlement - def _get_model_pricing(self) -> Dict[str, Dict[str, float]]: + def _get_model_pricing(self) -> dict[str, dict[str, float]]: """ Get model pricing for smart routing. @@ -484,7 +486,7 @@ def _get_model_pricing(self) -> Dict[str, Dict[str, float]]: return self._model_pricing_cache models = self.list_models() - pricing: Dict[str, Dict[str, float]] = {} + pricing: dict[str, dict[str, float]] = {} for model in models: model_id = model.get("id", "") block = model.get("pricing") or {} @@ -505,9 +507,9 @@ def smart_chat( self, prompt: str, *, - system: Optional[str] = None, - max_tokens: Optional[int] = None, - temperature: Optional[float] = None, + system: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, routing_profile: RoutingProfile = "auto", ) -> SmartChatResponse: """ @@ -573,7 +575,7 @@ def smart_chat( routing=RoutingDecision(**decision), ) - def get_spending(self) -> Dict[str, Any]: + def get_spending(self) -> dict[str, Any]: """ Get current session spending. @@ -594,14 +596,14 @@ def chat( model: str, prompt: str, *, - system: Optional[str] = None, - max_tokens: Optional[int] = None, - temperature: Optional[float] = None, - search: Optional[bool] = None, - search_parameters: Optional[Dict[str, Any]] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, - fallback_models: Optional[List[str]] = None, + system: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, + search: bool | None = None, + search_parameters: dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, **extra: Any, ) -> str: """ @@ -634,7 +636,7 @@ def chat( search=True # Enable live search ) """ - messages: List[Dict[str, str]] = [] + messages: list[dict[str, str]] = [] if system: messages.append({"role": "system", "content": system}) @@ -659,18 +661,18 @@ def chat( def chat_completion( self, model: str, - messages: List[Dict[str, Any]], + messages: list[dict[str, Any]], *, - max_tokens: Optional[int] = None, - temperature: Optional[float] = None, - top_p: Optional[float] = None, - search: Optional[bool] = None, - search_parameters: Optional[Dict[str, Any]] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[Any] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, - fallback_models: Optional[List[str]] = None, + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + search: bool | None = None, + search_parameters: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, **extra: Any, ) -> ChatResponse: """ @@ -748,7 +750,7 @@ def chat_completion( validate_top_p(top_p) # Build request body - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "messages": messages, "max_tokens": max_tokens or self.DEFAULT_MAX_TOKENS, @@ -788,7 +790,7 @@ def chat_completion( # network errors). Default behavior — single attempt — is preserved # when fallback_models is None or empty. attempts = [model, *(fallback_models or [])] - last_exc: Optional[Exception] = None + last_exc: Exception | None = None for i, attempt_model in enumerate(attempts): body["model"] = attempt_model try: @@ -814,18 +816,18 @@ def chat_completion( def chat_completion_stream( self, model: str, - messages: List[Dict[str, Any]], + messages: list[dict[str, Any]], *, - max_tokens: Optional[int] = None, - temperature: Optional[float] = None, - top_p: Optional[float] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[Any] = None, - search: Optional[bool] = None, - search_parameters: Optional[Dict[str, Any]] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, - fallback_models: Optional[List[str]] = None, + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + search: bool | None = None, + search_parameters: dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, **extra: Any, ) -> Iterator[ChatCompletionChunk]: """ @@ -871,7 +873,7 @@ def chat_completion_stream( validate_temperature(temperature) validate_top_p(top_p) - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "messages": messages, "stream": True, @@ -900,7 +902,7 @@ def chat_completion_stream( body.setdefault(k, v) attempts = [model, *(fallback_models or [])] - last_exc: Optional[Exception] = None + last_exc: Exception | None = None for i, attempt_model in enumerate(attempts): body["model"] = attempt_model @@ -938,7 +940,7 @@ def chat_completion_stream( def _stream_with_payment( self, endpoint: str, - body: Dict[str, Any], + body: dict[str, Any], ) -> Iterator[ChatCompletionChunk]: """ Run the 402 → sign → retry dance, then yield SSE chunks. @@ -955,7 +957,7 @@ def _stream_with_payment( timeout = self.search_timeout if is_search else self.timeout # ----- Phase 1: probe (no payment header) ----- - payment_headers: Optional[Dict[str, str]] = None + payment_headers: dict[str, str] | None = None cost_usd = 0.0 backoffs = self._STREAM_5XX_BACKOFFS @@ -997,10 +999,10 @@ def _stream_with_payment( def _stream_paid_phase( self, url: str, - body: Dict[str, Any], - payment_headers: Dict[str, str], + body: dict[str, Any], + payment_headers: dict[str, str], cost_usd: float, - timeout: Optional[float], + timeout: float | None, ) -> Iterator[ChatCompletionChunk]: """Phase 2 of :meth:`_stream_with_payment`: the paid, already-settled leg.""" backoffs = self._STREAM_5XX_BACKOFFS @@ -1029,7 +1031,7 @@ def _stream_paid_phase( def _iter_and_archive( self, response: httpx.Response, - body: Dict[str, Any], + body: dict[str, Any], cost_usd: float, *, streaming: bool = True, @@ -1039,12 +1041,12 @@ def _iter_and_archive( ``chat.completion`` response so paid streaming calls show up in ``~/.blockrun/cost_log.jsonl`` and ``~/.blockrun/data/`` the same way non-stream paid calls do.""" - assembled_id: Optional[str] = None - assembled_model: Optional[str] = None + assembled_id: str | None = None + assembled_model: str | None = None assembled_created: int = 0 - content_parts: List[str] = [] - finish_reason: Optional[str] = None - usage_dict: Optional[Dict[str, Any]] = None + content_parts: list[str] = [] + finish_reason: str | None = None + usage_dict: dict[str, Any] | None = None for chunk in self._iter_sse_chunks(response): if chunk.choices: @@ -1078,7 +1080,7 @@ def _iter_and_archive( if cost_usd > 0: from .cache import save_to_cache - response_data: Dict[str, Any] = { + response_data: dict[str, Any] = { "id": assembled_id or "stream", "object": "chat.completion", "created": assembled_created or int(__import__("time").time()), @@ -1138,9 +1140,9 @@ def _iter_sse_chunks(response: httpx.Response) -> Iterator[ChatCompletionChunk]: def _sign_payment_from_response( self, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, - ) -> Tuple[Dict[str, str], float]: + ) -> tuple[dict[str, str], float]: """ Extract a 402's payment requirements, sign locally, and return ``(headers_with_PAYMENT_SIGNATURE, cost_usd)``. @@ -1150,7 +1152,7 @@ def _sign_payment_from_response( which lets the streaming path open an SSE connection for the retry. """ payment_header = response.headers.get("payment-required") - price_info: Dict[str, Any] = {} + price_info: dict[str, Any] = {} if not payment_header: try: resp_body = response.json() @@ -1219,7 +1221,7 @@ def _raise_stream_error(response: httpx.Response, *, after_payment: bool) -> Non sanitize_error_response(error_body), ) - def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> ChatResponse: + def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> ChatResponse: """ Make a request with automatic x402 payment handling. @@ -1273,7 +1275,7 @@ def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> ChatResp def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, ) -> ChatResponse: """ @@ -1409,7 +1411,7 @@ def _handle_payment_and_retry( return chat_response - def _request_with_payment_raw(self, endpoint: str, body: Dict[str, Any]) -> Dict[str, Any]: + def _request_with_payment_raw(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]: """ Make a request with automatic x402 payment handling, returning raw JSON. @@ -1469,9 +1471,9 @@ def _request_with_payment_raw(self, endpoint: str, body: Dict[str, Any]) -> Dict def _handle_payment_and_retry_raw( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Handle 402 response for raw endpoints: parse requirements, sign payment, retry.""" payment_header = response.headers.get("payment-required") price_info = {} @@ -1558,8 +1560,8 @@ def _handle_payment_and_retry_raw( return retry_response.json() def _get_with_payment_raw( - self, endpoint: str, params: Optional[Dict[str, Any]] = None - ) -> Dict[str, Any]: + self, endpoint: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: """ GET with automatic x402 payment handling, returning raw JSON. @@ -1612,9 +1614,9 @@ def _get_with_payment_raw( def _handle_get_payment_and_retry( self, url: str, - params: Optional[Dict[str, Any]], + params: dict[str, Any] | None, response: httpx.Response, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Handle 402 response for GET endpoints: parse requirements, sign payment, retry with GET.""" payment_header = response.headers.get("payment-required") price_info = {} @@ -1700,10 +1702,10 @@ def _handle_get_payment_and_retry( def image_edit( self, prompt: str, - image: Union[str, List[str]], + image: str | list[str], *, model: str = "openai/gpt-image-2", - mask: Optional[str] = None, + mask: str | None = None, size: str = "1024x1024", n: int = 1, ) -> ImageResponse: @@ -1727,7 +1729,7 @@ def image_edit( Returns: ImageResponse with edited image URLs """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "prompt": prompt, "image": image, @@ -1744,10 +1746,10 @@ def search( self, query: str, *, - sources: Optional[List[str]] = None, + sources: list[str] | None = None, max_results: int = 10, - from_date: Optional[str] = None, - to_date: Optional[str] = None, + from_date: str | None = None, + to_date: str | None = None, ) -> SearchResult: """ Standalone search (web, X/Twitter, news). @@ -1762,7 +1764,7 @@ def search( Returns: SearchResult with summary and citations """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "query": query, "max_results": max_results, } @@ -1778,7 +1780,7 @@ def search( # ── Exa Web Search (Powered by Exa) ───────────────────────────────────── - def exa(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: + def exa(self, path: str, body: dict[str, Any]) -> dict[str, Any]: """Generic Exa endpoint proxy via x402 USDC on Base. Args: @@ -1791,7 +1793,7 @@ def exa(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: """ return self._request_with_payment_raw(f"/v1/exa/{path}", body) - def exa_search(self, query: str, **kwargs: Any) -> Dict[str, Any]: + def exa_search(self, query: str, **kwargs: Any) -> dict[str, Any]: """Neural and keyword web search via Exa ($0.01/request, Base USDC). Args: @@ -1804,7 +1806,7 @@ def exa_search(self, query: str, **kwargs: Any) -> Dict[str, Any]: """ return self._request_with_payment_raw("/v1/exa/search", {"query": query, **kwargs}) - def exa_find_similar(self, url: str, **kwargs: Any) -> Dict[str, Any]: + def exa_find_similar(self, url: str, **kwargs: Any) -> dict[str, Any]: """Find pages semantically similar to a given URL via Exa ($0.01/request, Base USDC). @@ -1818,7 +1820,7 @@ def exa_find_similar(self, url: str, **kwargs: Any) -> Dict[str, Any]: """ return self._request_with_payment_raw("/v1/exa/find-similar", {"url": url, **kwargs}) - def exa_contents(self, urls: List[str], **kwargs: Any) -> Dict[str, Any]: + def exa_contents(self, urls: list[str], **kwargs: Any) -> dict[str, Any]: """Extract full text content from URLs via Exa ($0.002/URL, Base USDC). Args: @@ -1831,7 +1833,7 @@ def exa_contents(self, urls: List[str], **kwargs: Any) -> Dict[str, Any]: """ return self._request_with_payment_raw("/v1/exa/contents", {"urls": urls, **kwargs}) - def exa_answer(self, query: str, **kwargs: Any) -> Dict[str, Any]: + def exa_answer(self, query: str, **kwargs: Any) -> dict[str, Any]: """AI-generated answer grounded in live web search via Exa ($0.01/request, Base USDC). @@ -1847,7 +1849,7 @@ def exa_answer(self, query: str, **kwargs: Any) -> Dict[str, Any]: # ── Prediction Markets (Powered by Predexon) ──────────────────────────── - def pm(self, path: str, **params: Any) -> Dict[str, Any]: + def pm(self, path: str, **params: Any) -> dict[str, Any]: """ Query Predexon prediction market data (GET endpoints). @@ -1875,7 +1877,7 @@ def pm(self, path: str, **params: Any) -> Dict[str, Any]: """ return self._get_with_payment_raw(f"/v1/pm/{path}", params or None) - def pm_query(self, path: str, query: Dict[str, Any]) -> Dict[str, Any]: + def pm_query(self, path: str, query: dict[str, Any]) -> dict[str, Any]: """ Structured query for Predexon prediction market data (POST endpoints). @@ -1901,7 +1903,7 @@ def pm_query(self, path: str, query: Dict[str, Any]) -> Dict[str, Any]: # Thin wrappers over pm() / pm_query() for the most common v2 endpoints. # All accept arbitrary keyword filters that are forwarded as query params. - def pm_markets(self, **params: Any) -> Dict[str, Any]: + def pm_markets(self, **params: Any) -> dict[str, Any]: """List canonical cross-venue markets (Predexon v2). Filter with venue=, status=, category=, league=, event_id=, @@ -1909,92 +1911,92 @@ def pm_markets(self, **params: Any) -> Dict[str, Any]: """ return self.pm("markets", **params) - def pm_listings(self, **params: Any) -> Dict[str, Any]: + def pm_listings(self, **params: Any) -> dict[str, Any]: """List venue-native executable listings flattened across canonical markets (Predexon v2). Tier 1 ($0.001/call).""" return self.pm("markets/listings", **params) - def pm_outcome(self, predexon_id: str) -> Dict[str, Any]: + def pm_outcome(self, predexon_id: str) -> dict[str, Any]: """Resolve a canonical Predexon outcome ID to its market context and venue listings (Predexon v2). Tier 1 ($0.001/call).""" return self.pm(f"outcomes/{predexon_id}") - def pm_polymarket_markets(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_markets(self, **params: Any) -> dict[str, Any]: """List Polymarket markets (Predexon v2). Tier 1 ($0.001/call). For high-volume traversal use ``pm_polymarket_markets_keyset()``. """ return self.pm("polymarket/markets", **params) - def pm_polymarket_events(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_events(self, **params: Any) -> dict[str, Any]: """List Polymarket events (Predexon v2). Tier 1 ($0.001/call). For high-volume traversal use ``pm_polymarket_events_keyset()``. """ return self.pm("polymarket/events", **params) - def pm_polymarket_markets_keyset(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_markets_keyset(self, **params: Any) -> dict[str, Any]: """Polymarket markets with cursor-based keyset pagination (use pagination_key=). Tier 1 ($0.001/call).""" return self.pm("polymarket/markets/keyset", **params) - def pm_polymarket_events_keyset(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_events_keyset(self, **params: Any) -> dict[str, Any]: """Polymarket events with cursor-based keyset pagination (use pagination_key=). Tier 1 ($0.001/call).""" return self.pm("polymarket/events/keyset", **params) - def pm_polymarket_positions(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_positions(self, **params: Any) -> dict[str, Any]: """Polymarket open positions (per-wallet, market-level PnL). Tier 1 ($0.001/call).""" return self.pm("polymarket/positions", **params) - def pm_polymarket_trades(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_trades(self, **params: Any) -> dict[str, Any]: """Recent Polymarket trades (token, side, shares, price, tx_hash). Tier 1 ($0.001/call).""" return self.pm("polymarket/trades", **params) - def pm_polymarket_leaderboard(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_leaderboard(self, **params: Any) -> dict[str, Any]: """Polymarket trader leaderboard (rank by window, sort_by). Tier 1 ($0.001/call).""" return self.pm("polymarket/leaderboard", **params) - def pm_kalshi_markets(self, **params: Any) -> Dict[str, Any]: + def pm_kalshi_markets(self, **params: Any) -> dict[str, Any]: """List Kalshi markets (CFTC-regulated event contracts). Tier 1 ($0.001/call).""" return self.pm("kalshi/markets", **params) - def pm_limitless_markets(self, **params: Any) -> Dict[str, Any]: + def pm_limitless_markets(self, **params: Any) -> dict[str, Any]: """List Limitless markets (binary AMM-style outcomes). Tier 1 ($0.001/call).""" return self.pm("limitless/markets", **params) - def pm_sports_categories(self) -> Dict[str, Any]: + def pm_sports_categories(self) -> dict[str, Any]: """List available sports categories. Tier 1 ($0.001/call).""" return self.pm("sports/categories") - def pm_sports_markets(self, **params: Any) -> Dict[str, Any]: + def pm_sports_markets(self, **params: Any) -> dict[str, Any]: """List sports markets grouped by game. Filter with league=, sport_type=, status=, venue=. Tier 1 ($0.001/call).""" return self.pm("sports/markets", **params) - def pm_wallet_identity(self, wallet: str) -> Dict[str, Any]: + def pm_wallet_identity(self, wallet: str) -> dict[str, Any]: """Fetch identity + profile metadata for one wallet (ENS, Twitter, portfolio, etc.). Tier 2 ($0.005/call).""" return self.pm(f"polymarket/wallet/identity/{wallet}") - def pm_wallet_identities(self, addresses: List[str]) -> Dict[str, Any]: + def pm_wallet_identities(self, addresses: list[str]) -> dict[str, Any]: """Bulk identity lookup for up to 200 wallet addresses (POST). Tier 2 ($0.005/call).""" return self.pm_query("polymarket/wallet/identities", {"addresses": addresses}) - def pm_wallet_cluster(self, address: str) -> Dict[str, Any]: + def pm_wallet_cluster(self, address: str) -> dict[str, Any]: """Discover wallets connected to a seed address via on-chain transfers and identity proofs. Tier 2 ($0.005/call).""" return self.pm(f"polymarket/wallet/{address}/cluster") # ── DefiLlama (DeFi protocols / TVL / yields / prices) ────────────────── - def defi(self, path: str, **params: Any) -> Dict[str, Any]: + def defi(self, path: str, **params: Any) -> dict[str, Any]: """ Query DefiLlama DeFi data (GET passthrough). Powered by DefiLlama. @@ -2014,23 +2016,23 @@ def defi(self, path: str, **params: Any) -> Dict[str, Any]: """ return self._get_with_payment_raw(f"/v1/defillama/{path}", params or None) - def defi_protocols(self) -> Dict[str, Any]: + def defi_protocols(self) -> dict[str, Any]: """All DeFi protocols with TVL ($0.005/call).""" return self.defi("protocols") - def defi_protocol(self, slug: str) -> Dict[str, Any]: + def defi_protocol(self, slug: str) -> dict[str, Any]: """Single protocol details + historical TVL ($0.005/call).""" return self.defi(f"protocol/{slug}") - def defi_chains(self) -> Dict[str, Any]: + def defi_chains(self) -> dict[str, Any]: """Current TVL of every chain ($0.005/call).""" return self.defi("chains") - def defi_yields(self, **params: Any) -> Dict[str, Any]: + def defi_yields(self, **params: Any) -> dict[str, Any]: """Yield pools with APY/TVL ($0.005/call).""" return self.defi("yields", **params) - def defi_prices(self, coins: Union[List[str], str]) -> Dict[str, Any]: + def defi_prices(self, coins: list[str] | str) -> dict[str, Any]: """Token price lookup ($0.001/call). Args: @@ -2047,9 +2049,9 @@ def dex( path: str, *, method: str = "GET", - body: Optional[Dict[str, Any]] = None, + body: dict[str, Any] | None = None, **params: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Query the 0x Swap / Gasless APIs (free — no x402 payment; BlockRun takes an on-chain affiliate fee on executed swaps instead). @@ -2074,41 +2076,41 @@ def dex( return self._request_with_payment_raw(endpoint, body or {}) return self._get_with_payment_raw(endpoint, params or None) - def dex_price(self, **params: Any) -> Dict[str, Any]: + def dex_price(self, **params: Any) -> dict[str, Any]: """Indicative Permit2 swap price — no commitment (free).""" return self.dex("price", **params) - def dex_quote(self, **params: Any) -> Dict[str, Any]: + def dex_quote(self, **params: Any) -> dict[str, Any]: """Firm Permit2 swap quote with permit2.eip712 + tx data (free).""" return self.dex("quote", **params) - def dex_gasless_price(self, **params: Any) -> Dict[str, Any]: + def dex_gasless_price(self, **params: Any) -> dict[str, Any]: """Gasless indicative price quote (free).""" return self.dex("gasless/price", **params) - def dex_gasless_quote(self, **params: Any) -> Dict[str, Any]: + def dex_gasless_quote(self, **params: Any) -> dict[str, Any]: """Gasless firm quote — returns trade.eip712 to sign (free).""" return self.dex("gasless/quote", **params) - def dex_gasless_submit(self, body: Dict[str, Any]) -> Dict[str, Any]: + def dex_gasless_submit(self, body: dict[str, Any]) -> dict[str, Any]: """Submit a signed gasless trade; the 0x relayer pays gas (free).""" return self.dex("gasless/submit", method="POST", body=body) - def dex_gasless_status(self, trade_hash: str) -> Dict[str, Any]: + def dex_gasless_status(self, trade_hash: str) -> dict[str, Any]: """Poll a gasless trade's status by tradeHash (free).""" return self.dex(f"gasless/status/{trade_hash}") - def dex_chains(self) -> Dict[str, Any]: + def dex_chains(self) -> dict[str, Any]: """Chains where the Swap API is supported (free).""" return self.dex("swap/chains") - def dex_gasless_chains(self) -> Dict[str, Any]: + def dex_gasless_chains(self) -> dict[str, Any]: """Chains where the Gasless API is supported (free).""" return self.dex("gasless/chains") # ── Modal Sandbox (pay-per-call cloud compute) ─────────────────────────── - def modal(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def modal(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: """ Call the Modal sandbox compute API (POST passthrough). @@ -2119,7 +2121,7 @@ def modal(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, A """ return self._request_with_payment_raw(f"/v1/modal/{path}", body or {}) - def modal_sandbox_create(self, **body: Any) -> Dict[str, Any]: + def modal_sandbox_create(self, **body: Any) -> dict[str, Any]: """Create a sandboxed compute environment ($0.01 CPU / $0.05 GPU). Common fields: image ("python:3.11"), gpu (optional GPU type), @@ -2128,22 +2130,22 @@ def modal_sandbox_create(self, **body: Any) -> Dict[str, Any]: return self.modal("sandbox/create", body) def modal_sandbox_exec( - self, sandbox_id: str, command: List[str], **body: Any - ) -> Dict[str, Any]: + self, sandbox_id: str, command: list[str], **body: Any + ) -> dict[str, Any]: """Execute a command in a sandbox; returns stdout/stderr ($0.001).""" return self.modal("sandbox/exec", {"sandbox_id": sandbox_id, "command": command, **body}) - def modal_sandbox_status(self, sandbox_id: str) -> Dict[str, Any]: + def modal_sandbox_status(self, sandbox_id: str) -> dict[str, Any]: """Check a sandbox's status ($0.001).""" return self.modal("sandbox/status", {"sandbox_id": sandbox_id}) - def modal_sandbox_terminate(self, sandbox_id: str) -> Dict[str, Any]: + def modal_sandbox_terminate(self, sandbox_id: str) -> dict[str, Any]: """Terminate a sandbox ($0.001).""" return self.modal("sandbox/terminate", {"sandbox_id": sandbox_id}) # ── Coinbase Onramp ────────────────────────────────────────────────────── - def onramp(self, address: str) -> Dict[str, Any]: + def onramp(self, address: str) -> dict[str, Any]: """Mint a one-time Coinbase Onramp link to fund a wallet with fiat (FREE). Opens the door to buying Base USDC with a card or bank (60+ fiat @@ -2176,7 +2178,7 @@ def onramp(self, address: str) -> Dict[str, Any]: raise APIError("gateway returned no onramp url", 0, None) return data - def list_models(self) -> List[Dict[str, Any]]: + def list_models(self) -> list[dict[str, Any]]: """ List available LLM models with pricing. @@ -2198,7 +2200,7 @@ def list_models(self) -> List[Dict[str, Any]]: return response.json().get("data", []) - def list_image_models(self) -> List[Dict[str, Any]]: + def list_image_models(self) -> list[dict[str, Any]]: """ List available image generation models with pricing. @@ -2213,7 +2215,7 @@ def list_image_models(self) -> List[Dict[str, Any]]: """ return [m for m in self.list_models() if "image" in (m.get("categories") or [])] - def list_all_models(self) -> List[Dict[str, Any]]: + def list_all_models(self) -> list[dict[str, Any]]: """ List all available models (chat, image, music, etc.) with pricing. @@ -2244,7 +2246,7 @@ def is_testnet(self) -> bool: """Check if client is configured for testnet.""" return "testnet.blockrun.ai" in self.api_url - def _billing_meta(self) -> Dict[str, Optional[str]]: + def _billing_meta(self) -> dict[str, str | None]: """Return billing metadata (wallet / network / client_kind) for the cost log. Used by ``save_to_cache`` call sites.""" return { @@ -2256,7 +2258,7 @@ def _billing_meta(self) -> Dict[str, Optional[str]]: def _log_transaction( self, endpoint: str, - body: Dict[str, Any], + body: dict[str, Any], response: Any, cost_usd: float, ) -> None: @@ -2378,13 +2380,13 @@ class AsyncLLMClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = DEFAULT_CHAT_TIMEOUT, search_timeout: float = 300.0, - transaction_log: Union[bool, str, "os.PathLike[str]", None] = None, - max_cost_per_call: Optional[float] = None, - max_session_cost: Optional[float] = None, + transaction_log: bool | str | os.PathLike[str] | None = None, + max_cost_per_call: float | None = None, + max_session_cost: float | None = None, ): """ Initialize the async BlockRun LLM client. @@ -2458,12 +2460,12 @@ def __init__( self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST") log_dir = _resolve_log_dir(transaction_log) - self._tx_logger: Optional[TransactionLogger] = ( + self._tx_logger: TransactionLogger | None = ( TransactionLogger(log_dir) if log_dir is not None else None ) - self._last_settlement: Optional[Dict[str, Any]] = None + self._last_settlement: dict[str, Any] | None = None - def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, Any]]: + def _capture_settlement(self, response: httpx.Response) -> dict[str, Any] | None: """Async-client twin of :meth:`LLMClient._capture_settlement`.""" header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) @@ -2475,18 +2477,18 @@ async def chat( model: str, prompt: str, *, - system: Optional[str] = None, - max_tokens: Optional[int] = None, - temperature: Optional[float] = None, - search: Optional[bool] = None, - search_parameters: Optional[Dict[str, Any]] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, - fallback_models: Optional[List[str]] = None, + system: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, + search: bool | None = None, + search_parameters: dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, **extra: Any, ) -> str: """Async 1-line chat interface with optional xAI Live Search.""" - messages: List[Dict[str, str]] = [] + messages: list[dict[str, str]] = [] if system: messages.append({"role": "system", "content": system}) @@ -2511,18 +2513,18 @@ async def chat( async def chat_completion( self, model: str, - messages: List[Dict[str, Any]], + messages: list[dict[str, Any]], *, - max_tokens: Optional[int] = None, - temperature: Optional[float] = None, - top_p: Optional[float] = None, - search: Optional[bool] = None, - search_parameters: Optional[Dict[str, Any]] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[Any] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, - fallback_models: Optional[List[str]] = None, + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + search: bool | None = None, + search_parameters: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, **extra: Any, ) -> ChatResponse: """Async full chat completion interface with optional xAI Live Search and tool calling.""" @@ -2532,7 +2534,7 @@ async def chat_completion( validate_temperature(temperature) validate_top_p(top_p) - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "messages": messages, "max_tokens": max_tokens or self.DEFAULT_MAX_TOKENS, @@ -2570,7 +2572,7 @@ async def chat_completion( # Walk [model, *fallback_models] on retriable errors. See sync # chat_completion() above for the rationale. attempts = [model, *(fallback_models or [])] - last_exc: Optional[Exception] = None + last_exc: Exception | None = None for i, attempt_model in enumerate(attempts): body["model"] = attempt_model try: @@ -2595,18 +2597,18 @@ async def chat_completion( async def chat_completion_stream( self, model: str, - messages: List[Dict[str, Any]], + messages: list[dict[str, Any]], *, - max_tokens: Optional[int] = None, - temperature: Optional[float] = None, - top_p: Optional[float] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[Any] = None, - search: Optional[bool] = None, - search_parameters: Optional[Dict[str, Any]] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, - fallback_models: Optional[List[str]] = None, + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + search: bool | None = None, + search_parameters: dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, **extra: Any, ) -> AsyncIterator[ChatCompletionChunk]: """ @@ -2619,7 +2621,7 @@ async def chat_completion_stream( validate_temperature(temperature) validate_top_p(top_p) - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "messages": messages, "stream": True, @@ -2648,7 +2650,7 @@ async def chat_completion_stream( body.setdefault(k, v) attempts = [model, *(fallback_models or [])] - last_exc: Optional[Exception] = None + last_exc: Exception | None = None for i, attempt_model in enumerate(attempts): body["model"] = attempt_model @@ -2684,7 +2686,7 @@ async def chat_completion_stream( async def _stream_with_payment( self, endpoint: str, - body: Dict[str, Any], + body: dict[str, Any], ) -> AsyncIterator[ChatCompletionChunk]: """Async version of LLMClient._stream_with_payment. @@ -2702,7 +2704,7 @@ async def _stream_with_payment( statuses_5xx = LLMClient._STREAM_5XX_STATUSES # ----- Phase 1: probe (no payment header) ----- - payment_headers: Optional[Dict[str, str]] = None + payment_headers: dict[str, str] | None = None cost_usd = 0.0 for attempt in range(len(backoffs) + 1): @@ -2747,10 +2749,10 @@ async def _stream_with_payment( async def _astream_paid_phase( self, url: str, - body: Dict[str, Any], - payment_headers: Dict[str, str], + body: dict[str, Any], + payment_headers: dict[str, str], cost_usd: float, - timeout: Optional[float], + timeout: float | None, ) -> AsyncIterator[ChatCompletionChunk]: """Phase 2 of the async stream: the paid, already-settled leg.""" backoffs = LLMClient._STREAM_5XX_BACKOFFS @@ -2784,7 +2786,7 @@ async def _astream_paid_phase( async def _aiter_and_archive( self, response: httpx.Response, - body: Dict[str, Any], + body: dict[str, Any], cost_usd: float, *, streaming: bool = True, @@ -2793,12 +2795,12 @@ async def _aiter_and_archive( assembled ``chat.completion`` response to ``~/.blockrun/data/`` and the cost row to ``~/.blockrun/cost_log.jsonl`` once the stream finishes — only for paid calls (cost_usd > 0).""" - assembled_id: Optional[str] = None - assembled_model: Optional[str] = None + assembled_id: str | None = None + assembled_model: str | None = None assembled_created: int = 0 - content_parts: List[str] = [] - finish_reason: Optional[str] = None - usage_dict: Optional[Dict[str, Any]] = None + content_parts: list[str] = [] + finish_reason: str | None = None + usage_dict: dict[str, Any] | None = None async for chunk in self._aiter_sse_chunks(response): if chunk.choices: @@ -2825,7 +2827,7 @@ async def _aiter_and_archive( if cost_usd > 0: from .cache import save_to_cache - response_data: Dict[str, Any] = { + response_data: dict[str, Any] = { "id": assembled_id or "stream", "object": "chat.completion", "created": assembled_created or int(__import__("time").time()), @@ -2879,7 +2881,7 @@ async def _aiter_sse_chunks(response: httpx.Response) -> AsyncIterator[ChatCompl _sign_payment_from_response = LLMClient._sign_payment_from_response _raise_stream_error = LLMClient._raise_stream_error - async def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> ChatResponse: + async def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> ChatResponse: """Make async request with automatic payment handling.""" url = f"{self.api_url}{endpoint}" req_headers = {"Content-Type": "application/json", "User-Agent": _get_user_agent()} @@ -2920,7 +2922,7 @@ async def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> Ch async def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, ) -> ChatResponse: """Handle 402 response asynchronously.""" @@ -3046,8 +3048,8 @@ async def _handle_payment_and_retry( return chat_response async def _request_with_payment_raw( - self, endpoint: str, body: Dict[str, Any] - ) -> Dict[str, Any]: + self, endpoint: str, body: dict[str, Any] + ) -> dict[str, Any]: """Make async request with automatic payment handling, returning raw JSON.""" from .cache import get_cached, save_to_cache @@ -3100,9 +3102,9 @@ async def _request_with_payment_raw( async def _handle_payment_and_retry_raw( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Handle 402 response asynchronously for raw endpoints.""" payment_header = response.headers.get("payment-required") if not payment_header: @@ -3178,8 +3180,8 @@ async def _handle_payment_and_retry_raw( return retry_response.json() async def _get_with_payment_raw( - self, endpoint: str, params: Optional[Dict[str, Any]] = None - ) -> Dict[str, Any]: + self, endpoint: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: """Async GET with x402 payment handling, returning raw JSON.""" from .cache import get_cached, save_to_cache @@ -3227,9 +3229,9 @@ async def _get_with_payment_raw( async def _handle_get_payment_and_retry( self, url: str, - params: Optional[Dict[str, Any]], + params: dict[str, Any] | None, response: httpx.Response, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Handle 402 response asynchronously for GET endpoints.""" payment_header = response.headers.get("payment-required") if not payment_header: @@ -3304,17 +3306,17 @@ async def _handle_get_payment_and_retry( async def image_edit( self, prompt: str, - image: Union[str, List[str]], + image: str | list[str], *, model: str = "openai/gpt-image-2", - mask: Optional[str] = None, + mask: str | None = None, size: str = "1024x1024", n: int = 1, ) -> ImageResponse: """Async image editing (img2img). ``image`` may be a single data URI or a list of 1-4 data URIs for multi-image fusion (openai/* up to 4, google/* up to 3).""" - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "prompt": prompt, "image": image, @@ -3331,13 +3333,13 @@ async def search( self, query: str, *, - sources: Optional[List[str]] = None, + sources: list[str] | None = None, max_results: int = 10, - from_date: Optional[str] = None, - to_date: Optional[str] = None, + from_date: str | None = None, + to_date: str | None = None, ) -> SearchResult: """Async standalone search.""" - body: Dict[str, Any] = { + body: dict[str, Any] = { "query": query, "max_results": max_results, } @@ -3353,107 +3355,107 @@ async def search( # ── Prediction Markets (Powered by Predexon) ──────────────────────────── - async def pm(self, path: str, **params: Any) -> Dict[str, Any]: + async def pm(self, path: str, **params: Any) -> dict[str, Any]: """Async query Predexon prediction market data (GET). Powered by Predexon.""" return await self._get_with_payment_raw(f"/v1/pm/{path}", params or None) - async def pm_query(self, path: str, query: Dict[str, Any]) -> Dict[str, Any]: + async def pm_query(self, path: str, query: dict[str, Any]) -> dict[str, Any]: """Async structured query for Predexon data (POST). Powered by Predexon.""" return await self._request_with_payment_raw(f"/v1/pm/{path}", query) - async def pm_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_markets(self, **params: Any) -> dict[str, Any]: """List canonical cross-venue markets (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm("markets", **params) - async def pm_listings(self, **params: Any) -> Dict[str, Any]: + async def pm_listings(self, **params: Any) -> dict[str, Any]: """List venue-native executable listings (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm("markets/listings", **params) - async def pm_outcome(self, predexon_id: str) -> Dict[str, Any]: + async def pm_outcome(self, predexon_id: str) -> dict[str, Any]: """Resolve a canonical Predexon outcome ID (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm(f"outcomes/{predexon_id}") - async def pm_polymarket_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_markets(self, **params: Any) -> dict[str, Any]: """List Polymarket markets (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm("polymarket/markets", **params) - async def pm_polymarket_events(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_events(self, **params: Any) -> dict[str, Any]: """List Polymarket events (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm("polymarket/events", **params) - async def pm_polymarket_markets_keyset(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_markets_keyset(self, **params: Any) -> dict[str, Any]: """Polymarket markets with cursor-based keyset pagination. Tier 1 ($0.001/call).""" return await self.pm("polymarket/markets/keyset", **params) - async def pm_polymarket_events_keyset(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_events_keyset(self, **params: Any) -> dict[str, Any]: """Polymarket events with cursor-based keyset pagination. Tier 1 ($0.001/call).""" return await self.pm("polymarket/events/keyset", **params) - async def pm_polymarket_positions(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_positions(self, **params: Any) -> dict[str, Any]: """Polymarket open positions (per-wallet, market-level PnL). Tier 1 ($0.001/call).""" return await self.pm("polymarket/positions", **params) - async def pm_polymarket_trades(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_trades(self, **params: Any) -> dict[str, Any]: """Recent Polymarket trades. Tier 1 ($0.001/call).""" return await self.pm("polymarket/trades", **params) - async def pm_polymarket_leaderboard(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_leaderboard(self, **params: Any) -> dict[str, Any]: """Polymarket trader leaderboard. Tier 1 ($0.001/call).""" return await self.pm("polymarket/leaderboard", **params) - async def pm_kalshi_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_kalshi_markets(self, **params: Any) -> dict[str, Any]: """List Kalshi markets. Tier 1 ($0.001/call).""" return await self.pm("kalshi/markets", **params) - async def pm_limitless_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_limitless_markets(self, **params: Any) -> dict[str, Any]: """List Limitless markets. Tier 1 ($0.001/call).""" return await self.pm("limitless/markets", **params) - async def pm_sports_categories(self) -> Dict[str, Any]: + async def pm_sports_categories(self) -> dict[str, Any]: """List available sports categories. Tier 1 ($0.001/call).""" return await self.pm("sports/categories") - async def pm_sports_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_sports_markets(self, **params: Any) -> dict[str, Any]: """List sports markets grouped by game. Tier 1 ($0.001/call).""" return await self.pm("sports/markets", **params) - async def pm_wallet_identity(self, wallet: str) -> Dict[str, Any]: + async def pm_wallet_identity(self, wallet: str) -> dict[str, Any]: """Identity + profile for one wallet. Tier 2 ($0.005/call).""" return await self.pm(f"polymarket/wallet/identity/{wallet}") - async def pm_wallet_identities(self, addresses: List[str]) -> Dict[str, Any]: + async def pm_wallet_identities(self, addresses: list[str]) -> dict[str, Any]: """Bulk identity for up to 200 wallet addresses. Tier 2 ($0.005/call).""" return await self.pm_query("polymarket/wallet/identities", {"addresses": addresses}) - async def pm_wallet_cluster(self, address: str) -> Dict[str, Any]: + async def pm_wallet_cluster(self, address: str) -> dict[str, Any]: """Wallet-cluster discovery (on-chain transfers + identity proofs). Tier 2 ($0.005/call).""" return await self.pm(f"polymarket/wallet/{address}/cluster") # ── DefiLlama (DeFi protocols / TVL / yields / prices) ────────────────── - async def defi(self, path: str, **params: Any) -> Dict[str, Any]: + async def defi(self, path: str, **params: Any) -> dict[str, Any]: """Async query DefiLlama DeFi data (GET). $0.005/call ($0.001 for prices).""" return await self._get_with_payment_raw(f"/v1/defillama/{path}", params or None) - async def defi_protocols(self) -> Dict[str, Any]: + async def defi_protocols(self) -> dict[str, Any]: """Async: all DeFi protocols with TVL ($0.005/call).""" return await self.defi("protocols") - async def defi_protocol(self, slug: str) -> Dict[str, Any]: + async def defi_protocol(self, slug: str) -> dict[str, Any]: """Async: single protocol details + historical TVL ($0.005/call).""" return await self.defi(f"protocol/{slug}") - async def defi_chains(self) -> Dict[str, Any]: + async def defi_chains(self) -> dict[str, Any]: """Async: current TVL of every chain ($0.005/call).""" return await self.defi("chains") - async def defi_yields(self, **params: Any) -> Dict[str, Any]: + async def defi_yields(self, **params: Any) -> dict[str, Any]: """Async: yield pools with APY/TVL ($0.005/call).""" return await self.defi("yields", **params) - async def defi_prices(self, coins: Union[List[str], str]) -> Dict[str, Any]: + async def defi_prices(self, coins: list[str] | str) -> dict[str, Any]: """Async: token price lookup ($0.001/call).""" joined = ",".join(coins) if isinstance(coins, list) else coins return await self.defi(f"prices/{joined}") @@ -3465,74 +3467,74 @@ async def dex( path: str, *, method: str = "GET", - body: Optional[Dict[str, Any]] = None, + body: dict[str, Any] | None = None, **params: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Async query the 0x Swap / Gasless APIs (free passthrough).""" endpoint = f"/v1/zerox/{path}" if method.upper() == "POST": return await self._request_with_payment_raw(endpoint, body or {}) return await self._get_with_payment_raw(endpoint, params or None) - async def dex_price(self, **params: Any) -> Dict[str, Any]: + async def dex_price(self, **params: Any) -> dict[str, Any]: """Async: indicative Permit2 swap price (free).""" return await self.dex("price", **params) - async def dex_quote(self, **params: Any) -> Dict[str, Any]: + async def dex_quote(self, **params: Any) -> dict[str, Any]: """Async: firm Permit2 swap quote (free).""" return await self.dex("quote", **params) - async def dex_gasless_price(self, **params: Any) -> Dict[str, Any]: + async def dex_gasless_price(self, **params: Any) -> dict[str, Any]: """Async: gasless indicative price quote (free).""" return await self.dex("gasless/price", **params) - async def dex_gasless_quote(self, **params: Any) -> Dict[str, Any]: + async def dex_gasless_quote(self, **params: Any) -> dict[str, Any]: """Async: gasless firm quote — returns trade.eip712 to sign (free).""" return await self.dex("gasless/quote", **params) - async def dex_gasless_submit(self, body: Dict[str, Any]) -> Dict[str, Any]: + async def dex_gasless_submit(self, body: dict[str, Any]) -> dict[str, Any]: """Async: submit a signed gasless trade (free).""" return await self.dex("gasless/submit", method="POST", body=body) - async def dex_gasless_status(self, trade_hash: str) -> Dict[str, Any]: + async def dex_gasless_status(self, trade_hash: str) -> dict[str, Any]: """Async: poll a gasless trade's status (free).""" return await self.dex(f"gasless/status/{trade_hash}") - async def dex_chains(self) -> Dict[str, Any]: + async def dex_chains(self) -> dict[str, Any]: """Async: chains where the Swap API is supported (free).""" return await self.dex("swap/chains") - async def dex_gasless_chains(self) -> Dict[str, Any]: + async def dex_gasless_chains(self) -> dict[str, Any]: """Async: chains where the Gasless API is supported (free).""" return await self.dex("gasless/chains") # ── Modal Sandbox (pay-per-call cloud compute) ─────────────────────────── - async def modal(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + async def modal(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: """Async call the Modal sandbox compute API (POST passthrough).""" return await self._request_with_payment_raw(f"/v1/modal/{path}", body or {}) - async def modal_sandbox_create(self, **body: Any) -> Dict[str, Any]: + async def modal_sandbox_create(self, **body: Any) -> dict[str, Any]: """Async: create a sandbox ($0.01 CPU / $0.05 GPU).""" return await self.modal("sandbox/create", body) async def modal_sandbox_exec( - self, sandbox_id: str, command: List[str], **body: Any - ) -> Dict[str, Any]: + self, sandbox_id: str, command: list[str], **body: Any + ) -> dict[str, Any]: """Async: execute a command in a sandbox ($0.001).""" return await self.modal( "sandbox/exec", {"sandbox_id": sandbox_id, "command": command, **body} ) - async def modal_sandbox_status(self, sandbox_id: str) -> Dict[str, Any]: + async def modal_sandbox_status(self, sandbox_id: str) -> dict[str, Any]: """Async: check a sandbox's status ($0.001).""" return await self.modal("sandbox/status", {"sandbox_id": sandbox_id}) - async def modal_sandbox_terminate(self, sandbox_id: str) -> Dict[str, Any]: + async def modal_sandbox_terminate(self, sandbox_id: str) -> dict[str, Any]: """Async: terminate a sandbox ($0.001).""" return await self.modal("sandbox/terminate", {"sandbox_id": sandbox_id}) - async def list_models(self) -> List[Dict[str, Any]]: + async def list_models(self) -> list[dict[str, Any]]: """List available LLM models asynchronously.""" response = await self._client.get(f"{self.api_url}/v1/models") @@ -3549,7 +3551,7 @@ async def list_models(self) -> List[Dict[str, Any]]: return response.json().get("data", []) - async def list_image_models(self) -> List[Dict[str, Any]]: + async def list_image_models(self) -> list[dict[str, Any]]: """List available image generation models asynchronously. ``/v1/images/models`` was deprecated server-side; this filters the @@ -3559,7 +3561,7 @@ async def list_image_models(self) -> List[Dict[str, Any]]: models = await self.list_models() return [m for m in models if "image" in (m.get("categories") or [])] - async def list_all_models(self) -> List[Dict[str, Any]]: + async def list_all_models(self) -> list[dict[str, Any]]: """ List all available models (chat, image, music, etc.) asynchronously. @@ -3587,7 +3589,7 @@ def is_testnet(self) -> bool: """Check if client is configured for testnet.""" return "testnet.blockrun.ai" in self.api_url - def _billing_meta(self) -> Dict[str, Optional[str]]: + def _billing_meta(self) -> dict[str, str | None]: """Billing metadata for cost-log entries.""" return { "wallet": self.account.address, @@ -3598,7 +3600,7 @@ def _billing_meta(self) -> Dict[str, Optional[str]]: def _log_transaction( self, endpoint: str, - body: Dict[str, Any], + body: dict[str, Any], response: Any, cost_usd: float, ) -> None: @@ -3700,7 +3702,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): # ============================================================================= -def testnet_client(private_key: Optional[str] = None, **kwargs) -> LLMClient: +def testnet_client(private_key: str | None = None, **kwargs) -> LLMClient: """ Create a testnet LLM client for development and testing. @@ -3736,7 +3738,7 @@ def testnet_client(private_key: Optional[str] = None, **kwargs) -> LLMClient: ) -async def async_testnet_client(private_key: Optional[str] = None, **kwargs) -> AsyncLLMClient: +async def async_testnet_client(private_key: str | None = None, **kwargs) -> AsyncLLMClient: """ Create an async testnet LLM client for development and testing. diff --git a/blockrun_llm/image.py b/blockrun_llm/image.py index 2084f56..e098d7a 100644 --- a/blockrun_llm/image.py +++ b/blockrun_llm/image.py @@ -26,14 +26,17 @@ result = client.generate("prompt", model="google/nano-banana-pro") """ +from __future__ import annotations + import os -from typing import Optional, Dict, Any, List, Union +from typing import Any + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account -from .types import ImageResponse, APIError, PaymentError -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details +from .tx_log import paid_request_error_prefix +from .types import APIError, ImageResponse, PaymentError from .validation import ( build_payment_rejected_error, sanitize_error_response, @@ -41,8 +44,7 @@ validate_private_key, validate_resource_url, ) -from .tx_log import paid_request_error_prefix - +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required # Load environment variables load_dotenv() @@ -72,8 +74,8 @@ class ImageClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = 200.0, # gpt-image-2 at >=1536px can take ~180s server-side; 200s gives buffer ): """ @@ -125,8 +127,8 @@ def generate( self, prompt: str, *, - model: Optional[str] = None, - size: Optional[str] = None, + model: str | None = None, + size: str | None = None, n: int = 1, **kwargs: Any, ) -> ImageResponse: @@ -168,7 +170,7 @@ def generate( ) # Build request body - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or self.DEFAULT_MODEL, "prompt": prompt, "size": size or self.DEFAULT_SIZE, @@ -181,11 +183,11 @@ def generate( def edit( self, prompt: str, - image: Union[str, List[str]], + image: str | list[str], *, - model: Optional[str] = None, - mask: Optional[str] = None, - size: Optional[str] = None, + model: str | None = None, + mask: str | None = None, + size: str | None = None, n: int = 1, **kwargs: Any, ) -> ImageResponse: @@ -241,7 +243,7 @@ def edit( f"Valid parameters are: prompt, image, model, mask, size, n.{hint}" ) - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or "openai/gpt-image-2", "prompt": prompt, "image": image, @@ -253,7 +255,7 @@ def edit( return self._request_with_payment("/v1/images/image2image", body) - def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> ImageResponse: + def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> ImageResponse: """ Make a request with automatic x402 payment handling. @@ -293,7 +295,7 @@ def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> ImageRes def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, ) -> ImageResponse: """Handle 402 response: parse requirements, sign payment, retry.""" @@ -378,9 +380,9 @@ def _absolute_url(self, url: str) -> str: our ``self.api_url`` already ends with ``/api`` so we strip it once to avoid double-prefixing. """ - if url.startswith("http://") or url.startswith("https://"): + if url.startswith(("http://", "https://")): return url - base = self.api_url[: -len("/api")] if self.api_url.endswith("/api") else self.api_url + base = self.api_url.removesuffix("/api") return f"{base}{url}" def _poll_until_completed( diff --git a/blockrun_llm/music.py b/blockrun_llm/music.py index 16058db..fc5750b 100644 --- a/blockrun_llm/music.py +++ b/blockrun_llm/music.py @@ -29,20 +29,23 @@ Note: Generated URLs expire in ~24h — download immediately if needed. """ +from __future__ import annotations + import os -from typing import Optional, Dict, Any +from typing import Any + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account -from .types import MusicResponse, APIError, PaymentError -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details +from .tx_log import paid_request_error_prefix +from .types import APIError, MusicResponse, PaymentError from .validation import ( - validate_private_key, - validate_api_url, sanitize_error_response, + validate_api_url, + validate_private_key, ) -from .tx_log import paid_request_error_prefix +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required load_dotenv() @@ -63,8 +66,8 @@ class MusicClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = 210.0, ): """ @@ -106,9 +109,9 @@ def generate( self, prompt: str, *, - model: Optional[str] = None, + model: str | None = None, instrumental: bool = True, - lyrics: Optional[str] = None, + lyrics: str | None = None, ) -> MusicResponse: """ Generate a music track from a text prompt. @@ -145,7 +148,7 @@ def generate( if instrumental and lyrics and lyrics.strip(): raise ValueError("Cannot specify lyrics when instrumental is True") - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or self.DEFAULT_MODEL, "prompt": prompt, "instrumental": instrumental, @@ -155,7 +158,7 @@ def generate( return self._request_with_payment("/v1/audio/generations", body) - def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> MusicResponse: + def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> MusicResponse: """Make a request with automatic x402 payment handling.""" url = f"{self.api_url}{endpoint}" @@ -184,7 +187,7 @@ def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> MusicRes def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, ) -> MusicResponse: """Handle 402 response: parse requirements, sign payment, retry.""" diff --git a/blockrun_llm/phone.py b/blockrun_llm/phone.py index f3ee5e1..65166f1 100644 --- a/blockrun_llm/phone.py +++ b/blockrun_llm/phone.py @@ -39,12 +39,14 @@ from __future__ import annotations import os -from typing import Any, Dict, Optional +from typing import Any import httpx from dotenv import load_dotenv from eth_account import Account +from typing_extensions import Self +from .tx_log import paid_request_error_prefix from .types import APIError, PaymentError from .validation import ( sanitize_error_response, @@ -56,13 +58,12 @@ extract_payment_details, parse_payment_required, ) -from .tx_log import paid_request_error_prefix load_dotenv() # Mirrors src/lib/twilio.ts PHONE_PRICES on the backend (settled USDC amount). -PHONE_PRICES: Dict[str, float] = { +PHONE_PRICES: dict[str, float] = { "lookup": 0.01, "lookup/fraud": 0.05, "numbers/buy": 5.00, @@ -86,8 +87,8 @@ class PhoneClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, ): from .wallet import load_wallet @@ -118,7 +119,7 @@ def __init__( # ------------------------------------------------------------------ Lookup - def lookup(self, phone_number: str) -> Dict[str, Any]: + def lookup(self, phone_number: str) -> dict[str, Any]: """ Carrier + line-type lookup. ~$0.01. @@ -131,7 +132,7 @@ def lookup(self, phone_number: str) -> Dict[str, Any]: self._require_e164(phone_number) return self._request("lookup", {"phoneNumber": phone_number.strip()}) - def lookup_fraud(self, phone_number: str) -> Dict[str, Any]: + def lookup_fraud(self, phone_number: str) -> dict[str, Any]: """ Lookup + fraud signals (SIM swap, call forwarding). ~$0.05. @@ -149,8 +150,8 @@ def lookup_fraud(self, phone_number: str) -> Dict[str, Any]: def buy_number( self, country: str = "US", - area_code: Optional[str] = None, - ) -> Dict[str, Any]: + area_code: str | None = None, + ) -> dict[str, Any]: """ Provision a dedicated phone number for 30 days. $5.00. @@ -173,14 +174,14 @@ def buy_number( """ if country not in ("US", "CA"): raise ValueError("country must be 'US' or 'CA'") - body: Dict[str, Any] = {"country": country} + body: dict[str, Any] = {"country": country} if area_code is not None: if not (isinstance(area_code, str) and area_code.isdigit() and len(area_code) == 3): raise ValueError("area_code must be a 3-digit string, e.g. '415'") body["areaCode"] = area_code return self._request("numbers/buy", body) - def renew_number(self, phone_number: str) -> Dict[str, Any]: + def renew_number(self, phone_number: str) -> dict[str, Any]: """ Extend an existing provisioned number by 30 days. $5.00. @@ -196,7 +197,7 @@ def renew_number(self, phone_number: str) -> Dict[str, Any]: self._require_e164(phone_number) return self._request("numbers/renew", {"phoneNumber": phone_number.strip()}) - def list_numbers(self) -> Dict[str, Any]: + def list_numbers(self) -> dict[str, Any]: """ List the wallet's active phone numbers. ~$0.001. @@ -208,7 +209,7 @@ def list_numbers(self) -> Dict[str, Any]: """ return self._request("numbers/list", {}) - def release_number(self, phone_number: str) -> Dict[str, Any]: + def release_number(self, phone_number: str) -> dict[str, Any]: """ Release a provisioned number back to the Twilio pool. Free, but the request still flows through x402 so the backend can verify ownership. @@ -232,7 +233,7 @@ def _require_e164(value: str) -> None: if not v.startswith("+") or not v[1:].isdigit() or not (8 <= len(v) <= 16): raise ValueError(f"phone_number must be E.164 (e.g. '+14155552671'), got {value!r}") - def _request(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: + def _request(self, path: str, body: dict[str, Any]) -> dict[str, Any]: url = f"{self.api_url}/v1/phone/{path}" response = self._client.post(url, json=body, headers={"Content-Type": "application/json"}) if response.status_code == 402: @@ -242,9 +243,9 @@ def _request(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: payment_header: Any = response.headers.get("payment-required") if not payment_header: try: @@ -294,7 +295,7 @@ def _handle_payment_and_retry( return data @staticmethod - def _unwrap(response: httpx.Response, *, after_payment: bool = False) -> Dict[str, Any]: + def _unwrap(response: httpx.Response, *, after_payment: bool = False) -> dict[str, Any]: if response.status_code == 200: return response.json() try: @@ -317,7 +318,7 @@ def get_wallet_address(self) -> str: def close(self) -> None: self._client.close() - def __enter__(self) -> "PhoneClient": + def __enter__(self) -> Self: return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: diff --git a/blockrun_llm/portrait.py b/blockrun_llm/portrait.py index 395bb43..b388d67 100644 --- a/blockrun_llm/portrait.py +++ b/blockrun_llm/portrait.py @@ -36,30 +36,33 @@ print(p.assetId, p.name) """ +from __future__ import annotations + import os -from typing import Optional, Dict, Any +from typing import Any + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account +from .tx_log import paid_request_error_prefix from .types import ( - PortraitEnrollment, - PortraitList, APIError, PaymentError, -) -from .x402 import ( - create_payment_payload, - parse_payment_required, - extract_payment_details, + PortraitEnrollment, + PortraitList, ) from .validation import ( - validate_private_key, - validate_api_url, sanitize_error_response, + validate_api_url, + validate_private_key, validate_resource_url, ) -from .tx_log import paid_request_error_prefix +from .x402 import ( + create_payment_payload, + extract_payment_details, + parse_payment_required, +) load_dotenv() @@ -85,8 +88,8 @@ class PortraitClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = 60.0, ): """ @@ -153,7 +156,7 @@ def enroll(self, name: str, image_url: str) -> PortraitEnrollment: if not image_url or not image_url.lower().startswith(("https://", "http://")): raise ValueError("image_url must be an http(s) URL") - body: Dict[str, Any] = { + body: dict[str, Any] = { "name": name, "image_url": image_url, } @@ -163,7 +166,7 @@ def enroll(self, name: str, image_url: str) -> PortraitEnrollment: # Listing (free, rate-limited) # ------------------------------------------------------------------ - def list_portraits(self, wallet_address: Optional[str] = None) -> PortraitList: + def list_portraits(self, wallet_address: str | None = None) -> PortraitList: """ List portraits enrolled by a wallet. Free, but rate-limited to ~20 requests / hour / IP (shared with the wallet-reconciliation @@ -198,7 +201,7 @@ def list_portraits(self, wallet_address: Optional[str] = None) -> PortraitList: # Internal: x402 paid POST # ------------------------------------------------------------------ - def _post_with_payment(self, endpoint: str, body: Dict[str, Any]) -> PortraitEnrollment: + def _post_with_payment(self, endpoint: str, body: dict[str, Any]) -> PortraitEnrollment: url = f"{self.api_url}{endpoint}" resp = self._client.post( @@ -218,7 +221,7 @@ def _post_with_payment(self, endpoint: str, body: Dict[str, Any]) -> PortraitEnr def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, ) -> PortraitEnrollment: payment_header = response.headers.get("payment-required") diff --git a/blockrun_llm/price.py b/blockrun_llm/price.py index 91b3e89..c1bac2e 100644 --- a/blockrun_llm/price.py +++ b/blockrun_llm/price.py @@ -34,20 +34,21 @@ from __future__ import annotations import os -from typing import Optional, Dict, Any, Literal +from typing import Any, Literal + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account +from typing_extensions import Self -from .types import APIError, PaymentError, PricePoint, PriceHistoryResponse, SymbolListResponse -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details +from .tx_log import paid_request_error_prefix +from .types import APIError, PaymentError, PriceHistoryResponse, PricePoint, SymbolListResponse from .validation import ( - validate_private_key, - validate_api_url, sanitize_error_response, + validate_api_url, + validate_private_key, ) -from .tx_log import paid_request_error_prefix - +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required load_dotenv() @@ -72,8 +73,8 @@ class PriceClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, require_wallet: bool = True, ): @@ -113,8 +114,8 @@ def price( category: Category, symbol: str, *, - market: Optional[Market] = None, - session: Optional[Session] = None, + market: Market | None = None, + session: Session | None = None, ) -> PricePoint: """ Fetch a realtime price quote. @@ -122,7 +123,7 @@ def price( For ``stocks`` category the ``market`` param is required. """ endpoint = self._category_path(category, market, "price", symbol) - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if session is not None: params["session"] = session data = self._get_with_payment(endpoint, params=params) @@ -147,14 +148,14 @@ def history( resolution: Resolution = "D", from_ts: int, to_ts: int, - market: Optional[Market] = None, - session: Optional[Session] = None, + market: Market | None = None, + session: Session | None = None, ) -> PriceHistoryResponse: """ Fetch OHLC bars between two Unix timestamps (seconds). """ endpoint = self._category_path(category, market, "history", symbol) - params: Dict[str, Any] = { + params: dict[str, Any] = { "resolution": resolution, "from": from_ts, "to": to_ts, @@ -173,15 +174,15 @@ def list_symbols( self, category: Category, *, - q: Optional[str] = None, + q: str | None = None, limit: int = 100, - market: Optional[Market] = None, + market: Market | None = None, ) -> SymbolListResponse: """ List available symbols in a category (free discovery endpoint). """ endpoint = self._category_path(category, market, "list", None) - params: Dict[str, Any] = {"limit": limit} + params: dict[str, Any] = {"limit": limit} if q: params["q"] = q data = self._get_with_payment(endpoint, params=params) @@ -199,9 +200,9 @@ def list_symbols( def _category_path( self, category: Category, - market: Optional[str], + market: str | None, kind: str, - symbol: Optional[str], + symbol: str | None, ) -> str: if category == "stocks": if not market: @@ -215,7 +216,7 @@ def _category_path( return f"{base}/{kind}" return f"{base}/{kind}/{symbol.upper()}" - def _get_with_payment(self, endpoint: str, *, params: Optional[Dict[str, Any]] = None) -> Any: + def _get_with_payment(self, endpoint: str, *, params: dict[str, Any] | None = None) -> Any: url = f"{self.api_url}{endpoint}" response = self._client.get(url, params=params) if response.status_code == 402: @@ -239,7 +240,7 @@ def _get_with_payment(self, endpoint: str, *, params: Optional[Dict[str, Any]] = def _pay_and_retry( self, url: str, - params: Optional[Dict[str, Any]], + params: dict[str, Any] | None, response: httpx.Response, ) -> Any: payment_header: Any = response.headers.get("payment-required") @@ -292,13 +293,13 @@ def _pay_and_retry( ) return retry.json() - def get_wallet_address(self) -> Optional[str]: + def get_wallet_address(self) -> str | None: return self.account.address if self.account else None def close(self) -> None: self._client.close() - def __enter__(self) -> "PriceClient": + def __enter__(self) -> Self: return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: diff --git a/blockrun_llm/realface.py b/blockrun_llm/realface.py index d51f3ec..d930364 100644 --- a/blockrun_llm/realface.py +++ b/blockrun_llm/realface.py @@ -55,34 +55,37 @@ print(r.assetId, r.name) """ +from __future__ import annotations + import os import re import time -from typing import Optional, Dict, Any +from typing import Any + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account +from .tx_log import paid_request_error_prefix from .types import ( - RealFaceInit, - RealFaceStatus, - RealFaceEnrollment, - RealFaceList, APIError, PaymentError, -) -from .x402 import ( - create_payment_payload, - parse_payment_required, - extract_payment_details, + RealFaceEnrollment, + RealFaceInit, + RealFaceList, + RealFaceStatus, ) from .validation import ( - validate_private_key, - validate_api_url, sanitize_error_response, + validate_api_url, + validate_private_key, validate_resource_url, ) -from .tx_log import paid_request_error_prefix +from .x402 import ( + create_payment_payload, + extract_payment_details, + parse_payment_required, +) load_dotenv() @@ -115,8 +118,8 @@ class RealFaceClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = 60.0, ): """ @@ -156,7 +159,7 @@ def __init__( # Step 1: init (free, rate-limited) # ------------------------------------------------------------------ - def init(self, name: str, group_id: Optional[str] = None) -> RealFaceInit: + def init(self, name: str, group_id: str | None = None) -> RealFaceInit: """ Start (or refresh) a RealFace enrollment. Free, but rate-limited to ~10 calls / hour / IP (each call creates an upstream session). @@ -182,7 +185,7 @@ def init(self, name: str, group_id: Optional[str] = None) -> RealFaceInit: if group_id is not None and not _GROUP_ID_RE.match(group_id): raise ValueError("group_id must look like 'legacy_rf_'") - body: Dict[str, Any] = {"name": name} + body: dict[str, Any] = {"name": name} if group_id: body["groupId"] = group_id @@ -304,7 +307,7 @@ def enroll(self, name: str, image_url: str, group_id: str) -> RealFaceEnrollment if not group_id or not _GROUP_ID_RE.match(group_id): raise ValueError("group_id must look like 'legacy_rf_'") - body: Dict[str, Any] = { + body: dict[str, Any] = { "name": name, "image_url": image_url, "group_id": group_id, @@ -315,7 +318,7 @@ def enroll(self, name: str, image_url: str, group_id: str) -> RealFaceEnrollment # Listing (free, rate-limited) # ------------------------------------------------------------------ - def list_realfaces(self, wallet_address: Optional[str] = None) -> RealFaceList: + def list_realfaces(self, wallet_address: str | None = None) -> RealFaceList: """ List RealFaces enrolled by a wallet. Free, but rate-limited to ~20 requests / hour / IP (shared with the wallet-reconciliation @@ -350,7 +353,7 @@ def list_realfaces(self, wallet_address: Optional[str] = None) -> RealFaceList: # Internal: x402 paid POST # ------------------------------------------------------------------ - def _post_with_payment(self, endpoint: str, body: Dict[str, Any]) -> RealFaceEnrollment: + def _post_with_payment(self, endpoint: str, body: dict[str, Any]) -> RealFaceEnrollment: url = f"{self.api_url}{endpoint}" resp = self._client.post( @@ -370,7 +373,7 @@ def _post_with_payment(self, endpoint: str, body: Dict[str, Any]) -> RealFaceEnr def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, ) -> RealFaceEnrollment: payment_header = response.headers.get("payment-required") diff --git a/blockrun_llm/router.py b/blockrun_llm/router.py index e7e4a90..0fd8367 100644 --- a/blockrun_llm/router.py +++ b/blockrun_llm/router.py @@ -14,10 +14,11 @@ print(f"Saved {result['routing']['savings'] * 100:.0f}%") """ -import re -import math -from typing import Dict, List, Optional, Literal, TypedDict +from __future__ import annotations +import math +import re +from typing import Literal, TypedDict # Type definitions Tier = Literal["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] @@ -33,19 +34,19 @@ class RoutingDecision(TypedDict): cost_estimate: float baseline_cost: float savings: float # 0-1 percentage - fallbacks: List[str] # remaining models in tier order, for runtime fallback + fallbacks: list[str] # remaining models in tier order, for runtime fallback class TierConfig(TypedDict): primary: str - fallback: List[str] + fallback: list[str] class ScoringResult(TypedDict): score: float - tier: Optional[Tier] + tier: Tier | None confidence: float - signals: List[str] + signals: list[str] agentic_score: float @@ -224,7 +225,7 @@ class ScoringResult(TypedDict): # ─── Tier Configs by Profile ─── -AUTO_TIERS: Dict[Tier, TierConfig] = { +AUTO_TIERS: dict[Tier, TierConfig] = { "SIMPLE": { # moonshot/kimi-k2.7 is Moonshot's current flagship (256K context, # image+video input, reasoning_content). It is the only k2 visible in @@ -269,7 +270,7 @@ class ScoringResult(TypedDict): }, } -ECO_TIERS: Dict[Tier, TierConfig] = { +ECO_TIERS: dict[Tier, TierConfig] = { "SIMPLE": { # See AUTO_TIERS note: kimi-k2.7 is the catalog flagship. k2.6 and k2.5 # are hidden so the SDK no longer sees their pricing; primary must stay @@ -303,7 +304,7 @@ class ScoringResult(TypedDict): }, } -PREMIUM_TIERS: Dict[Tier, TierConfig] = { +PREMIUM_TIERS: dict[Tier, TierConfig] = { "SIMPLE": { "primary": "google/gemini-2.5-flash", "fallback": ["openai/gpt-5.4-nano", "anthropic/claude-haiku-4.5"], @@ -331,7 +332,7 @@ class ScoringResult(TypedDict): }, } -FREE_TIERS: Dict[Tier, TierConfig] = { +FREE_TIERS: dict[Tier, TierConfig] = { # NVIDIA free tier refresh 2026-04-28: retired nvidia/gpt-oss-120b and # nvidia/gpt-oss-20b (NVIDIA's free build.nvidia.com tier reserves the # right to use prompts/outputs for service improvement, conflicting with @@ -375,7 +376,7 @@ class ScoringResult(TypedDict): def _score_keyword_match( text: str, - keywords: List[str], + keywords: list[str], thresholds: tuple = (1, 2), scores: tuple = (0, 0.5, 1.0), ) -> tuple: @@ -395,7 +396,7 @@ def _calibrate_confidence(distance: float, steepness: float = 12) -> float: def classify_by_rules( prompt: str, - system_prompt: Optional[str], + system_prompt: str | None, estimated_tokens: int, ) -> ScoringResult: """ @@ -404,10 +405,10 @@ def classify_by_rules( """ text = f"{system_prompt or ''} {prompt}".lower() user_text = prompt.lower() - signals: List[str] = [] + signals: list[str] = [] # Dimension scores - scores: Dict[str, float] = {} + scores: dict[str, float] = {} # 1. Token count if estimated_tokens < 50: @@ -540,9 +541,9 @@ def classify_by_rules( def route( prompt: str, - system_prompt: Optional[str], + system_prompt: str | None, max_output_tokens: int, - model_pricing: Dict[str, Dict[str, float]], + model_pricing: dict[str, dict[str, float]], routing_profile: RoutingProfile = "auto", ) -> RoutingDecision: """ diff --git a/blockrun_llm/rpc.py b/blockrun_llm/rpc.py index 20369e4..0f9e5de 100644 --- a/blockrun_llm/rpc.py +++ b/blockrun_llm/rpc.py @@ -46,20 +46,23 @@ attempt, so new Tatum chains work without an SDK update. """ +from __future__ import annotations + import os -from typing import Optional, Dict, Any, List, Union +from typing import Any + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account -from .types import RpcResponse, APIError, PaymentError -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details +from .tx_log import paid_request_error_prefix +from .types import APIError, PaymentError, RpcResponse from .validation import ( - validate_private_key, - validate_api_url, sanitize_error_response, + validate_api_url, + validate_private_key, ) -from .tx_log import paid_request_error_prefix +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required load_dotenv() @@ -163,8 +166,8 @@ class RpcClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, ): """ @@ -206,9 +209,9 @@ def call( self, network: str, method: str, - params: Optional[List[Any]] = None, + params: list[Any] | None = None, *, - id: Union[str, int] = 1, + id: str | int = 1, ) -> RpcResponse: """ Make a single JSON-RPC 2.0 call. Flat $0.002. @@ -235,7 +238,7 @@ def call( block = client.call("ethereum", "eth_blockNumber") print(int(block.result, 16)) """ - body: Dict[str, Any] = {"jsonrpc": "2.0", "id": id, "method": method} + body: dict[str, Any] = {"jsonrpc": "2.0", "id": id, "method": method} if params is not None: body["params"] = params @@ -245,8 +248,8 @@ def call( def batch( self, network: str, - requests: List[Dict[str, Any]], - ) -> List[RpcResponse]: + requests: list[dict[str, Any]], + ) -> list[RpcResponse]: """ Make a JSON-RPC 2.0 batch call. Priced per element ($0.002 x N). @@ -292,7 +295,7 @@ def _to_response(data: Any, headers: httpx.Headers) -> RpcResponse: ) def _request_with_payment( - self, network: str, body: Union[Dict[str, Any], List[Dict[str, Any]]] + self, network: str, body: dict[str, Any] | list[dict[str, Any]] ) -> tuple: """POST the JSON-RPC body with automatic x402 payment handling.""" endpoint = f"/v1/rpc/{network}" @@ -324,7 +327,7 @@ def _handle_payment_and_retry( self, url: str, endpoint: str, - body: Union[Dict[str, Any], List[Dict[str, Any]]], + body: dict[str, Any] | list[dict[str, Any]], response: httpx.Response, ) -> tuple: """Handle 402 response: parse requirements, sign payment, retry.""" diff --git a/blockrun_llm/search.py b/blockrun_llm/search.py index 0e4882d..d9a8055 100644 --- a/blockrun_llm/search.py +++ b/blockrun_llm/search.py @@ -19,21 +19,24 @@ in the PAYMENT-SIGNATURE header. """ +from __future__ import annotations + import os -from typing import Optional, Dict, Any, List, Literal +from typing import Any, Literal + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account +from typing_extensions import Self -from .types import SearchResult, APIError, PaymentError -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details +from .tx_log import paid_request_error_prefix +from .types import APIError, PaymentError, SearchResult from .validation import ( - validate_private_key, - validate_api_url, sanitize_error_response, + validate_api_url, + validate_private_key, ) -from .tx_log import paid_request_error_prefix - +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required load_dotenv() @@ -55,8 +58,8 @@ class SearchClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, ): from .wallet import load_wallet @@ -89,10 +92,10 @@ def search( self, query: str, *, - sources: Optional[List[SearchSourceLiteral]] = None, + sources: list[SearchSourceLiteral] | None = None, max_results: int = DEFAULT_MAX_RESULTS, - from_date: Optional[str] = None, - to_date: Optional[str] = None, + from_date: str | None = None, + to_date: str | None = None, ) -> SearchResult: """ Run a live search query. @@ -111,7 +114,7 @@ def search( if not 1 <= max_results <= 50: raise ValueError("max_results must be between 1 and 50") - body: Dict[str, Any] = { + body: dict[str, Any] = { "query": query, "max_results": max_results, } @@ -125,7 +128,7 @@ def search( data = self._request_with_payment("/v1/search", body) return SearchResult(**data) - def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> Dict[str, Any]: + def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]: url = f"{self.api_url}{endpoint}" response = self._client.post(url, json=body, headers={"Content-Type": "application/json"}) if response.status_code == 402: @@ -145,9 +148,9 @@ def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> Dict[str def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: payment_header: Any = response.headers.get("payment-required") if not payment_header: try: @@ -208,7 +211,7 @@ def get_wallet_address(self) -> str: def close(self) -> None: self._client.close() - def __enter__(self) -> "SearchClient": + def __enter__(self) -> Self: return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index 5dbaabd..9242313 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -23,45 +23,52 @@ import re import sys import threading -from typing import Any, Dict, Iterator, List, Optional, Tuple, Union +from collections.abc import Iterator +from typing import Any import httpx +from typing_extensions import Self +# Shared with the Base client: signing is settlement on either chain, so the +# "already paid, do not retry on another model" tag has to mean the same thing +# in both fallback chains. client.py does not import this module, so there is +# no cycle. +from .client import _SETTLED_ATTR, _enforce_spend_limits, _mark_settled +from .price import Category, Market, Resolution, Session +from .realface import _GROUP_ID_RE +from .solana_wallet import get_solana_public_key +from .tx_log import ( + TransactionLogger, + _resolve_log_dir, + decode_settlement_header, + paid_request_error_prefix, + read_settlement_header, +) from .types import ( + APIError, ChatCompletionChunk, ChatResponse, ImageResponse, - VideoResponse, MusicResponse, - SpeechResponse, + PaymentError, PortraitEnrollment, PortraitList, - RealFaceInit, - RealFaceStatus, + PriceHistoryResponse, + PricePoint, RealFaceEnrollment, + RealFaceInit, RealFaceList, - PricePoint, - PriceHistoryResponse, - SymbolListResponse, + RealFaceStatus, RpcResponse, - APIError, - PaymentError, SearchResult, - stream_choice_content, - stream_choice_finish_reason, + SpeechResponse, + SymbolListResponse, + VideoResponse, chunk_meta, chunk_usage_dict, + stream_choice_content, + stream_choice_finish_reason, ) -from .solana_wallet import get_solana_public_key -from .tx_log import ( - TransactionLogger, - decode_settlement_header, - paid_request_error_prefix, - read_settlement_header, - _resolve_log_dir, -) -from .price import Category, Market, Resolution, Session -from .realface import _GROUP_ID_RE from .validation import ( build_payment_rejected_error, resolve_spend_limit, @@ -72,17 +79,11 @@ validate_video_input_type, ) -# Shared with the Base client: signing is settlement on either chain, so the -# "already paid, do not retry on another model" tag has to mean the same thing -# in both fallback chains. client.py does not import this module, so there is -# no cycle. -from .client import _SETTLED_ATTR, _enforce_spend_limits, _mark_settled - try: from x402 import x402ClientSync + from x402.http.utils import decode_payment_required_header, encode_payment_signature_header from x402.mechanisms.svm import KeypairSigner from x402.mechanisms.svm.exact.register import register_exact_svm_client - from x402.http.utils import decode_payment_required_header, encode_payment_signature_header _HAS_X402 = True except ImportError: @@ -237,9 +238,9 @@ def _get_user_agent() -> str: def _resolve_rpc_config( - rpc_url: Optional[str], - rpc_headers: Optional[Dict[str, str]], -) -> Tuple[str, Optional[Dict[str, str]]]: + rpc_url: str | None, + rpc_headers: dict[str, str] | None, +) -> tuple[str, dict[str, str] | None]: """Resolve the effective RPC URL + headers from explicit args, env vars, or defaults — in that priority order. @@ -273,7 +274,7 @@ def _resolve_rpc_config( resolved_url = rpc_url or os.environ.get("SOLANA_RPC_URL") or DEFAULT_SOLANA_RPC_URL - resolved_headers: Optional[Dict[str, str]] = None + resolved_headers: dict[str, str] | None = None if rpc_headers is not None: resolved_headers = dict(rpc_headers) else: @@ -296,7 +297,7 @@ def _register_svm_with_headers( x402_client: Any, signer: Any, rpc_url: str, - rpc_headers: Optional[Dict[str, str]], + rpc_headers: dict[str, str] | None, ) -> None: """Register the SVM exact scheme on an x402 client, with optional extra HTTP headers for the underlying Solana RPC. @@ -318,8 +319,8 @@ def _register_svm_with_headers( # header-less default. from solana.rpc.api import Client as SolanaClient from x402.mechanisms.svm.exact.client import ExactSvmScheme - from x402.mechanisms.svm.exact.v1.client import ExactSvmSchemeV1 from x402.mechanisms.svm.exact.register import V1_NETWORKS + from x402.mechanisms.svm.exact.v1.client import ExactSvmSchemeV1 pre_client = SolanaClient(rpc_url, extra_headers=rpc_headers) @@ -369,9 +370,7 @@ def _should_fallback_solana(exc: Exception) -> bool: return True if isinstance(exc, httpx.NetworkError): return True - if isinstance(exc, APIError) and exc.status_code in (502, 503, 504, 522, 524): - return True - return False + return bool(isinstance(exc, APIError) and exc.status_code in (502, 503, 504, 522, 524)) # Characters safe to interpolate into a single URL path segment. network / @@ -391,7 +390,7 @@ def _safe_path_segment(value: str, field: str) -> str: return value -def _receipt_from_headers(headers: Any) -> Optional[str]: +def _receipt_from_headers(headers: Any) -> str | None: """Pull the x402 settlement tx hash from a paid response's headers.""" if headers is None: return None @@ -459,16 +458,16 @@ class SolanaLLMClient: def __init__( self, - private_key: Optional[str] = None, + private_key: str | None = None, api_url: str = SOLANA_API_URL, - rpc_url: Optional[str] = None, + rpc_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, image_timeout: float = DEFAULT_IMAGE_TIMEOUT, search_timeout: float = DEFAULT_SEARCH_TIMEOUT, - rpc_headers: Optional[Dict[str, str]] = None, - transaction_log: Union[bool, str, "os.PathLike[str]", None] = None, - max_cost_per_call: Optional[float] = None, - max_session_cost: Optional[float] = None, + rpc_headers: dict[str, str] | None = None, + transaction_log: bool | str | os.PathLike[str] | None = None, + max_cost_per_call: float | None = None, + max_session_cost: float | None = None, ) -> None: """Initialise the Solana client. @@ -539,18 +538,18 @@ def __init__( self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST") self._session_calls = 0 self._last_call_cost: float = 0.0 - self._address: Optional[str] = None + self._address: str | None = None log_dir = _resolve_log_dir(transaction_log) - self._tx_logger: Optional[TransactionLogger] = ( + self._tx_logger: TransactionLogger | None = ( TransactionLogger(log_dir) if log_dir is not None else None ) - self._last_settlement: Optional[Dict[str, Any]] = None + self._last_settlement: dict[str, Any] | None = None # Response headers from the most recent raw paid POST — consumed by # rpc()/music()/speech() to surface the settlement receipt + gateway # metadata the shared JSON-only helper would otherwise drop. Read it # immediately after the helper returns (no intervening await). - self._last_raw_headers: Optional[httpx.Headers] = None + self._last_raw_headers: httpx.Headers | None = None # Initialize x402 SDK client for Solana payment signing. self._x402_client = x402ClientSync() @@ -584,7 +583,7 @@ def _sign_payment(self, payment_required: Any) -> Any: with self._payment_lock: return self._x402_client.create_payment_payload(payment_required) - def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, Any]]: + def _capture_settlement(self, response: httpx.Response) -> dict[str, Any] | None: """Decode the x402 settlement header on a Solana paid response. Solana facilitators put the on-chain transaction signature in the @@ -623,10 +622,10 @@ def get_balance(self) -> float: return get_solana_usdc_balance(self.get_wallet_address(), rpc_url=self._rpc_url) - def get_spending(self) -> Dict[str, Any]: + def get_spending(self) -> dict[str, Any]: return {"total_usd": self._session_total_usd, "calls": self._session_calls} - def _billing_meta(self) -> Dict[str, Optional[str]]: + def _billing_meta(self) -> dict[str, str | None]: """Billing metadata for cost-log entries.""" return { "wallet": self.get_wallet_address(), @@ -637,7 +636,7 @@ def _billing_meta(self) -> Dict[str, Optional[str]]: def _log_transaction( self, endpoint: str, - body: Dict[str, Any], + body: dict[str, Any], response: Any, cost_usd: float, ) -> None: @@ -670,16 +669,16 @@ def chat( self, model: str, prompt: str, - system: Optional[str] = None, + system: str | None = None, max_tokens: int = DEFAULT_MAX_TOKENS, - temperature: Optional[float] = None, + temperature: float | None = None, search: bool = False, - timeout: Optional[float] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, + timeout: float | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, ) -> str: """Simple 1-line chat.""" - messages: List[Dict[str, str]] = [] + messages: list[dict[str, str]] = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) @@ -698,17 +697,17 @@ def chat( def chat_completion( self, model: str, - messages: List[Dict[str, Any]], + messages: list[dict[str, Any]], max_tokens: int = DEFAULT_MAX_TOKENS, - temperature: Optional[float] = None, - top_p: Optional[float] = None, + temperature: float | None = None, + top_p: float | None = None, search: bool = False, - search_parameters: Optional[Dict[str, Any]] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[Any] = None, - timeout: Optional[float] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, + search_parameters: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + timeout: float | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, ) -> ChatResponse: """Full chat completion (OpenAI-compatible). @@ -722,7 +721,7 @@ def chat_completion( large ``max_tokens`` runs against slow models. """ validate_max_tokens(max_tokens) - body: Dict[str, Any] = {"model": model, "messages": messages, "max_tokens": max_tokens} + body: dict[str, Any] = {"model": model, "messages": messages, "max_tokens": max_tokens} if temperature is not None: body["temperature"] = temperature if top_p is not None: @@ -745,13 +744,13 @@ def close(self) -> None: """Close the HTTP client.""" self._client.close() - def list_models(self) -> List[Dict[str, Any]]: + def list_models(self) -> list[dict[str, Any]]: resp = self._client.get(f"{self._api_url}/v1/models") resp.raise_for_status() return resp.json().get("data", []) @staticmethod - def _extract_payment_header(response: httpx.Response) -> Optional[str]: + def _extract_payment_header(response: httpx.Response) -> str | None: """Extract x402 payment header from a 402 response (header or body).""" payment_header = response.headers.get("payment-required") if not payment_header: @@ -787,19 +786,19 @@ def _extract_payment_header(response: httpx.Response) -> Optional[str]: def chat_completion_stream( self, model: str, - messages: List[Dict[str, Any]], + messages: list[dict[str, Any]], *, max_tokens: int = DEFAULT_MAX_TOKENS, - temperature: Optional[float] = None, - top_p: Optional[float] = None, + temperature: float | None = None, + top_p: float | None = None, search: bool = False, - search_parameters: Optional[Dict[str, Any]] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[Any] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, - fallback_models: Optional[List[str]] = None, - timeout: Optional[float] = None, + search_parameters: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, + timeout: float | None = None, ) -> Iterator[ChatCompletionChunk]: """ Stream a chat completion via Server-Sent Events, paid in Solana USDC @@ -823,7 +822,7 @@ def chat_completion_stream( stream mode (HTTP 400). Codex / GPT-5.4-Pro also can't stream. """ validate_max_tokens(max_tokens) - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "messages": messages, "stream": True, @@ -847,7 +846,7 @@ def chat_completion_stream( body["stop"] = stop attempts = [model, *(fallback_models or [])] - last_exc: Optional[Exception] = None + last_exc: Exception | None = None for i, attempt_model in enumerate(attempts): body["model"] = attempt_model @@ -876,8 +875,8 @@ def chat_completion_stream( def _stream_with_payment( self, endpoint: str, - body: Dict[str, Any], - timeout: Optional[float] = None, + body: dict[str, Any], + timeout: float | None = None, ) -> Iterator[ChatCompletionChunk]: """Whole-request payment-retry wrapper around :meth:`_stream_once`. @@ -911,8 +910,8 @@ def _stream_with_payment( def _stream_once( self, endpoint: str, - body: Dict[str, Any], - timeout: Optional[float] = None, + body: dict[str, Any], + timeout: float | None = None, ) -> Iterator[ChatCompletionChunk]: """402 → sign (SVM) → retry → SSE iter. Same shape as the Base :meth:`LLMClient._stream_with_payment`; differs only in the @@ -924,7 +923,7 @@ def _stream_once( backoffs = self._STREAM_5XX_BACKOFFS # ----- Phase 1: probe (no payment header) ----- - payment_headers: Optional[Dict[str, str]] = None + payment_headers: dict[str, str] | None = None cost_usd = 0.0 for attempt in range(len(backoffs) + 1): @@ -983,7 +982,7 @@ def _stream_once( def _iter_and_archive( self, response: httpx.Response, - body: Dict[str, Any], + body: dict[str, Any], cost_usd: float, ) -> Iterator[ChatCompletionChunk]: """Yield SSE chunks; on stream completion, archive the assembled @@ -992,12 +991,12 @@ def _iter_and_archive( in the same audit trail as non-stream paid calls. ``cost_usd == 0`` skips the archive (free models / unauth probe).""" - assembled_id: Optional[str] = None - assembled_model: Optional[str] = None + assembled_id: str | None = None + assembled_model: str | None = None assembled_created: int = 0 - content_parts: List[str] = [] - finish_reason: Optional[str] = None - usage_dict: Optional[Dict[str, Any]] = None + content_parts: list[str] = [] + finish_reason: str | None = None + usage_dict: dict[str, Any] | None = None for chunk in self._iter_sse_chunks(response): if chunk.choices: @@ -1024,7 +1023,7 @@ def _iter_and_archive( if cost_usd > 0: from .cache import save_to_cache - response_data: Dict[str, Any] = { + response_data: dict[str, Any] = { "id": assembled_id or "stream", "object": "chat.completion", "created": assembled_created or int(__import__("time").time()), @@ -1077,7 +1076,7 @@ def _iter_sse_chunks(response: httpx.Response) -> Iterator[ChatCompletionChunk]: def _sign_payment_from_response( self, response: httpx.Response, - ) -> Tuple[Dict[str, str], float]: + ) -> tuple[dict[str, str], float]: """Extract a 402 response's payment requirements, sign locally with the SVM x402 client, return ``(headers_with_PAYMENT_SIGNATURE, cost_usd)``. Mirrors the inline logic in @@ -1121,7 +1120,7 @@ def _raise_stream_error(response: httpx.Response, *, after_payment: bool) -> Non ) def _request_with_payment( - self, endpoint: str, body: Dict[str, Any], timeout: Optional[float] = None + self, endpoint: str, body: dict[str, Any], timeout: float | None = None ) -> ChatResponse: """Whole-request payment-retry wrapper around :meth:`_request_once`. @@ -1148,7 +1147,7 @@ def _request_with_payment( ) def _request_once( - self, endpoint: str, body: Dict[str, Any], timeout: Optional[float] = None + self, endpoint: str, body: dict[str, Any], timeout: float | None = None ) -> ChatResponse: url = f"{self._api_url}{endpoint}" headers = {"Content-Type": "application/json", "User-Agent": _get_user_agent()} @@ -1188,9 +1187,9 @@ def _request_once( def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, - timeout: Optional[float] = None, + timeout: float | None = None, ) -> ChatResponse: eff_timeout = timeout if timeout is not None else self._timeout payment_header = self._extract_payment_header(response) @@ -1260,8 +1259,8 @@ def _handle_payment_and_retry( return ChatResponse(**response_data) def _request_with_payment_raw( - self, endpoint: str, body: Dict[str, Any], timeout: Optional[float] = None - ) -> Dict[str, Any]: + self, endpoint: str, body: dict[str, Any], timeout: float | None = None + ) -> dict[str, Any]: """Make a request with Solana x402 payment, returning raw JSON.""" from .cache import get_cached, save_to_cache @@ -1323,10 +1322,10 @@ def _request_with_payment_raw( def _handle_payment_and_retry_raw( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, - timeout: Optional[float] = None, - ) -> Dict[str, Any]: + timeout: float | None = None, + ) -> dict[str, Any]: """Handle 402 for raw endpoints with Solana payment.""" eff_timeout = timeout if timeout is not None else self._timeout payment_header = self._extract_payment_header(response) @@ -1386,9 +1385,9 @@ def _handle_payment_and_retry_raw( def _get_with_payment_raw( self, endpoint: str, - params: Optional[Dict[str, Any]] = None, - timeout: Optional[float] = None, - ) -> Dict[str, Any]: + params: dict[str, Any] | None = None, + timeout: float | None = None, + ) -> dict[str, Any]: """GET with Solana x402 payment, returning raw JSON.""" from .cache import get_cached, save_to_cache @@ -1437,10 +1436,10 @@ def _get_with_payment_raw( def _handle_get_payment_and_retry( self, url: str, - params: Optional[Dict[str, Any]], + params: dict[str, Any] | None, response: httpx.Response, - timeout: Optional[float] = None, - ) -> Dict[str, Any]: + timeout: float | None = None, + ) -> dict[str, Any]: """Handle 402 for GET endpoints with Solana payment.""" eff_timeout = timeout if timeout is not None else self._timeout payment_header = self._extract_payment_header(response) @@ -1501,8 +1500,8 @@ def _absolute_url(self, url: str) -> str: configured ``api_url`` already includes the trailing ``/api`` so we strip it once to avoid ``/api/api/...``. """ - base = self._api_url[: -len("/api")] if self._api_url.endswith("/api") else self._api_url - if url.startswith("http://") or url.startswith("https://"): + base = self._api_url.removesuffix("/api") + if url.startswith(("http://", "https://")): # The poll loop sends (and re-signs) the wallet's PAYMENT-SIGNATURE # against this URL, so an absolute poll_url is pinned to the API # host+scheme — a gateway response pointing it elsewhere would leak @@ -1522,14 +1521,14 @@ def _absolute_url(self, url: str) -> str: def _request_image_with_payment( self, endpoint: str, - body: Dict[str, Any], - timeout: Optional[float] = None, + body: dict[str, Any], + timeout: float | None = None, *, - poll_budget_seconds: Optional[float] = None, - poll_interval_seconds: Optional[float] = None, + poll_budget_seconds: float | None = None, + poll_interval_seconds: float | None = None, max_resigns: int = 0, label: str = "Image", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Sign + submit + poll wrapper for async media generation. Shared by :meth:`image` (5-min budget, no mid-poll re-signing needed) @@ -1815,8 +1814,8 @@ def image( model: str = "google/nano-banana", size: str = "1024x1024", n: int = 1, - quality: Optional[str] = None, - timeout: Optional[float] = None, + quality: str | None = None, + timeout: float | None = None, ) -> ImageResponse: """Generate an image from a text prompt (Solana payment). @@ -1842,7 +1841,7 @@ def image( Raises: ValueError: If ``quality`` is not one of the four accepted values. """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "prompt": prompt, "size": size, @@ -1857,14 +1856,14 @@ def image( def image_edit( self, prompt: str, - image: Union[str, List[str]], + image: str | list[str], *, model: str = "openai/gpt-image-2", - mask: Optional[str] = None, + mask: str | None = None, size: str = "1024x1024", n: int = 1, - quality: Optional[str] = None, - timeout: Optional[float] = None, + quality: str | None = None, + timeout: float | None = None, ) -> ImageResponse: """Edit an image using img2img (Solana payment). ``image`` may be a single data URI or a list of 1-4 data URIs for multi-image fusion @@ -1880,7 +1879,7 @@ def image_edit( Raises: ValueError: If ``quality`` is not one of the four accepted values. """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "prompt": prompt, "image": image, @@ -1904,21 +1903,21 @@ def video( self, prompt: str, *, - model: Optional[str] = None, - image_url: Optional[str] = None, - last_frame_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, - real_face_asset_id: Optional[str] = None, - duration_seconds: Optional[int] = None, - aspect_ratio: Optional[str] = None, - resolution: Optional[str] = None, - generate_audio: Optional[bool] = None, - seed: Optional[int] = None, - watermark: Optional[bool] = None, - return_last_frame: Optional[bool] = None, - input_type: Optional[str] = None, - budget_seconds: Optional[float] = None, - timeout: Optional[float] = None, + model: str | None = None, + image_url: str | None = None, + last_frame_url: str | None = None, + reference_image_urls: list[str] | None = None, + real_face_asset_id: str | None = None, + duration_seconds: int | None = None, + aspect_ratio: str | None = None, + resolution: str | None = None, + generate_audio: bool | None = None, + seed: int | None = None, + watermark: bool | None = None, + return_last_frame: bool | None = None, + input_type: str | None = None, + budget_seconds: float | None = None, + timeout: float | None = None, ) -> VideoResponse: """Generate a video clip from a text prompt (Solana payment). @@ -1967,11 +1966,11 @@ def video( def video_from_content( self, - content: List[Dict[str, Any]], + content: list[dict[str, Any]], *, - model: Optional[str] = None, - budget_seconds: Optional[float] = None, - timeout: Optional[float] = None, + model: str | None = None, + budget_seconds: float | None = None, + timeout: float | None = None, **options: Any, ) -> VideoResponse: """Generate a video from a Seedance ``content[]`` body (Solana payment). @@ -1982,7 +1981,7 @@ def video_from_content( """ if not content: raise ValueError("content must be a non-empty list of Seedance content items.") - body: Dict[str, Any] = {"content": content, **options} + body: dict[str, Any] = {"content": content, **options} if model is not None: body["model"] = model data = self._request_image_with_payment( @@ -2006,10 +2005,10 @@ def music( self, prompt: str, *, - model: Optional[str] = None, + model: str | None = None, instrumental: bool = True, - lyrics: Optional[str] = None, - timeout: Optional[float] = None, + lyrics: str | None = None, + timeout: float | None = None, ) -> MusicResponse: """Generate a music track from a text prompt (Solana payment). @@ -2018,7 +2017,7 @@ def music( """ if instrumental and lyrics and lyrics.strip(): raise ValueError("Cannot specify lyrics when instrumental is True") - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or self.MUSIC_DEFAULT_MODEL, "prompt": prompt, "instrumental": instrumental, @@ -2037,11 +2036,11 @@ def speech( self, input: str, *, - model: Optional[str] = None, - voice: Optional[str] = None, - response_format: Optional[str] = None, - speed: Optional[float] = None, - timeout: Optional[float] = None, + model: str | None = None, + voice: str | None = None, + response_format: str | None = None, + speed: float | None = None, + timeout: float | None = None, ) -> SpeechResponse: """Synthesize speech from text (Solana payment). @@ -2049,7 +2048,7 @@ def speech( with character count. Default model ``elevenlabs/flash-v2.5``, default voice ``sarah``. """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or self.SPEECH_DEFAULT_MODEL, "input": input, } @@ -2067,15 +2066,15 @@ def sound_effect( self, text: str, *, - model: Optional[str] = None, - duration_seconds: Optional[float] = None, - prompt_influence: Optional[float] = None, - response_format: Optional[str] = None, - timeout: Optional[float] = None, + model: str | None = None, + duration_seconds: float | None = None, + prompt_influence: float | None = None, + response_format: str | None = None, + timeout: float | None = None, ) -> SpeechResponse: """Generate a cinematic sound effect from a text prompt (Solana payment). Mirrors ``SpeechClient.sound_effect``. Flat $0.05, <=22s.""" - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or self.SOUNDFX_DEFAULT_MODEL, "text": text, } @@ -2089,7 +2088,7 @@ def sound_effect( self._attach_receipt(data) return SpeechResponse(**data) - def list_voices(self) -> List[Dict[str, Any]]: + def list_voices(self) -> list[dict[str, Any]]: """List available speech voices (free).""" url = f"{self._api_url}/v1/audio/voices" resp = self._client.get( @@ -2123,11 +2122,11 @@ def portrait_enroll(self, name: str, image_url: str) -> PortraitEnrollment: raise ValueError(f"name must be 64 chars or fewer (got {len(name)})") if not image_url or not image_url.lower().startswith(("https://", "http://")): raise ValueError("image_url must be an http(s) URL") - body: Dict[str, Any] = {"name": name, "image_url": image_url} + body: dict[str, Any] = {"name": name, "image_url": image_url} data = self._request_with_payment_raw("/v1/portrait/enroll", body) return PortraitEnrollment(**data) - def list_portraits(self, wallet_address: Optional[str] = None) -> PortraitList: + def list_portraits(self, wallet_address: str | None = None) -> PortraitList: """List Virtual Portraits enrolled by a wallet (free, rate-limited).""" addr = _safe_path_segment(wallet_address or self.get_wallet_address(), "wallet_address") url = f"{self._api_url}/v1/wallet/{addr}/portraits" @@ -2150,7 +2149,7 @@ def list_portraits(self, wallet_address: Optional[str] = None) -> PortraitList: # RealFace enrollment (Solana payment) # ------------------------------------------------------------------ - def realface_init(self, name: str, group_id: Optional[str] = None) -> RealFaceInit: + def realface_init(self, name: str, group_id: str | None = None) -> RealFaceInit: """Start/refresh a RealFace enrollment (free, rate-limited). Returns the ``group_id`` and an ``h5_link`` (render as a QR for the real person's phone liveness check).""" @@ -2160,7 +2159,7 @@ def realface_init(self, name: str, group_id: Optional[str] = None) -> RealFaceIn raise ValueError(f"name must be 64 chars or fewer (got {len(name)})") if group_id is not None and not _GROUP_ID_RE.match(group_id): raise ValueError("group_id must look like 'legacy_rf_'") - body: Dict[str, Any] = {"name": name} + body: dict[str, Any] = {"name": name} if group_id: body["groupId"] = group_id url = f"{self._api_url}/v1/realface/init" @@ -2238,11 +2237,11 @@ def realface_enroll(self, name: str, image_url: str, group_id: str) -> RealFaceE raise ValueError("image_url must be an http(s) URL") if not group_id or not _GROUP_ID_RE.match(group_id): raise ValueError("group_id must look like 'legacy_rf_'") - body: Dict[str, Any] = {"name": name, "image_url": image_url, "group_id": group_id} + body: dict[str, Any] = {"name": name, "image_url": image_url, "group_id": group_id} data = self._request_with_payment_raw("/v1/realface/enroll", body) return RealFaceEnrollment(**data) - def list_realfaces(self, wallet_address: Optional[str] = None) -> RealFaceList: + def list_realfaces(self, wallet_address: str | None = None) -> RealFaceList: """List RealFace assets enrolled by a wallet (free, rate-limited).""" addr = _safe_path_segment(wallet_address or self.get_wallet_address(), "wallet_address") url = f"{self._api_url}/v1/wallet/{addr}/realfaces" @@ -2269,20 +2268,20 @@ def list_realfaces(self, wallet_address: Optional[str] = None) -> RealFaceList: def _build_video_body( prompt: str, *, - model: Optional[str], - image_url: Optional[str], - last_frame_url: Optional[str], - reference_image_urls: Optional[List[str]], - real_face_asset_id: Optional[str], - duration_seconds: Optional[int], - aspect_ratio: Optional[str], - resolution: Optional[str], - generate_audio: Optional[bool], - seed: Optional[int], - watermark: Optional[bool], - return_last_frame: Optional[bool], - input_type: Optional[str], - ) -> Dict[str, Any]: + model: str | None, + image_url: str | None, + last_frame_url: str | None, + reference_image_urls: list[str] | None, + real_face_asset_id: str | None, + duration_seconds: int | None, + aspect_ratio: str | None, + resolution: str | None, + generate_audio: bool | None, + seed: int | None, + watermark: bool | None, + return_last_frame: bool | None, + input_type: str | None, + ) -> dict[str, Any]: """Validate video kwargs and build the request body. Shared by the sync and async ``video()`` so their validation and payload never drift. @@ -2317,7 +2316,7 @@ def _build_video_body( ) validate_video_input_type(input_type) - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or SolanaLLMClient.VIDEO_DEFAULT_MODEL, "prompt": prompt, } @@ -2349,7 +2348,7 @@ def _build_video_body( @staticmethod def _rpc_response( - data: Any, headers: Optional[httpx.Headers], fallback_network: str + data: Any, headers: httpx.Headers | None, fallback_network: str ) -> RpcResponse: """Build an RpcResponse, surfacing gateway metadata from the paid response headers (canonical network, cache hit, settlement tx) exactly @@ -2369,7 +2368,7 @@ def _rpc_response( @staticmethod def _price_category_path( - category: str, market: Optional[str], kind: str, symbol: Optional[str] + category: str, market: str | None, kind: str, symbol: str | None ) -> str: if category == "stocks": if not market: @@ -2388,13 +2387,13 @@ def price( category: Category, symbol: str, *, - market: Optional[Market] = None, - session: Optional[Session] = None, + market: Market | None = None, + session: Session | None = None, ) -> PricePoint: """Fetch a realtime Pyth price quote (Solana payment for paid categories). ``market`` is required for ``category='stocks'``.""" endpoint = self._price_category_path(category, market, "price", symbol) - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if session is not None: params["session"] = session data = self._get_with_payment_raw( @@ -2421,12 +2420,12 @@ def price_history( resolution: Resolution = "D", from_ts: int, to_ts: int, - market: Optional[Market] = None, - session: Optional[Session] = None, + market: Market | None = None, + session: Session | None = None, ) -> PriceHistoryResponse: """Fetch OHLC bars between two Unix timestamps (seconds).""" endpoint = self._price_category_path(category, market, "history", symbol) - params: Dict[str, Any] = {"resolution": resolution, "from": from_ts, "to": to_ts} + params: dict[str, Any] = {"resolution": resolution, "from": from_ts, "to": to_ts} if session is not None: params["session"] = session data = self._get_with_payment_raw(endpoint, params=params, timeout=DEFAULT_FAST_TIMEOUT) @@ -2441,13 +2440,13 @@ def list_symbols( self, category: Category, *, - q: Optional[str] = None, + q: str | None = None, limit: int = 100, - market: Optional[Market] = None, + market: Market | None = None, ) -> SymbolListResponse: """List available symbols in a Pyth category (free discovery).""" endpoint = self._price_category_path(category, market, "list", None) - params: Dict[str, Any] = {"limit": limit} + params: dict[str, Any] = {"limit": limit} if q: params["q"] = q data = self._get_with_payment_raw(endpoint, params=params, timeout=DEFAULT_FAST_TIMEOUT) @@ -2467,9 +2466,9 @@ def rpc( self, network: str, method: str, - params: Optional[List[Any]] = None, + params: list[Any] | None = None, *, - id: Union[str, int] = 1, + id: str | int = 1, ) -> RpcResponse: """Make a single JSON-RPC 2.0 call (Solana payment, flat $0.002). @@ -2477,18 +2476,18 @@ def rpc( (``eth``, ``sol``, ``base`` …); the gateway resolves it. """ _safe_path_segment(network, "network") - body: Dict[str, Any] = {"jsonrpc": "2.0", "id": id, "method": method} + body: dict[str, Any] = {"jsonrpc": "2.0", "id": id, "method": method} if params is not None: body["params"] = params data = self._request_with_payment_raw(f"/v1/rpc/{network}", body) return self._rpc_response(data, self._last_raw_headers, network) - def rpc_batch(self, network: str, requests: List[Dict[str, Any]]) -> List[RpcResponse]: + def rpc_batch(self, network: str, requests: list[dict[str, Any]]) -> list[RpcResponse]: """Make a JSON-RPC 2.0 batch call (Solana payment, $0.002 x N).""" if not requests: raise ValueError("batch requires at least one request") _safe_path_segment(network, "network") - body: List[Dict[str, Any]] = [] + body: list[dict[str, Any]] = [] for i, req in enumerate(requests): if "method" not in req: raise ValueError(f"batch request {i} is missing 'method'") @@ -2503,18 +2502,18 @@ def search( self, query: str, *, - sources: Optional[List[str]] = None, + sources: list[str] | None = None, max_results: int = 10, - from_date: Optional[str] = None, - to_date: Optional[str] = None, - timeout: Optional[float] = None, + from_date: str | None = None, + to_date: str | None = None, + timeout: float | None = None, ) -> SearchResult: """Standalone search (Solana payment). ``timeout`` overrides the per-call HTTP timeout (defaults to ``DEFAULT_SEARCH_TIMEOUT`` — deep web/X tool-use can run minutes). """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "query": query, "max_results": max_results, } @@ -2531,87 +2530,87 @@ def search( # ── Prediction Markets (Powered by Predexon) ──────────────────────────── - def pm(self, path: str, **params: Any) -> Dict[str, Any]: + def pm(self, path: str, **params: Any) -> dict[str, Any]: """Query Predexon prediction market data (GET, Solana payment). Powered by Predexon.""" return self._get_with_payment_raw(f"/v1/pm/{path}", params or None) - def pm_query(self, path: str, query: Dict[str, Any]) -> Dict[str, Any]: + def pm_query(self, path: str, query: dict[str, Any]) -> dict[str, Any]: """Structured query for Predexon data (POST, Solana payment). Powered by Predexon.""" return self._request_with_payment_raw(f"/v1/pm/{path}", query) - def pm_markets(self, **params: Any) -> Dict[str, Any]: + def pm_markets(self, **params: Any) -> dict[str, Any]: """List canonical cross-venue markets (Predexon v2). Tier 1 ($0.001/call).""" return self.pm("markets", **params) - def pm_listings(self, **params: Any) -> Dict[str, Any]: + def pm_listings(self, **params: Any) -> dict[str, Any]: """List venue-native executable listings (Predexon v2). Tier 1 ($0.001/call).""" return self.pm("markets/listings", **params) - def pm_outcome(self, predexon_id: str) -> Dict[str, Any]: + def pm_outcome(self, predexon_id: str) -> dict[str, Any]: """Resolve a canonical Predexon outcome ID (Predexon v2). Tier 1 ($0.001/call).""" return self.pm(f"outcomes/{predexon_id}") - def pm_polymarket_markets(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_markets(self, **params: Any) -> dict[str, Any]: """List Polymarket markets (Predexon v2). Tier 1 ($0.001/call).""" return self.pm("polymarket/markets", **params) - def pm_polymarket_events(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_events(self, **params: Any) -> dict[str, Any]: """List Polymarket events (Predexon v2). Tier 1 ($0.001/call).""" return self.pm("polymarket/events", **params) - def pm_polymarket_markets_keyset(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_markets_keyset(self, **params: Any) -> dict[str, Any]: """Polymarket markets with cursor-based keyset pagination. Tier 1 ($0.001/call).""" return self.pm("polymarket/markets/keyset", **params) - def pm_polymarket_events_keyset(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_events_keyset(self, **params: Any) -> dict[str, Any]: """Polymarket events with cursor-based keyset pagination. Tier 1 ($0.001/call).""" return self.pm("polymarket/events/keyset", **params) - def pm_polymarket_positions(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_positions(self, **params: Any) -> dict[str, Any]: """Polymarket open positions (per-wallet, market-level PnL). Tier 1 ($0.001/call).""" return self.pm("polymarket/positions", **params) - def pm_polymarket_trades(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_trades(self, **params: Any) -> dict[str, Any]: """Recent Polymarket trades. Tier 1 ($0.001/call).""" return self.pm("polymarket/trades", **params) - def pm_polymarket_leaderboard(self, **params: Any) -> Dict[str, Any]: + def pm_polymarket_leaderboard(self, **params: Any) -> dict[str, Any]: """Polymarket trader leaderboard. Tier 1 ($0.001/call).""" return self.pm("polymarket/leaderboard", **params) - def pm_kalshi_markets(self, **params: Any) -> Dict[str, Any]: + def pm_kalshi_markets(self, **params: Any) -> dict[str, Any]: """List Kalshi markets. Tier 1 ($0.001/call).""" return self.pm("kalshi/markets", **params) - def pm_limitless_markets(self, **params: Any) -> Dict[str, Any]: + def pm_limitless_markets(self, **params: Any) -> dict[str, Any]: """List Limitless markets. Tier 1 ($0.001/call).""" return self.pm("limitless/markets", **params) - def pm_sports_categories(self) -> Dict[str, Any]: + def pm_sports_categories(self) -> dict[str, Any]: """List available sports categories. Tier 1 ($0.001/call).""" return self.pm("sports/categories") - def pm_sports_markets(self, **params: Any) -> Dict[str, Any]: + def pm_sports_markets(self, **params: Any) -> dict[str, Any]: """List sports markets grouped by game. Tier 1 ($0.001/call).""" return self.pm("sports/markets", **params) - def pm_wallet_identity(self, wallet: str) -> Dict[str, Any]: + def pm_wallet_identity(self, wallet: str) -> dict[str, Any]: """Identity + profile for one wallet. Tier 2 ($0.005/call).""" return self.pm(f"polymarket/wallet/identity/{wallet}") - def pm_wallet_identities(self, addresses: List[str]) -> Dict[str, Any]: + def pm_wallet_identities(self, addresses: list[str]) -> dict[str, Any]: """Bulk identity for up to 200 wallet addresses. Tier 2 ($0.005/call).""" return self.pm_query("polymarket/wallet/identities", {"addresses": addresses}) - def pm_wallet_cluster(self, address: str) -> Dict[str, Any]: + def pm_wallet_cluster(self, address: str) -> dict[str, Any]: """Wallet-cluster discovery (on-chain transfers + identity proofs). Tier 2 ($0.005/call).""" return self.pm(f"polymarket/wallet/{address}/cluster") # ── Exa Web Search (Powered by Exa) ───────────────────────────────────── - def exa(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: + def exa(self, path: str, body: dict[str, Any]) -> dict[str, Any]: """Generic Exa endpoint proxy (POST, Solana payment). Powered by Exa. Args: @@ -2624,7 +2623,7 @@ def exa(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: """ return self._request_with_payment_raw(f"/v1/exa/{path}", body, timeout=self._search_timeout) - def exa_search(self, query: str, **kwargs: Any) -> Dict[str, Any]: + def exa_search(self, query: str, **kwargs: Any) -> dict[str, Any]: """Neural and keyword web search via Exa (Solana payment, $0.01/request). Args: @@ -2639,7 +2638,7 @@ def exa_search(self, query: str, **kwargs: Any) -> Dict[str, Any]: "/v1/exa/search", {"query": query, **kwargs}, timeout=self._search_timeout ) - def exa_find_similar(self, url: str, **kwargs: Any) -> Dict[str, Any]: + def exa_find_similar(self, url: str, **kwargs: Any) -> dict[str, Any]: """Find pages semantically similar to a given URL via Exa (Solana payment, $0.01/request). Args: @@ -2654,7 +2653,7 @@ def exa_find_similar(self, url: str, **kwargs: Any) -> Dict[str, Any]: "/v1/exa/find-similar", {"url": url, **kwargs}, timeout=self._search_timeout ) - def exa_contents(self, urls: List[str], **kwargs: Any) -> Dict[str, Any]: + def exa_contents(self, urls: list[str], **kwargs: Any) -> dict[str, Any]: """Extract full text content from URLs via Exa (Solana payment, $0.002/URL). Args: @@ -2669,7 +2668,7 @@ def exa_contents(self, urls: List[str], **kwargs: Any) -> Dict[str, Any]: "/v1/exa/contents", {"urls": urls, **kwargs}, timeout=self._search_timeout ) - def exa_answer(self, query: str, **kwargs: Any) -> Dict[str, Any]: + def exa_answer(self, query: str, **kwargs: Any) -> dict[str, Any]: """AI-generated answer grounded in live web search via Exa (Solana payment, $0.01/request). Args: @@ -2686,28 +2685,28 @@ def exa_answer(self, query: str, **kwargs: Any) -> Dict[str, Any]: # ── DefiLlama (DeFi protocols / TVL / yields / prices) ────────────────── - def defi(self, path: str, **params: Any) -> Dict[str, Any]: + def defi(self, path: str, **params: Any) -> dict[str, Any]: """Query DefiLlama DeFi data (GET, Solana payment). $0.005/call ($0.001 for prices/{coins}).""" return self._get_with_payment_raw(f"/v1/defillama/{path}", params or None) - def defi_protocols(self) -> Dict[str, Any]: + def defi_protocols(self) -> dict[str, Any]: """All DeFi protocols with TVL ($0.005/call).""" return self.defi("protocols") - def defi_protocol(self, slug: str) -> Dict[str, Any]: + def defi_protocol(self, slug: str) -> dict[str, Any]: """Single protocol details + historical TVL ($0.005/call).""" return self.defi(f"protocol/{slug}") - def defi_chains(self) -> Dict[str, Any]: + def defi_chains(self) -> dict[str, Any]: """Current TVL of every chain ($0.005/call).""" return self.defi("chains") - def defi_yields(self, **params: Any) -> Dict[str, Any]: + def defi_yields(self, **params: Any) -> dict[str, Any]: """Yield pools with APY/TVL ($0.005/call).""" return self.defi("yields", **params) - def defi_prices(self, coins: Union[List[str], str]) -> Dict[str, Any]: + def defi_prices(self, coins: list[str] | str) -> dict[str, Any]: """Token price lookup ($0.001/call).""" joined = ",".join(coins) if isinstance(coins, list) else coins return self.defi(f"prices/{joined}") @@ -2719,68 +2718,68 @@ def dex( path: str, *, method: str = "GET", - body: Optional[Dict[str, Any]] = None, + body: dict[str, Any] | None = None, **params: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Query the 0x Swap / Gasless APIs (free — no x402 payment).""" endpoint = f"/v1/zerox/{path}" if method.upper() == "POST": return self._request_with_payment_raw(endpoint, body or {}) return self._get_with_payment_raw(endpoint, params or None) - def dex_price(self, **params: Any) -> Dict[str, Any]: + def dex_price(self, **params: Any) -> dict[str, Any]: """Indicative Permit2 swap price — no commitment (free).""" return self.dex("price", **params) - def dex_quote(self, **params: Any) -> Dict[str, Any]: + def dex_quote(self, **params: Any) -> dict[str, Any]: """Firm Permit2 swap quote with permit2.eip712 + tx data (free).""" return self.dex("quote", **params) - def dex_gasless_price(self, **params: Any) -> Dict[str, Any]: + def dex_gasless_price(self, **params: Any) -> dict[str, Any]: """Gasless indicative price quote (free).""" return self.dex("gasless/price", **params) - def dex_gasless_quote(self, **params: Any) -> Dict[str, Any]: + def dex_gasless_quote(self, **params: Any) -> dict[str, Any]: """Gasless firm quote — returns trade.eip712 to sign (free).""" return self.dex("gasless/quote", **params) - def dex_gasless_submit(self, body: Dict[str, Any]) -> Dict[str, Any]: + def dex_gasless_submit(self, body: dict[str, Any]) -> dict[str, Any]: """Submit a signed gasless trade; the 0x relayer pays gas (free).""" return self.dex("gasless/submit", method="POST", body=body) - def dex_gasless_status(self, trade_hash: str) -> Dict[str, Any]: + def dex_gasless_status(self, trade_hash: str) -> dict[str, Any]: """Poll a gasless trade's status by tradeHash (free).""" return self.dex(f"gasless/status/{trade_hash}") - def dex_chains(self) -> Dict[str, Any]: + def dex_chains(self) -> dict[str, Any]: """Chains where the Swap API is supported (free).""" return self.dex("swap/chains") - def dex_gasless_chains(self) -> Dict[str, Any]: + def dex_gasless_chains(self) -> dict[str, Any]: """Chains where the Gasless API is supported (free).""" return self.dex("gasless/chains") # ── Modal Sandbox (pay-per-call cloud compute) ─────────────────────────── - def modal(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def modal(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: """Call the Modal sandbox compute API (POST, Solana payment).""" return self._request_with_payment_raw(f"/v1/modal/{path}", body or {}) - def modal_sandbox_create(self, **body: Any) -> Dict[str, Any]: + def modal_sandbox_create(self, **body: Any) -> dict[str, Any]: """Create a sandboxed compute environment ($0.01 CPU / $0.05 GPU).""" return self.modal("sandbox/create", body) def modal_sandbox_exec( - self, sandbox_id: str, command: List[str], **body: Any - ) -> Dict[str, Any]: + self, sandbox_id: str, command: list[str], **body: Any + ) -> dict[str, Any]: """Execute a command in a sandbox; returns stdout/stderr ($0.001).""" return self.modal("sandbox/exec", {"sandbox_id": sandbox_id, "command": command, **body}) - def modal_sandbox_status(self, sandbox_id: str) -> Dict[str, Any]: + def modal_sandbox_status(self, sandbox_id: str) -> dict[str, Any]: """Check a sandbox's status ($0.001).""" return self.modal("sandbox/status", {"sandbox_id": sandbox_id}) - def modal_sandbox_terminate(self, sandbox_id: str) -> Dict[str, Any]: + def modal_sandbox_terminate(self, sandbox_id: str) -> dict[str, Any]: """Terminate a sandbox ($0.001).""" return self.modal("sandbox/terminate", {"sandbox_id": sandbox_id}) @@ -2820,16 +2819,16 @@ class AsyncSolanaLLMClient: def __init__( self, - private_key: Optional[str] = None, + private_key: str | None = None, api_url: str = SOLANA_API_URL, - rpc_url: Optional[str] = None, + rpc_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, image_timeout: float = DEFAULT_IMAGE_TIMEOUT, search_timeout: float = DEFAULT_SEARCH_TIMEOUT, - rpc_headers: Optional[Dict[str, str]] = None, - transaction_log: Union[bool, str, "os.PathLike[str]", None] = None, - max_cost_per_call: Optional[float] = None, - max_session_cost: Optional[float] = None, + rpc_headers: dict[str, str] | None = None, + transaction_log: bool | str | os.PathLike[str] | None = None, + max_cost_per_call: float | None = None, + max_session_cost: float | None = None, ) -> None: """Async mirror of :class:`SolanaLLMClient.__init__`. Same env-var fallback for ``rpc_url`` / ``rpc_headers`` — see @@ -2875,18 +2874,18 @@ def __init__( self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST") self._session_calls = 0 self._last_call_cost: float = 0.0 - self._address: Optional[str] = None + self._address: str | None = None log_dir = _resolve_log_dir(transaction_log) - self._tx_logger: Optional[TransactionLogger] = ( + self._tx_logger: TransactionLogger | None = ( TransactionLogger(log_dir) if log_dir is not None else None ) - self._last_settlement: Optional[Dict[str, Any]] = None + self._last_settlement: dict[str, Any] | None = None # Response headers from the most recent raw paid POST — consumed by # rpc()/music()/speech() to surface the settlement receipt + gateway # metadata the shared JSON-only helper would otherwise drop. Read it # immediately after the helper returns (no intervening await). - self._last_raw_headers: Optional[httpx.Headers] = None + self._last_raw_headers: httpx.Headers | None = None # Async x402 client + same SVM signer the sync class uses. from x402 import x402Client # local import to keep optional dep clean @@ -2905,7 +2904,7 @@ def __init__( # Lazily created on first sign (avoids binding asyncio.Lock to a loop at # construction time). Serializes the async signing critical section so a # shared client is safe across concurrent coroutines — see _sign_payment. - self._payment_lock: Optional[asyncio.Lock] = None + self._payment_lock: asyncio.Lock | None = None async def _sign_payment(self, payment_required: Any) -> Any: """Task-safe async wrapper around ``x402_client.create_payment_payload``. @@ -2919,7 +2918,7 @@ async def _sign_payment(self, payment_required: Any) -> Any: async with self._payment_lock: return await self._x402_client.create_payment_payload(payment_required) - def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, Any]]: + def _capture_settlement(self, response: httpx.Response) -> dict[str, Any] | None: """Async-Solana twin of :meth:`SolanaLLMClient._capture_settlement`.""" header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) @@ -2937,7 +2936,7 @@ def _attach_receipt(self, data: Any) -> None: def _log_transaction( self, endpoint: str, - body: Dict[str, Any], + body: dict[str, Any], response: Any, cost_usd: float, ) -> None: @@ -2969,10 +2968,10 @@ def _log_transaction( async def close(self) -> None: await self._client.aclose() - async def __aenter__(self) -> "AsyncSolanaLLMClient": + async def __aenter__(self) -> Self: return self - async def __aexit__(self, *_exc: Any) -> None: + async def __aexit__(self, *_exc: object) -> None: await self.close() # ------------------------------------------------------------------ @@ -2987,10 +2986,10 @@ def get_wallet_address(self) -> str: def is_solana(self) -> bool: return "sol.blockrun.ai" in self._api_url - def get_spending(self) -> Dict[str, Any]: + def get_spending(self) -> dict[str, Any]: return {"total_usd": self._session_total_usd, "calls": self._session_calls} - def _billing_meta(self) -> Dict[str, Optional[str]]: + def _billing_meta(self) -> dict[str, str | None]: return { "wallet": self.get_wallet_address(), "network": "solana-mainnet" if self.is_solana() else "solana-other", @@ -3005,15 +3004,15 @@ async def chat( self, model: str, prompt: str, - system: Optional[str] = None, + system: str | None = None, max_tokens: int = DEFAULT_MAX_TOKENS, - temperature: Optional[float] = None, + temperature: float | None = None, search: bool = False, - timeout: Optional[float] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, + timeout: float | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, ) -> str: - messages: List[Dict[str, str]] = [] + messages: list[dict[str, str]] = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) @@ -3032,20 +3031,20 @@ async def chat( async def chat_completion( self, model: str, - messages: List[Dict[str, Any]], + messages: list[dict[str, Any]], max_tokens: int = DEFAULT_MAX_TOKENS, - temperature: Optional[float] = None, - top_p: Optional[float] = None, + temperature: float | None = None, + top_p: float | None = None, search: bool = False, - search_parameters: Optional[Dict[str, Any]] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[Any] = None, - timeout: Optional[float] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, + search_parameters: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + timeout: float | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, ) -> ChatResponse: validate_max_tokens(max_tokens) - body: Dict[str, Any] = {"model": model, "messages": messages, "max_tokens": max_tokens} + body: dict[str, Any] = {"model": model, "messages": messages, "max_tokens": max_tokens} if temperature is not None: body["temperature"] = temperature if top_p is not None: @@ -3064,7 +3063,7 @@ async def chat_completion( body["stop"] = stop return await self._request_with_payment("/v1/chat/completions", body, timeout=timeout) - async def list_models(self) -> List[Dict[str, Any]]: + async def list_models(self) -> list[dict[str, Any]]: resp = await self._client.get(f"{self._api_url}/v1/models") resp.raise_for_status() return resp.json().get("data", []) @@ -3076,25 +3075,25 @@ async def list_models(self) -> List[Dict[str, Any]]: async def chat_completion_stream( self, model: str, - messages: List[Dict[str, Any]], + messages: list[dict[str, Any]], *, max_tokens: int = DEFAULT_MAX_TOKENS, - temperature: Optional[float] = None, - top_p: Optional[float] = None, + temperature: float | None = None, + top_p: float | None = None, search: bool = False, - search_parameters: Optional[Dict[str, Any]] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[Any] = None, - response_format: Optional[Dict[str, Any]] = None, - stop: Optional[Union[str, List[str]]] = None, - fallback_models: Optional[List[str]] = None, - timeout: Optional[float] = None, - ) -> "AsyncSolanaIterator": + search_parameters: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, + timeout: float | None = None, + ) -> AsyncSolanaIterator: """Async streaming. Same protocol semantics as the sync :meth:`SolanaLLMClient.chat_completion_stream`; only the iteration protocol differs (``async for``).""" validate_max_tokens(max_tokens) - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "messages": messages, "stream": True, @@ -3118,7 +3117,7 @@ async def chat_completion_stream( body["stop"] = stop attempts = [model, *(fallback_models or [])] - last_exc: Optional[Exception] = None + last_exc: Exception | None = None for i, attempt_model in enumerate(attempts): body["model"] = attempt_model @@ -3147,8 +3146,8 @@ async def chat_completion_stream( async def _stream_with_payment( self, endpoint: str, - body: Dict[str, Any], - timeout: Optional[float] = None, + body: dict[str, Any], + timeout: float | None = None, ): """Whole-request payment-retry wrapper around :meth:`_stream_once` (async). Re-runs the paid request on a recoverable payment rejection, @@ -3176,8 +3175,8 @@ async def _stream_with_payment( async def _stream_once( self, endpoint: str, - body: Dict[str, Any], - timeout: Optional[float] = None, + body: dict[str, Any], + timeout: float | None = None, ): """Async version of :meth:`SolanaLLMClient._stream_once`.""" url = f"{self._api_url}{endpoint}" @@ -3186,7 +3185,7 @@ async def _stream_once( backoffs = self._STREAM_5XX_BACKOFFS # ----- Phase 1: probe (no payment header) ----- - payment_headers: Optional[Dict[str, str]] = None + payment_headers: dict[str, str] | None = None cost_usd = 0.0 for attempt in range(len(backoffs) + 1): @@ -3263,16 +3262,16 @@ async def _aiter_sse_chunks(response: httpx.Response): async def _aiter_and_archive( self, response: httpx.Response, - body: Dict[str, Any], + body: dict[str, Any], cost_usd: float, ): """Async version of :meth:`SolanaLLMClient._iter_and_archive`.""" - assembled_id: Optional[str] = None - assembled_model: Optional[str] = None + assembled_id: str | None = None + assembled_model: str | None = None assembled_created: int = 0 - content_parts: List[str] = [] - finish_reason: Optional[str] = None - usage_dict: Optional[Dict[str, Any]] = None + content_parts: list[str] = [] + finish_reason: str | None = None + usage_dict: dict[str, Any] | None = None async for chunk in self._aiter_sse_chunks(response): if chunk.choices: @@ -3299,7 +3298,7 @@ async def _aiter_and_archive( if cost_usd > 0: from .cache import save_to_cache - response_data: Dict[str, Any] = { + response_data: dict[str, Any] = { "id": assembled_id or "stream", "object": "chat.completion", "created": assembled_created or int(__import__("time").time()), @@ -3337,7 +3336,7 @@ async def _aiter_and_archive( async def _sign_payment_from_response( self, response: httpx.Response, - ) -> Tuple[Dict[str, str], float]: + ) -> tuple[dict[str, str], float]: payment_header = SolanaLLMClient._extract_payment_header(response) if not payment_header: raise PaymentError("402 response but no payment requirements found") @@ -3360,7 +3359,7 @@ async def _sign_payment_from_response( _raise_stream_error = SolanaLLMClient._raise_stream_error async def _request_with_payment( - self, endpoint: str, body: Dict[str, Any], timeout: Optional[float] = None + self, endpoint: str, body: dict[str, Any], timeout: float | None = None ) -> ChatResponse: """Whole-request payment-retry wrapper around :meth:`_request_once` (async). Same policy as the sync path — recoverable payment rejections @@ -3381,7 +3380,7 @@ async def _request_with_payment( ) async def _request_once( - self, endpoint: str, body: Dict[str, Any], timeout: Optional[float] = None + self, endpoint: str, body: dict[str, Any], timeout: float | None = None ) -> ChatResponse: url = f"{self._api_url}{endpoint}" headers = {"Content-Type": "application/json", "User-Agent": _get_user_agent()} @@ -3420,9 +3419,9 @@ async def _request_once( async def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, - timeout: Optional[float] = None, + timeout: float | None = None, ) -> ChatResponse: eff_timeout = timeout if timeout is not None else self._timeout payment_headers, cost_usd = await self._sign_payment_from_response(response) @@ -3472,8 +3471,8 @@ async def _handle_payment_and_retry( # ── Raw passthrough request helpers (async, Solana payment) ───────────── async def _request_with_payment_raw( - self, endpoint: str, body: Dict[str, Any], timeout: Optional[float] = None - ) -> Dict[str, Any]: + self, endpoint: str, body: dict[str, Any], timeout: float | None = None + ) -> dict[str, Any]: """POST with Solana x402 payment, returning raw JSON (async mirror of the sync :class:`SolanaLLMClient` helper).""" from .cache import get_cached, save_to_cache @@ -3542,9 +3541,9 @@ async def _request_with_payment_raw( async def _get_with_payment_raw( self, endpoint: str, - params: Optional[Dict[str, Any]] = None, - timeout: Optional[float] = None, - ) -> Dict[str, Any]: + params: dict[str, Any] | None = None, + timeout: float | None = None, + ) -> dict[str, Any]: """GET with Solana x402 payment, returning raw JSON (async).""" from .cache import get_cached, save_to_cache @@ -3615,18 +3614,18 @@ async def search( self, query: str, *, - sources: Optional[List[str]] = None, + sources: list[str] | None = None, max_results: int = 10, - from_date: Optional[str] = None, - to_date: Optional[str] = None, - timeout: Optional[float] = None, + from_date: str | None = None, + to_date: str | None = None, + timeout: float | None = None, ) -> SearchResult: """Standalone search (Solana payment). ``timeout`` overrides the per-call HTTP timeout (defaults to ``DEFAULT_SEARCH_TIMEOUT`` — deep web/X tool-use can run minutes). """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "query": query, "max_results": max_results, } @@ -3664,8 +3663,8 @@ async def image( model: str = "google/nano-banana", size: str = "1024x1024", n: int = 1, - quality: Optional[str] = None, - timeout: Optional[float] = None, + quality: str | None = None, + timeout: float | None = None, ) -> ImageResponse: """Generate an image from a text prompt (Solana payment). @@ -3682,7 +3681,7 @@ async def image( Raises: ValueError: If ``quality`` is not one of the four accepted values. """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "prompt": prompt, "size": size, @@ -3699,14 +3698,14 @@ async def image( async def image_edit( self, prompt: str, - image: Union[str, List[str]], + image: str | list[str], *, model: str = "openai/gpt-image-2", - mask: Optional[str] = None, + mask: str | None = None, size: str = "1024x1024", n: int = 1, - quality: Optional[str] = None, - timeout: Optional[float] = None, + quality: str | None = None, + timeout: float | None = None, ) -> ImageResponse: """Edit an image using img2img (Solana payment). ``image`` may be a single data URI or a list of 1-4 data URIs for multi-image fusion @@ -3720,7 +3719,7 @@ async def image_edit( Raises: ValueError: If ``quality`` is not one of the four accepted values. """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model, "prompt": prompt, "image": image, @@ -3741,8 +3740,8 @@ async def image_edit( def _absolute_url(self, url: str) -> str: """Resolve a server-supplied relative ``poll_url`` against the API host (``api_url`` already includes the trailing ``/api`` — strip it once).""" - base = self._api_url[: -len("/api")] if self._api_url.endswith("/api") else self._api_url - if url.startswith("http://") or url.startswith("https://"): + base = self._api_url.removesuffix("/api") + if url.startswith(("http://", "https://")): # The poll loop sends (and re-signs) the wallet's PAYMENT-SIGNATURE # against this URL, so an absolute poll_url is pinned to the API # host+scheme — a gateway response pointing it elsewhere would leak @@ -3765,21 +3764,21 @@ async def video( self, prompt: str, *, - model: Optional[str] = None, - image_url: Optional[str] = None, - last_frame_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, - real_face_asset_id: Optional[str] = None, - duration_seconds: Optional[int] = None, - aspect_ratio: Optional[str] = None, - resolution: Optional[str] = None, - generate_audio: Optional[bool] = None, - seed: Optional[int] = None, - watermark: Optional[bool] = None, - return_last_frame: Optional[bool] = None, - input_type: Optional[str] = None, - budget_seconds: Optional[float] = None, - timeout: Optional[float] = None, + model: str | None = None, + image_url: str | None = None, + last_frame_url: str | None = None, + reference_image_urls: list[str] | None = None, + real_face_asset_id: str | None = None, + duration_seconds: int | None = None, + aspect_ratio: str | None = None, + resolution: str | None = None, + generate_audio: bool | None = None, + seed: int | None = None, + watermark: bool | None = None, + return_last_frame: bool | None = None, + input_type: str | None = None, + budget_seconds: float | None = None, + timeout: float | None = None, ) -> VideoResponse: """Generate a video clip (Solana payment). Async mirror of :meth:`SolanaLLMClient.video`.""" @@ -3817,17 +3816,17 @@ async def video( async def video_from_content( self, - content: List[Dict[str, Any]], + content: list[dict[str, Any]], *, - model: Optional[str] = None, - budget_seconds: Optional[float] = None, - timeout: Optional[float] = None, + model: str | None = None, + budget_seconds: float | None = None, + timeout: float | None = None, **options: Any, ) -> VideoResponse: """Generate a video from a Seedance ``content[]`` body (Solana payment).""" if not content: raise ValueError("content must be a non-empty list of Seedance content items.") - body: Dict[str, Any] = {"content": content, **options} + body: dict[str, Any] = {"content": content, **options} if model is not None: body["model"] = model data = await self._request_image_with_payment( @@ -3849,15 +3848,15 @@ async def music( self, prompt: str, *, - model: Optional[str] = None, + model: str | None = None, instrumental: bool = True, - lyrics: Optional[str] = None, - timeout: Optional[float] = None, + lyrics: str | None = None, + timeout: float | None = None, ) -> MusicResponse: """Generate a music track (Solana payment).""" if instrumental and lyrics and lyrics.strip(): raise ValueError("Cannot specify lyrics when instrumental is True") - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or SolanaLLMClient.MUSIC_DEFAULT_MODEL, "prompt": prompt, "instrumental": instrumental, @@ -3872,14 +3871,14 @@ async def speech( self, input: str, *, - model: Optional[str] = None, - voice: Optional[str] = None, - response_format: Optional[str] = None, - speed: Optional[float] = None, - timeout: Optional[float] = None, + model: str | None = None, + voice: str | None = None, + response_format: str | None = None, + speed: float | None = None, + timeout: float | None = None, ) -> SpeechResponse: """Synthesize speech from text (Solana payment).""" - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or SolanaLLMClient.SPEECH_DEFAULT_MODEL, "input": input, } @@ -3897,14 +3896,14 @@ async def sound_effect( self, text: str, *, - model: Optional[str] = None, - duration_seconds: Optional[float] = None, - prompt_influence: Optional[float] = None, - response_format: Optional[str] = None, - timeout: Optional[float] = None, + model: str | None = None, + duration_seconds: float | None = None, + prompt_influence: float | None = None, + response_format: str | None = None, + timeout: float | None = None, ) -> SpeechResponse: """Generate a cinematic sound effect (Solana payment).""" - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or SolanaLLMClient.SOUNDFX_DEFAULT_MODEL, "text": text, } @@ -3920,7 +3919,7 @@ async def sound_effect( self._attach_receipt(data) return SpeechResponse(**data) - async def list_voices(self) -> List[Dict[str, Any]]: + async def list_voices(self) -> list[dict[str, Any]]: """List available speech voices (free).""" url = f"{self._api_url}/v1/audio/voices" resp = await self._client.get( @@ -3953,7 +3952,7 @@ async def portrait_enroll(self, name: str, image_url: str) -> PortraitEnrollment ) return PortraitEnrollment(**data) - async def list_portraits(self, wallet_address: Optional[str] = None) -> PortraitList: + async def list_portraits(self, wallet_address: str | None = None) -> PortraitList: """List Virtual Portraits enrolled by a wallet (free, rate-limited).""" addr = _safe_path_segment(wallet_address or self.get_wallet_address(), "wallet_address") url = f"{self._api_url}/v1/wallet/{addr}/portraits" @@ -3970,7 +3969,7 @@ async def list_portraits(self, wallet_address: Optional[str] = None) -> Portrait ) return PortraitList(**resp.json()) - async def realface_init(self, name: str, group_id: Optional[str] = None) -> RealFaceInit: + async def realface_init(self, name: str, group_id: str | None = None) -> RealFaceInit: """Start/refresh a RealFace enrollment (free, rate-limited).""" if not name or not name.strip(): raise ValueError("name is required (1-64 chars)") @@ -3978,7 +3977,7 @@ async def realface_init(self, name: str, group_id: Optional[str] = None) -> Real raise ValueError(f"name must be 64 chars or fewer (got {len(name)})") if group_id is not None and not _GROUP_ID_RE.match(group_id): raise ValueError("group_id must look like 'legacy_rf_'") - body: Dict[str, Any] = {"name": name} + body: dict[str, Any] = {"name": name} if group_id: body["groupId"] = group_id url = f"{self._api_url}/v1/realface/init" @@ -4060,7 +4059,7 @@ async def realface_enroll(self, name: str, image_url: str, group_id: str) -> Rea ) return RealFaceEnrollment(**data) - async def list_realfaces(self, wallet_address: Optional[str] = None) -> RealFaceList: + async def list_realfaces(self, wallet_address: str | None = None) -> RealFaceList: """List RealFace assets enrolled by a wallet (free, rate-limited).""" addr = _safe_path_segment(wallet_address or self.get_wallet_address(), "wallet_address") url = f"{self._api_url}/v1/wallet/{addr}/realfaces" @@ -4082,12 +4081,12 @@ async def price( category: Category, symbol: str, *, - market: Optional[Market] = None, - session: Optional[Session] = None, + market: Market | None = None, + session: Session | None = None, ) -> PricePoint: """Fetch a realtime Pyth price quote (Solana payment for paid categories).""" endpoint = SolanaLLMClient._price_category_path(category, market, "price", symbol) - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if session is not None: params["session"] = session data = await self._get_with_payment_raw( @@ -4114,12 +4113,12 @@ async def price_history( resolution: Resolution = "D", from_ts: int, to_ts: int, - market: Optional[Market] = None, - session: Optional[Session] = None, + market: Market | None = None, + session: Session | None = None, ) -> PriceHistoryResponse: """Fetch OHLC bars between two Unix timestamps (seconds).""" endpoint = SolanaLLMClient._price_category_path(category, market, "history", symbol) - params: Dict[str, Any] = {"resolution": resolution, "from": from_ts, "to": to_ts} + params: dict[str, Any] = {"resolution": resolution, "from": from_ts, "to": to_ts} if session is not None: params["session"] = session data = await self._get_with_payment_raw( @@ -4136,13 +4135,13 @@ async def list_symbols( self, category: Category, *, - q: Optional[str] = None, + q: str | None = None, limit: int = 100, - market: Optional[Market] = None, + market: Market | None = None, ) -> SymbolListResponse: """List available symbols in a Pyth category (free discovery).""" endpoint = SolanaLLMClient._price_category_path(category, market, "list", None) - params: Dict[str, Any] = {"limit": limit} + params: dict[str, Any] = {"limit": limit} if q: params["q"] = q data = await self._get_with_payment_raw( @@ -4160,24 +4159,24 @@ async def rpc( self, network: str, method: str, - params: Optional[List[Any]] = None, + params: list[Any] | None = None, *, - id: Union[str, int] = 1, + id: str | int = 1, ) -> RpcResponse: """Make a single JSON-RPC 2.0 call (Solana payment, flat $0.002).""" _safe_path_segment(network, "network") - body: Dict[str, Any] = {"jsonrpc": "2.0", "id": id, "method": method} + body: dict[str, Any] = {"jsonrpc": "2.0", "id": id, "method": method} if params is not None: body["params"] = params data = await self._request_with_payment_raw(f"/v1/rpc/{network}", body) return SolanaLLMClient._rpc_response(data, self._last_raw_headers, network) - async def rpc_batch(self, network: str, requests: List[Dict[str, Any]]) -> List[RpcResponse]: + async def rpc_batch(self, network: str, requests: list[dict[str, Any]]) -> list[RpcResponse]: """Make a JSON-RPC 2.0 batch call (Solana payment, $0.002 x N).""" if not requests: raise ValueError("batch requires at least one request") _safe_path_segment(network, "network") - body: List[Dict[str, Any]] = [] + body: list[dict[str, Any]] = [] for i, req in enumerate(requests): if "method" not in req: raise ValueError(f"batch request {i} is missing 'method'") @@ -4191,14 +4190,14 @@ async def rpc_batch(self, network: str, requests: List[Dict[str, Any]]) -> List[ async def _request_image_with_payment( self, endpoint: str, - body: Dict[str, Any], - timeout: Optional[float] = None, + body: dict[str, Any], + timeout: float | None = None, *, - poll_budget_seconds: Optional[float] = None, - poll_interval_seconds: Optional[float] = None, + poll_budget_seconds: float | None = None, + poll_interval_seconds: float | None = None, max_resigns: int = 0, label: str = "Image", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Async sign + submit + poll wrapper for async media generation — the async mirror of the sync :class:`SolanaLLMClient` helper. Shared by :meth:`image` and :meth:`video` (``max_resigns`` re-signs to survive @@ -4441,85 +4440,85 @@ async def _request_image_with_payment( # ── Prediction Markets (Powered by Predexon) ──────────────────────────── - async def pm(self, path: str, **params: Any) -> Dict[str, Any]: + async def pm(self, path: str, **params: Any) -> dict[str, Any]: """Query Predexon prediction market data (GET, Solana payment). Powered by Predexon.""" return await self._get_with_payment_raw(f"/v1/pm/{path}", params or None) - async def pm_query(self, path: str, query: Dict[str, Any]) -> Dict[str, Any]: + async def pm_query(self, path: str, query: dict[str, Any]) -> dict[str, Any]: """Structured query for Predexon data (POST, Solana payment). Powered by Predexon.""" return await self._request_with_payment_raw(f"/v1/pm/{path}", query) - async def pm_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_markets(self, **params: Any) -> dict[str, Any]: """List canonical cross-venue markets (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm("markets", **params) - async def pm_listings(self, **params: Any) -> Dict[str, Any]: + async def pm_listings(self, **params: Any) -> dict[str, Any]: """List venue-native executable listings (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm("markets/listings", **params) - async def pm_outcome(self, predexon_id: str) -> Dict[str, Any]: + async def pm_outcome(self, predexon_id: str) -> dict[str, Any]: """Resolve a canonical Predexon outcome ID (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm(f"outcomes/{predexon_id}") - async def pm_polymarket_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_markets(self, **params: Any) -> dict[str, Any]: """List Polymarket markets (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm("polymarket/markets", **params) - async def pm_polymarket_events(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_events(self, **params: Any) -> dict[str, Any]: """List Polymarket events (Predexon v2). Tier 1 ($0.001/call).""" return await self.pm("polymarket/events", **params) - async def pm_polymarket_markets_keyset(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_markets_keyset(self, **params: Any) -> dict[str, Any]: """Polymarket markets with cursor-based keyset pagination. Tier 1 ($0.001/call).""" return await self.pm("polymarket/markets/keyset", **params) - async def pm_polymarket_events_keyset(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_events_keyset(self, **params: Any) -> dict[str, Any]: """Polymarket events with cursor-based keyset pagination. Tier 1 ($0.001/call).""" return await self.pm("polymarket/events/keyset", **params) - async def pm_polymarket_positions(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_positions(self, **params: Any) -> dict[str, Any]: """Polymarket open positions (per-wallet, market-level PnL). Tier 1 ($0.001/call).""" return await self.pm("polymarket/positions", **params) - async def pm_polymarket_trades(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_trades(self, **params: Any) -> dict[str, Any]: """Recent Polymarket trades. Tier 1 ($0.001/call).""" return await self.pm("polymarket/trades", **params) - async def pm_polymarket_leaderboard(self, **params: Any) -> Dict[str, Any]: + async def pm_polymarket_leaderboard(self, **params: Any) -> dict[str, Any]: """Polymarket trader leaderboard. Tier 1 ($0.001/call).""" return await self.pm("polymarket/leaderboard", **params) - async def pm_kalshi_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_kalshi_markets(self, **params: Any) -> dict[str, Any]: """List Kalshi markets. Tier 1 ($0.001/call).""" return await self.pm("kalshi/markets", **params) - async def pm_limitless_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_limitless_markets(self, **params: Any) -> dict[str, Any]: """List Limitless markets. Tier 1 ($0.001/call).""" return await self.pm("limitless/markets", **params) - async def pm_sports_categories(self) -> Dict[str, Any]: + async def pm_sports_categories(self) -> dict[str, Any]: """List available sports categories. Tier 1 ($0.001/call).""" return await self.pm("sports/categories") - async def pm_sports_markets(self, **params: Any) -> Dict[str, Any]: + async def pm_sports_markets(self, **params: Any) -> dict[str, Any]: """List sports markets grouped by game. Tier 1 ($0.001/call).""" return await self.pm("sports/markets", **params) - async def pm_wallet_identity(self, wallet: str) -> Dict[str, Any]: + async def pm_wallet_identity(self, wallet: str) -> dict[str, Any]: """Identity + profile for one wallet. Tier 2 ($0.005/call).""" return await self.pm(f"polymarket/wallet/identity/{wallet}") - async def pm_wallet_identities(self, addresses: List[str]) -> Dict[str, Any]: + async def pm_wallet_identities(self, addresses: list[str]) -> dict[str, Any]: """Bulk identity for up to 200 wallet addresses. Tier 2 ($0.005/call).""" return await self.pm_query("polymarket/wallet/identities", {"addresses": addresses}) - async def pm_wallet_cluster(self, address: str) -> Dict[str, Any]: + async def pm_wallet_cluster(self, address: str) -> dict[str, Any]: """Wallet-cluster discovery (on-chain transfers + identity proofs). Tier 2 ($0.005/call).""" return await self.pm(f"polymarket/wallet/{address}/cluster") # ── Exa Web Search (Powered by Exa) ───────────────────────────────────── - async def exa(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: + async def exa(self, path: str, body: dict[str, Any]) -> dict[str, Any]: """Generic Exa endpoint proxy (POST, Solana payment). Powered by Exa. Args: @@ -4530,25 +4529,25 @@ async def exa(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: f"/v1/exa/{path}", body, timeout=self._search_timeout ) - async def exa_search(self, query: str, **kwargs: Any) -> Dict[str, Any]: + async def exa_search(self, query: str, **kwargs: Any) -> dict[str, Any]: """Neural and keyword web search via Exa (Solana payment, $0.01/request).""" return await self._request_with_payment_raw( "/v1/exa/search", {"query": query, **kwargs}, timeout=self._search_timeout ) - async def exa_find_similar(self, url: str, **kwargs: Any) -> Dict[str, Any]: + async def exa_find_similar(self, url: str, **kwargs: Any) -> dict[str, Any]: """Find pages semantically similar to a given URL via Exa (Solana payment, $0.01/request).""" return await self._request_with_payment_raw( "/v1/exa/find-similar", {"url": url, **kwargs}, timeout=self._search_timeout ) - async def exa_contents(self, urls: List[str], **kwargs: Any) -> Dict[str, Any]: + async def exa_contents(self, urls: list[str], **kwargs: Any) -> dict[str, Any]: """Extract full text content from URLs via Exa (Solana payment, $0.002/URL).""" return await self._request_with_payment_raw( "/v1/exa/contents", {"urls": urls, **kwargs}, timeout=self._search_timeout ) - async def exa_answer(self, query: str, **kwargs: Any) -> Dict[str, Any]: + async def exa_answer(self, query: str, **kwargs: Any) -> dict[str, Any]: """AI-generated answer grounded in live web search via Exa (Solana payment, $0.01/request).""" return await self._request_with_payment_raw( "/v1/exa/answer", {"query": query, **kwargs}, timeout=self._search_timeout @@ -4556,28 +4555,28 @@ async def exa_answer(self, query: str, **kwargs: Any) -> Dict[str, Any]: # ── DefiLlama (DeFi protocols / TVL / yields / prices) ────────────────── - async def defi(self, path: str, **params: Any) -> Dict[str, Any]: + async def defi(self, path: str, **params: Any) -> dict[str, Any]: """Query DefiLlama DeFi data (GET, Solana payment). $0.005/call ($0.001 for prices/{coins}).""" return await self._get_with_payment_raw(f"/v1/defillama/{path}", params or None) - async def defi_protocols(self) -> Dict[str, Any]: + async def defi_protocols(self) -> dict[str, Any]: """All DeFi protocols with TVL ($0.005/call).""" return await self.defi("protocols") - async def defi_protocol(self, slug: str) -> Dict[str, Any]: + async def defi_protocol(self, slug: str) -> dict[str, Any]: """Single protocol details + historical TVL ($0.005/call).""" return await self.defi(f"protocol/{slug}") - async def defi_chains(self) -> Dict[str, Any]: + async def defi_chains(self) -> dict[str, Any]: """Current TVL of every chain ($0.005/call).""" return await self.defi("chains") - async def defi_yields(self, **params: Any) -> Dict[str, Any]: + async def defi_yields(self, **params: Any) -> dict[str, Any]: """Yield pools with APY/TVL ($0.005/call).""" return await self.defi("yields", **params) - async def defi_prices(self, coins: Union[List[str], str]) -> Dict[str, Any]: + async def defi_prices(self, coins: list[str] | str) -> dict[str, Any]: """Token price lookup ($0.001/call).""" joined = ",".join(coins) if isinstance(coins, list) else coins return await self.defi(f"prices/{joined}") @@ -4589,70 +4588,70 @@ async def dex( path: str, *, method: str = "GET", - body: Optional[Dict[str, Any]] = None, + body: dict[str, Any] | None = None, **params: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Query the 0x Swap / Gasless APIs (free — no x402 payment).""" endpoint = f"/v1/zerox/{path}" if method.upper() == "POST": return await self._request_with_payment_raw(endpoint, body or {}) return await self._get_with_payment_raw(endpoint, params or None) - async def dex_price(self, **params: Any) -> Dict[str, Any]: + async def dex_price(self, **params: Any) -> dict[str, Any]: """Indicative Permit2 swap price — no commitment (free).""" return await self.dex("price", **params) - async def dex_quote(self, **params: Any) -> Dict[str, Any]: + async def dex_quote(self, **params: Any) -> dict[str, Any]: """Firm Permit2 swap quote with permit2.eip712 + tx data (free).""" return await self.dex("quote", **params) - async def dex_gasless_price(self, **params: Any) -> Dict[str, Any]: + async def dex_gasless_price(self, **params: Any) -> dict[str, Any]: """Gasless indicative price quote (free).""" return await self.dex("gasless/price", **params) - async def dex_gasless_quote(self, **params: Any) -> Dict[str, Any]: + async def dex_gasless_quote(self, **params: Any) -> dict[str, Any]: """Gasless firm quote — returns trade.eip712 to sign (free).""" return await self.dex("gasless/quote", **params) - async def dex_gasless_submit(self, body: Dict[str, Any]) -> Dict[str, Any]: + async def dex_gasless_submit(self, body: dict[str, Any]) -> dict[str, Any]: """Submit a signed gasless trade; the 0x relayer pays gas (free).""" return await self.dex("gasless/submit", method="POST", body=body) - async def dex_gasless_status(self, trade_hash: str) -> Dict[str, Any]: + async def dex_gasless_status(self, trade_hash: str) -> dict[str, Any]: """Poll a gasless trade's status by tradeHash (free).""" return await self.dex(f"gasless/status/{trade_hash}") - async def dex_chains(self) -> Dict[str, Any]: + async def dex_chains(self) -> dict[str, Any]: """Chains where the Swap API is supported (free).""" return await self.dex("swap/chains") - async def dex_gasless_chains(self) -> Dict[str, Any]: + async def dex_gasless_chains(self) -> dict[str, Any]: """Chains where the Gasless API is supported (free).""" return await self.dex("gasless/chains") # ── Modal Sandbox (pay-per-call cloud compute) ─────────────────────────── - async def modal(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + async def modal(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: """Call the Modal sandbox compute API (POST, Solana payment).""" return await self._request_with_payment_raw(f"/v1/modal/{path}", body or {}) - async def modal_sandbox_create(self, **body: Any) -> Dict[str, Any]: + async def modal_sandbox_create(self, **body: Any) -> dict[str, Any]: """Create a sandboxed compute environment ($0.01 CPU / $0.05 GPU).""" return await self.modal("sandbox/create", body) async def modal_sandbox_exec( - self, sandbox_id: str, command: List[str], **body: Any - ) -> Dict[str, Any]: + self, sandbox_id: str, command: list[str], **body: Any + ) -> dict[str, Any]: """Execute a command in a sandbox; returns stdout/stderr ($0.001).""" return await self.modal( "sandbox/exec", {"sandbox_id": sandbox_id, "command": command, **body} ) - async def modal_sandbox_status(self, sandbox_id: str) -> Dict[str, Any]: + async def modal_sandbox_status(self, sandbox_id: str) -> dict[str, Any]: """Check a sandbox's status ($0.001).""" return await self.modal("sandbox/status", {"sandbox_id": sandbox_id}) - async def modal_sandbox_terminate(self, sandbox_id: str) -> Dict[str, Any]: + async def modal_sandbox_terminate(self, sandbox_id: str) -> dict[str, Any]: """Terminate a sandbox ($0.001).""" return await self.modal("sandbox/terminate", {"sandbox_id": sandbox_id}) diff --git a/blockrun_llm/solana_wallet.py b/blockrun_llm/solana_wallet.py index 17d5cff..44fedbf 100644 --- a/blockrun_llm/solana_wallet.py +++ b/blockrun_llm/solana_wallet.py @@ -11,7 +11,7 @@ import os import time from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING if TYPE_CHECKING: from .solana_client import SolanaLLMClient @@ -30,7 +30,7 @@ def _require_solders() -> None: ) -def create_solana_wallet() -> Dict[str, str]: +def create_solana_wallet() -> dict[str, str]: """ Create a new Solana wallet. @@ -144,7 +144,7 @@ def _expand_solana_seed(private_key: str) -> str: return private_key -def scan_solana_wallets() -> List[Dict[str, str]]: +def scan_solana_wallets() -> list[dict[str, str]]: """ Discover ~/./solana-wallet.json files from other providers. @@ -159,7 +159,7 @@ def scan_solana_wallets() -> List[Dict[str, str]]: list_discovered_solana_wallets() for an address derived from the key. """ home = Path.home() - results: List[tuple] = [] # (mtime, private_key, address, source) + results: list[tuple] = [] # (mtime, private_key, address, source) try: for entry in home.iterdir(): @@ -190,7 +190,7 @@ def scan_solana_wallets() -> List[Dict[str, str]]: return [{"private_key": pk, "address": addr, "source": src} for _, pk, addr, src in results] -def list_discovered_solana_wallets() -> List[Dict[str, str]]: +def list_discovered_solana_wallets() -> list[dict[str, str]]: """ List Solana wallets from other applications, safe to show to a user. @@ -257,7 +257,7 @@ def import_solana_wallet(address: str) -> str: ) -def load_solana_wallet() -> Optional[str]: +def load_solana_wallet() -> str | None: """ Load Solana wallet private key. @@ -275,7 +275,7 @@ def load_solana_wallet() -> Optional[str]: return None -def get_or_create_solana_wallet() -> Dict[str, object]: +def get_or_create_solana_wallet() -> dict[str, object]: """ Get existing Solana wallet or create new one. @@ -309,7 +309,7 @@ def get_or_create_solana_wallet() -> Dict[str, object]: return {**wallet, "is_new": True} -def format_solana_wallet_migration_notice(new_address: str) -> Optional[str]: +def format_solana_wallet_migration_notice(new_address: str) -> str | None: """ Warn when a new Solana wallet was created while provider wallets exist. @@ -363,7 +363,7 @@ def format_solana_wallet_migration_notice(new_address: str) -> Optional[str]: """ -def setup_agent_solana_wallet(silent: bool = False) -> "SolanaLLMClient": +def setup_agent_solana_wallet(silent: bool = False) -> SolanaLLMClient: """ Set up Solana wallet for agent use and return a SolanaLLMClient. @@ -402,7 +402,7 @@ def setup_agent_solana_wallet(silent: bool = False) -> "SolanaLLMClient": return SolanaLLMClient(private_key=result["private_key"]) -def get_solana_usdc_balance(address: str, rpc_url: Optional[str] = None) -> float: +def get_solana_usdc_balance(address: str, rpc_url: str | None = None) -> float: """ Get USDC-SPL balance for a Solana address. @@ -484,9 +484,10 @@ def generate_solana_qr_ascii(address: str) -> str: # Generate new QR try: - import qrcode from io import StringIO + import qrcode + qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, @@ -513,7 +514,7 @@ def generate_solana_qr_ascii(address: str) -> str: return f"[QR code requires 'qrcode' package: pip install qrcode[pil]]\nAddress: {address}" -def save_solana_wallet_qr(address: str, path: Optional[str] = None) -> str: +def save_solana_wallet_qr(address: str, path: str | None = None) -> str: """ Save Solana QR code as PNG image. @@ -560,8 +561,8 @@ def open_solana_wallet_qr(address: str) -> str: Returns: Path to saved QR image """ - import subprocess import platform + import subprocess qr_path = save_solana_wallet_qr(address) if qr_path: diff --git a/blockrun_llm/speech.py b/blockrun_llm/speech.py index 97f7fdd..c7869ab 100644 --- a/blockrun_llm/speech.py +++ b/blockrun_llm/speech.py @@ -35,20 +35,23 @@ Price = (characters / 1000) x model rate, minimum $0.001/request. """ +from __future__ import annotations + import os -from typing import Optional, Dict, Any, List +from typing import Any + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account -from .types import SpeechResponse, APIError, PaymentError -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details +from .tx_log import paid_request_error_prefix +from .types import APIError, PaymentError, SpeechResponse from .validation import ( - validate_private_key, - validate_api_url, sanitize_error_response, + validate_api_url, + validate_private_key, ) -from .tx_log import paid_request_error_prefix +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required load_dotenv() @@ -85,8 +88,8 @@ class SpeechClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = 120.0, ): """ @@ -128,10 +131,10 @@ def generate( self, input: str, *, - model: Optional[str] = None, - voice: Optional[str] = None, - response_format: Optional[str] = None, - speed: Optional[float] = None, + model: str | None = None, + voice: str | None = None, + response_format: str | None = None, + speed: float | None = None, ) -> SpeechResponse: """ Synthesize speech from text (OpenAI-compatible TTS). @@ -162,7 +165,7 @@ def generate( result = client.generate("Welcome to BlockRun.", voice="george") print(result.data[0].url) """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or self.DEFAULT_MODEL, "input": input, } @@ -182,10 +185,10 @@ def sound_effect( self, text: str, *, - model: Optional[str] = None, - duration_seconds: Optional[float] = None, - prompt_influence: Optional[float] = None, - response_format: Optional[str] = None, + model: str | None = None, + duration_seconds: float | None = None, + prompt_influence: float | None = None, + response_format: str | None = None, ) -> SpeechResponse: """ Generate a cinematic sound effect from a text prompt. @@ -207,7 +210,7 @@ def sound_effect( result = client.sound_effect("crackling campfire at night") print(result.data[0].url) """ - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or self.DEFAULT_SOUNDFX_MODEL, "text": text, } @@ -220,7 +223,7 @@ def sound_effect( return self._request_with_payment("/v1/audio/sound-effects", body) - def list_voices(self) -> List[Dict[str, Any]]: + def list_voices(self) -> list[dict[str, Any]]: """ List available voices for TTS (free, rate-limited 60 req/min/IP). @@ -243,7 +246,7 @@ def list_voices(self) -> List[Dict[str, Any]]: return response.json().get("data", []) - def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> SpeechResponse: + def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> SpeechResponse: """Make a request with automatic x402 payment handling.""" url = f"{self.api_url}{endpoint}" @@ -273,7 +276,7 @@ def _handle_payment_and_retry( self, url: str, endpoint: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, ) -> SpeechResponse: """Handle 402 response: parse requirements, sign payment, retry.""" diff --git a/blockrun_llm/surf.py b/blockrun_llm/surf.py index 2711b48..824e95c 100644 --- a/blockrun_llm/surf.py +++ b/blockrun_llm/surf.py @@ -36,12 +36,14 @@ from __future__ import annotations import os -from typing import Any, Dict, List, Optional, Tuple +from typing import Any import httpx from dotenv import load_dotenv from eth_account import Account +from typing_extensions import Self +from .tx_log import paid_request_error_prefix from .types import APIError, PaymentError from .validation import ( sanitize_error_response, @@ -53,13 +55,12 @@ extract_payment_details, parse_payment_required, ) -from .tx_log import paid_request_error_prefix load_dotenv() # Mirrors src/lib/surf.ts SURF_TIER_*_PRICE on the backend. -SURF_TIER_PRICES: Dict[int, float] = { +SURF_TIER_PRICES: dict[int, float] = { 1: 0.001, 2: 0.005, 3: 0.020, @@ -69,7 +70,7 @@ # Mirrors src/lib/surf.ts SURF_ENDPOINTS. Each tuple is (path, method, tier, required_params). # Keep this list in sync when backend endpoints change — used for discovery, parameter # validation, and auto GET/POST routing in SurfClient.call(). -_SURF_CATALOG: List[Tuple[str, str, int, Tuple[str, ...]]] = [ +_SURF_CATALOG: list[tuple[str, str, int, tuple[str, ...]]] = [ # exchange ("exchange/markets", "GET", 1, ()), ("exchange/price", "GET", 1, ("pair",)), @@ -167,7 +168,7 @@ ("web/fetch", "GET", 2, ("url",)), ] -_CATALOG_BY_PATH: Dict[str, Tuple[str, int, Tuple[str, ...]]] = { +_CATALOG_BY_PATH: dict[str, tuple[str, int, tuple[str, ...]]] = { path: (method, tier, required) for path, method, tier, required in _SURF_CATALOG } @@ -186,8 +187,8 @@ class SurfClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, ): from .wallet import load_wallet @@ -219,7 +220,7 @@ def __init__( # ------------------------------------------------------------ Discovery API @staticmethod - def endpoints() -> List[Dict[str, Any]]: + def endpoints() -> list[dict[str, Any]]: """Return the full Surf endpoint catalog with method, tier, and price.""" return [ { @@ -233,7 +234,7 @@ def endpoints() -> List[Dict[str, Any]]: ] @staticmethod - def endpoint_info(path: str) -> Optional[Dict[str, Any]]: + def endpoint_info(path: str) -> dict[str, Any] | None: """Return catalog info for one path, or None if unknown.""" entry = _CATALOG_BY_PATH.get(path) if not entry: @@ -257,12 +258,12 @@ def price(path: str) -> float: # ---------------------------------------------------------------- Core HTTP - def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: """GET an `/v1/surf/{path}` endpoint with optional query params.""" self._validate_path(path, "GET", params or {}) return self._request("GET", path, params=params, json_body=None) - def post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: """POST an `/v1/surf/{path}` endpoint with an optional JSON body.""" self._validate_path(path, "POST", body or {}) return self._request("POST", path, params=None, json_body=body) @@ -271,9 +272,9 @@ def call( self, path: str, *, - params: Optional[Dict[str, Any]] = None, - body: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> dict[str, Any]: """ Generic helper that auto-routes to GET or POST based on the catalog. @@ -295,7 +296,7 @@ def call( # ---------------------------------------------------------------- Internals - def _validate_path(self, path: str, method: str, supplied: Dict[str, Any]) -> None: + def _validate_path(self, path: str, method: str, supplied: dict[str, Any]) -> None: info = self.endpoint_info(path) if info is None: return # allow forward-compat with newer backend endpoints @@ -312,9 +313,9 @@ def _request( method: str, path: str, *, - params: Optional[Dict[str, Any]], - json_body: Optional[Dict[str, Any]], - ) -> Dict[str, Any]: + params: dict[str, Any] | None, + json_body: dict[str, Any] | None, + ) -> dict[str, Any]: url = f"{self.api_url}/v1/surf/{path}" headers = {"Content-Type": "application/json"} if json_body is not None else {} response = self._client.request( @@ -332,10 +333,10 @@ def _handle_payment_and_retry( self, method: str, url: str, - params: Optional[Dict[str, Any]], - json_body: Optional[Dict[str, Any]], + params: dict[str, Any] | None, + json_body: dict[str, Any] | None, response: httpx.Response, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: payment_header: Any = response.headers.get("payment-required") if not payment_header: try: @@ -368,7 +369,7 @@ def _handle_payment_and_retry( extensions=extensions, ) - retry_headers: Dict[str, str] = {"PAYMENT-SIGNATURE": payment_payload} + retry_headers: dict[str, str] = {"PAYMENT-SIGNATURE": payment_payload} if json_body is not None: retry_headers["Content-Type"] = "application/json" @@ -388,7 +389,7 @@ def _handle_payment_and_retry( return data @staticmethod - def _unwrap(response: httpx.Response, *, after_payment: bool = False) -> Dict[str, Any]: + def _unwrap(response: httpx.Response, *, after_payment: bool = False) -> dict[str, Any]: if response.status_code == 200: return response.json() try: @@ -411,7 +412,7 @@ def get_wallet_address(self) -> str: def close(self) -> None: self._client.close() - def __enter__(self) -> "SurfClient": + def __enter__(self) -> Self: return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: diff --git a/blockrun_llm/tx_log.py b/blockrun_llm/tx_log.py index 1fe2ece..44ead88 100644 --- a/blockrun_llm/tx_log.py +++ b/blockrun_llm/tx_log.py @@ -36,8 +36,7 @@ import time from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional, Union - +from typing import Any DEFAULT_LOG_DIR = Path("./log") LOG_NAME = "transactions.log" @@ -60,7 +59,7 @@ _SETTLEMENT_HEADER_NAMES = ("PAYMENT-RESPONSE", "X-PAYMENT-RESPONSE") -def read_settlement_header(headers: Any) -> Optional[str]: +def read_settlement_header(headers: Any) -> str | None: """Pull the raw settlement header out of a response, under either name. Single source of truth for the header name — call sites must not hand-roll @@ -78,7 +77,7 @@ def read_settlement_header(headers: Any) -> Optional[str]: return None -def decode_settlement_header(header_value: Optional[str]) -> Optional[Dict[str, Any]]: +def decode_settlement_header(header_value: str | None) -> dict[str, Any] | None: """Decode a ``PAYMENT-RESPONSE`` header into a settlement dict. The x402 facilitator returns a base64-encoded JSON describing what @@ -170,7 +169,7 @@ def paid_request_error_prefix(headers: Any) -> str: # --------------------------------------------------------------------------- -def _resolve_log_dir(option: Union[bool, str, "os.PathLike[str]", Path, None]) -> Optional[Path]: +def _resolve_log_dir(option: bool | str | os.PathLike[str] | Path | None) -> Path | None: """Translate the ``transaction_log=...`` constructor argument into a Path. ``True`` → default ``./log`` @@ -268,13 +267,13 @@ def _extract_tokens(response: Any) -> tuple[int, int]: def format_row( *, - ts: Optional[float] = None, + ts: float | None = None, endpoint: str, - model: Optional[str], + model: str | None, in_tokens: int, out_tokens: int, cost_usd: float, - tx_hash: Optional[str], + tx_hash: str | None, ) -> str: """Format one log row exactly like the example in the module docstring.""" if ts is None: @@ -313,7 +312,7 @@ class TransactionLogger: automatically via the ``transaction_log=`` constructor argument. """ - def __init__(self, directory: Union[str, "os.PathLike[str]", Path] = DEFAULT_LOG_DIR): + def __init__(self, directory: str | os.PathLike[str] | Path = DEFAULT_LOG_DIR): self.directory = Path(directory).expanduser() self.path = self.directory / LOG_NAME @@ -321,15 +320,15 @@ def log( self, *, endpoint: str, - request: Dict[str, Any], + request: dict[str, Any], response: Any, cost_usd: float, - model: Optional[str] = None, - wallet: Optional[str] = None, - network: Optional[str] = None, - client_kind: Optional[str] = None, - settlement: Optional[Dict[str, Any]] = None, - ) -> Optional[Path]: + model: str | None = None, + wallet: str | None = None, + network: str | None = None, + client_kind: str | None = None, + settlement: dict[str, Any] | None = None, + ) -> Path | None: """Append one formatted row to ``./log/transactions.log``. Returns the log path on success, or ``None`` if the file could not @@ -382,8 +381,8 @@ def entries(self) -> list[str]: __all__ = [ + "DEFAULT_LOG_DIR", "TransactionLogger", "decode_settlement_header", "format_row", - "DEFAULT_LOG_DIR", ] diff --git a/blockrun_llm/types.py b/blockrun_llm/types.py index c9fdf8c..613f0df 100644 --- a/blockrun_llm/types.py +++ b/blockrun_llm/types.py @@ -1,6 +1,9 @@ """Type definitions for BlockRun LLM SDK.""" -from typing import List, Optional, Literal, Dict, Any, Union +from __future__ import annotations + +from typing import Any, Literal, Union + from pydantic import BaseModel @@ -9,9 +12,9 @@ class FunctionDefinition(BaseModel): """Function definition for tool calling.""" name: str - description: Optional[str] = None - parameters: Optional[Dict[str, Any]] = None - strict: Optional[bool] = None + description: str | None = None + parameters: dict[str, Any] | None = None + strict: bool | None = None class Tool(BaseModel): @@ -37,7 +40,7 @@ class ToolCall(BaseModel): # Tool choice can be a string or object specifying which tool to use -ToolChoiceFunction = Dict[str, Any] # {"type": "function", "function": {"name": "..."}} +ToolChoiceFunction = dict[str, Any] # {"type": "function", "function": {"name": "..."}} ToolChoice = Union[Literal["none", "auto", "required"], ToolChoiceFunction] @@ -50,16 +53,16 @@ class ChatMessage(BaseModel): """ role: Literal["system", "user", "assistant", "tool"] - content: Optional[str] = None - name: Optional[str] = None # For tool messages - tool_call_id: Optional[str] = None # For tool result messages - tool_calls: Optional[List[ToolCall]] = None # For assistant messages with tool calls + content: str | None = None + name: str | None = None # For tool messages + tool_call_id: str | None = None # For tool result messages + tool_calls: list[ToolCall] | None = None # For assistant messages with tool calls # Extended fields returned by reasoning-capable upstream providers # (DeepSeek Reasoner, Grok 4 reasoning, xAI multi-agent, etc.). # Backend strips these from inbound requests but may forward them on the # response side, so we accept them as optional. - reasoning_content: Optional[str] = None - thinking: Optional[str] = None + reasoning_content: str | None = None + thinking: str | None = None class Config: extra = "allow" @@ -70,7 +73,7 @@ class ChatChoice(BaseModel): index: int message: ChatMessage - finish_reason: Optional[str] = None # OpenAI-compatible; upstreams may add new values + finish_reason: str | None = None # OpenAI-compatible; upstreams may add new values class Config: extra = "allow" @@ -82,11 +85,11 @@ class ChatUsage(BaseModel): prompt_tokens: int completion_tokens: int total_tokens: int - num_sources_used: Optional[int] = None # xAI Live Search sources used + num_sources_used: int | None = None # xAI Live Search sources used # Anthropic prompt caching — populated on anthropic/* models when cache # headers are sent. Reads are cheaper; writes incur a one-time surcharge. - cache_read_input_tokens: Optional[int] = None - cache_creation_input_tokens: Optional[int] = None + cache_read_input_tokens: int | None = None + cache_creation_input_tokens: int | None = None class Config: extra = "allow" @@ -104,9 +107,9 @@ class ChatResponse(BaseModel): object: str = "chat.completion" created: int model: str - choices: List[ChatChoice] - usage: Optional[ChatUsage] = None - citations: Optional[List[str]] = None # xAI Live Search citation URLs + choices: list[ChatChoice] + usage: ChatUsage | None = None + citations: list[str] | None = None # xAI Live Search citation URLs # Real x402 charge for THIS call, in USD — the exact amount debited from the # wallet (0.0 for free / cached calls). This is the authoritative number to @@ -115,8 +118,8 @@ class ChatResponse(BaseModel): # the client on every chat completion. ``settlement`` carries the decoded # on-chain receipt (tx hash / micro-USDC / network) when the facilitator # returned an X-PAYMENT-RESPONSE header. - cost_usd: Optional[float] = None - settlement: Optional[Dict[str, Any]] = None + cost_usd: float | None = None + settlement: dict[str, Any] | None = None class Config: extra = "allow" @@ -136,8 +139,8 @@ class ChatChunkFunctionCall(BaseModel): frame and ``arguments`` in fragments afterwards, so both are optional here — unlike the non-stream :class:`FunctionCall` where both are required.""" - name: Optional[str] = None - arguments: Optional[str] = None + name: str | None = None + arguments: str | None = None class Config: extra = "allow" @@ -157,13 +160,13 @@ class ChatChunkToolCall(BaseModel): lenient type keeps streamed tool calls parsing into real objects. """ - index: Optional[int] = None - id: Optional[str] = None + index: int | None = None + id: str | None = None # Kept as a free-form ``str`` (not ``Literal["function"]``) so an upstream # that streams a non-"function" tool type can't fail validation and re-trigger # the very ``model_construct`` fallback this lenient type exists to avoid. - type: Optional[str] = None - function: Optional[ChatChunkFunctionCall] = None + type: str | None = None + function: ChatChunkFunctionCall | None = None class Config: extra = "allow" @@ -178,11 +181,11 @@ class ChatChunkDelta(BaseModel): reasoning-capable upstreams. """ - role: Optional[Literal["system", "user", "assistant", "tool"]] = None - content: Optional[str] = None - tool_calls: Optional[List[ChatChunkToolCall]] = None - reasoning_content: Optional[str] = None - thinking: Optional[str] = None + role: Literal["system", "user", "assistant", "tool"] | None = None + content: str | None = None + tool_calls: list[ChatChunkToolCall] | None = None + reasoning_content: str | None = None + thinking: str | None = None class Config: extra = "allow" @@ -193,7 +196,7 @@ class ChatChunkChoice(BaseModel): index: int delta: ChatChunkDelta - finish_reason: Optional[str] = None # OpenAI-compatible; upstreams may add new values + finish_reason: str | None = None # OpenAI-compatible; upstreams may add new values class Config: extra = "allow" @@ -206,17 +209,17 @@ class ChatCompletionChunk(BaseModel): object: str = "chat.completion.chunk" created: int model: str - choices: List[ChatChunkChoice] + choices: list[ChatChunkChoice] # Usage is populated only on the final chunk for providers that support it # (some upstreams omit it entirely — callers must tolerate ``None``). - usage: Optional[ChatUsage] = None - citations: Optional[List[str]] = None # xAI Live Search citation URLs (final chunk only) + usage: ChatUsage | None = None + citations: list[str] | None = None # xAI Live Search citation URLs (final chunk only) class Config: extra = "allow" -def stream_choice_content(choice: Any) -> Optional[str]: +def stream_choice_content(choice: Any) -> str | None: """Text delta from a streaming choice, tolerant of a raw ``dict`` choice. A chunk that fails strict validation falls back to ``model_construct``, @@ -232,14 +235,14 @@ def stream_choice_content(choice: Any) -> Optional[str]: return getattr(delta, "content", None) if delta is not None else None -def stream_choice_finish_reason(choice: Any) -> Optional[str]: +def stream_choice_finish_reason(choice: Any) -> str | None: """``finish_reason`` from a streaming choice, tolerant of a raw dict choice.""" if isinstance(choice, dict): return choice.get("finish_reason") return getattr(choice, "finish_reason", None) -def chunk_meta(chunk: Any) -> "tuple[Optional[str], Optional[str], Optional[int]]": +def chunk_meta(chunk: Any) -> tuple[str | None, str | None, int | None]: """``(id, model, created)`` of a chunk, tolerant of a ``model_construct``'d chunk that omits required fields. @@ -256,7 +259,7 @@ def chunk_meta(chunk: Any) -> "tuple[Optional[str], Optional[str], Optional[int] ) -def chunk_usage_dict(chunk: Any) -> Optional[Dict[str, Any]]: +def chunk_usage_dict(chunk: Any) -> dict[str, Any] | None: """``usage`` of a chunk as a dict, tolerant of a model_construct'd chunk whose ``usage`` is a raw dict (no ``.model_dump``).""" usage = getattr(chunk, "usage", None) @@ -281,10 +284,10 @@ class Model(BaseModel): available: bool = True # Extended metadata surfaced by /v1/models. `billing_mode` is one of # "paid" (per-token), "flat" (flat_price per request) or "free". - billing_mode: Optional[Literal["paid", "flat", "free"]] = None - flat_price: Optional[float] = None - categories: Optional[List[str]] = None # e.g. ["chat","reasoning","coding","vision"] - hidden: Optional[bool] = None # True for deprecated/superseded models still routable + billing_mode: Literal["paid", "flat", "free"] | None = None + flat_price: float | None = None + categories: list[str] | None = None # e.g. ["chat","reasoning","coding","vision"] + hidden: bool | None = None # True for deprecated/superseded models still routable class PaymentRequirement(BaseModel): @@ -302,14 +305,12 @@ class PaymentRequired(BaseModel): """x402 payment required response.""" x402_version: int = 1 - accepts: List[PaymentRequirement] + accepts: list[PaymentRequirement] class BlockrunError(Exception): """Base exception for BlockRun SDK.""" - pass - class PaymentError(BlockrunError): """Payment-related error. @@ -324,8 +325,8 @@ def __init__( self, message: str, *, - status_code: Optional[int] = None, - response: Optional[dict] = None, + status_code: int | None = None, + response: dict | None = None, ) -> None: super().__init__(message) self.status_code = status_code @@ -362,7 +363,7 @@ def __init__( class APIError(BlockrunError): """API-related error.""" - def __init__(self, message: str, status_code: int, response: Optional[dict] = None): + def __init__(self, message: str, status_code: int, response: dict | None = None): super().__init__(message) self.status_code = status_code self.response = response @@ -377,16 +378,16 @@ class ImageData(BaseModel): # permanent blockrun-hosted URL and `source_url` is the original upstream. # `backed_up` is True iff the mirror step succeeded. For data-URI results # (e.g. openai/gpt-image-1) both fields are omitted. - source_url: Optional[str] = None - backed_up: Optional[bool] = None - revised_prompt: Optional[str] = None + source_url: str | None = None + backed_up: bool | None = None + revised_prompt: str | None = None class ImageResponse(BaseModel): """Response from image generation.""" created: int - data: List[ImageData] + data: list[ImageData] class ImageModel(BaseModel): @@ -407,8 +408,8 @@ class AudioTrack(BaseModel): """A single generated audio track.""" url: str - duration_seconds: Optional[float] = None - lyrics: Optional[str] = None + duration_seconds: float | None = None + lyrics: str | None = None class MusicResponse(BaseModel): @@ -416,8 +417,8 @@ class MusicResponse(BaseModel): created: int model: str - data: List[AudioTrack] - txHash: Optional[str] = None + data: list[AudioTrack] + txHash: str | None = None class AudioModel(BaseModel): @@ -438,9 +439,9 @@ class SpeechAudio(BaseModel): """A single synthesized audio clip.""" url: str - format: Optional[str] = None - characters: Optional[int] = None - credits: Optional[float] = None + format: str | None = None + characters: int | None = None + credits: float | None = None class SpeechResponse(BaseModel): @@ -448,8 +449,8 @@ class SpeechResponse(BaseModel): created: int model: str - data: List[SpeechAudio] - txHash: Optional[str] = None + data: list[SpeechAudio] + txHash: str | None = None # Multi-chain RPC types @@ -458,9 +459,9 @@ class SpeechResponse(BaseModel): class RpcError(BaseModel): """A JSON-RPC 2.0 error object.""" - code: Optional[int] = None - message: Optional[str] = None - data: Optional[Any] = None + code: int | None = None + message: str | None = None + data: Any | None = None class RpcResponse(BaseModel): @@ -470,14 +471,14 @@ class RpcResponse(BaseModel): from response headers (X-Network / X-Cache / X-Payment-Receipt). """ - jsonrpc: Optional[str] = None - id: Optional[Union[str, int]] = None - result: Optional[Any] = None - error: Optional[RpcError] = None + jsonrpc: str | None = None + id: str | int | None = None + result: Any | None = None + error: RpcError | None = None # Gateway metadata (response headers) - network: Optional[str] = None # canonical network key, e.g. "ethereum" + network: str | None = None # canonical network key, e.g. "ethereum" cache_hit: bool = False # served from the gateway's method-aware cache - tx_hash: Optional[str] = None # x402 settlement tx (single calls) + tx_hash: str | None = None # x402 settlement tx (single calls) # Video generation types @@ -487,10 +488,10 @@ class VideoClip(BaseModel): """A single generated video clip.""" url: str # Permanent blockrun-hosted URL (falls back to upstream if backup fails) - source_url: Optional[str] = None # Original upstream URL (e.g. vidgen.x.ai) - duration_seconds: Optional[int] = None - request_id: Optional[str] = None # Upstream provider's request id (xAI) - backed_up: Optional[bool] = None + source_url: str | None = None # Original upstream URL (e.g. vidgen.x.ai) + duration_seconds: int | None = None + request_id: str | None = None # Upstream provider's request id (xAI) + backed_up: bool | None = None class VideoResponse(BaseModel): @@ -498,8 +499,8 @@ class VideoResponse(BaseModel): created: int model: str - data: List[VideoClip] - txHash: Optional[str] = None + data: list[VideoClip] + txHash: str | None = None class VideoModel(BaseModel): @@ -523,11 +524,9 @@ class WebSearchSource(BaseModel): """Web search source configuration.""" type: Literal["web"] = "web" - country: Optional[str] = None # ISO alpha-2 country code - excluded_websites: Optional[List[str]] = None # Max 5 websites - allowed_websites: Optional[List[str]] = ( - None # Max 5 websites (mutually exclusive with excluded) - ) + country: str | None = None # ISO alpha-2 country code + excluded_websites: list[str] | None = None # Max 5 websites + allowed_websites: list[str] | None = None # Max 5 websites (mutually exclusive with excluded) safe_search: bool = True @@ -535,19 +534,19 @@ class XSearchSource(BaseModel): """X/Twitter search source configuration.""" type: Literal["x"] = "x" - included_x_handles: Optional[List[str]] = None # Max 10 handles - excluded_x_handles: Optional[List[str]] = None # Max 10 handles - post_favorite_count: Optional[int] = None # Minimum favorites threshold - post_view_count: Optional[int] = None # Minimum views threshold + included_x_handles: list[str] | None = None # Max 10 handles + excluded_x_handles: list[str] | None = None # Max 10 handles + post_favorite_count: int | None = None # Minimum favorites threshold + post_view_count: int | None = None # Minimum views threshold class NewsSearchSource(BaseModel): """News search source configuration.""" type: Literal["news"] = "news" - country: Optional[str] = None # ISO alpha-2 country code - excluded_websites: Optional[List[str]] = None # Max 5 websites - allowed_websites: Optional[List[str]] = None # Max 5 websites + country: str | None = None # ISO alpha-2 country code + excluded_websites: list[str] | None = None # Max 5 websites + allowed_websites: list[str] | None = None # Max 5 websites safe_search: bool = True @@ -555,11 +554,11 @@ class RssSearchSource(BaseModel): """RSS feed search source configuration.""" type: Literal["rss"] = "rss" - links: List[str] # RSS feed URLs (currently supports one) + links: list[str] # RSS feed URLs (currently supports one) SearchSource = Union[ - WebSearchSource, XSearchSource, NewsSearchSource, RssSearchSource, Dict[str, Any] + WebSearchSource, XSearchSource, NewsSearchSource, RssSearchSource, dict[str, Any] ] @@ -579,17 +578,17 @@ class SearchParameters(BaseModel): """ mode: Literal["off", "auto", "on"] = "auto" - sources: Optional[List[SearchSource]] = None # Default: web, news, x + sources: list[SearchSource] | None = None # Default: web, news, x return_citations: bool = True - from_date: Optional[str] = None # YYYY-MM-DD format - to_date: Optional[str] = None # YYYY-MM-DD format + from_date: str | None = None # YYYY-MM-DD format + to_date: str | None = None # YYYY-MM-DD format max_search_results: int = 10 # Max sources (default 10, ~$0.26 with margin) class SearchUsage(BaseModel): """Search usage information from xAI Live Search.""" - num_sources_used: Optional[int] = None + num_sources_used: int | None = None class CostEstimate(BaseModel): @@ -667,7 +666,7 @@ class RoutingDecision(BaseModel): cost_estimate: float baseline_cost: float savings: float # 0-1 percentage - fallbacks: List[str] = [] # remaining models in tier order, for runtime fallback + fallbacks: list[str] = [] # remaining models in tier order, for runtime fallback class SmartChatResponse(BaseModel): @@ -692,9 +691,9 @@ class SearchResult(BaseModel): query: str summary: str - citations: Optional[List[Dict[str, str]]] = None - sources_used: Optional[int] = None - model: Optional[str] = None + citations: list[dict[str, str]] | None = None + sources_used: int | None = None + model: str | None = None # Pyth-backed market data types (crypto, stocks, fx, commodity) @@ -703,9 +702,9 @@ class PricePoint(BaseModel): symbol: str price: float - publish_time: Optional[int] = None # Unix seconds - confidence: Optional[float] = None - feed_id: Optional[str] = None + publish_time: int | None = None # Unix seconds + confidence: float | None = None + feed_id: str | None = None class Config: extra = "allow" @@ -714,12 +713,12 @@ class Config: class PriceBar(BaseModel): """OHLC bar in a historical price series.""" - t: Optional[int] = None # Bar open time (unix seconds) - o: Optional[float] = None - h: Optional[float] = None - l: Optional[float] = None # noqa: E741 — Pyth bar field name - c: Optional[float] = None - v: Optional[float] = None + t: int | None = None # Bar open time (unix seconds) + o: float | None = None + h: float | None = None + l: float | None = None + c: float | None = None + v: float | None = None class Config: extra = "allow" @@ -729,8 +728,8 @@ class PriceHistoryResponse(BaseModel): """Response from a historical price endpoint.""" symbol: str - resolution: Optional[str] = None - bars: List[PriceBar] = [] + resolution: str | None = None + bars: list[PriceBar] = [] class Config: extra = "allow" @@ -739,8 +738,8 @@ class Config: class SymbolListResponse(BaseModel): """Response from a market symbol list endpoint.""" - symbols: List[Dict[str, Any]] = [] - count: Optional[int] = None + symbols: list[dict[str, Any]] = [] + count: int | None = None class Config: extra = "allow" @@ -752,8 +751,8 @@ class Config: class PortraitUsage(BaseModel): """How the enrolled portrait can be used.""" - compatible_models: List[str] = [] - how_to_use: Optional[str] = None + compatible_models: list[str] = [] + how_to_use: str | None = None class Config: extra = "allow" @@ -763,8 +762,8 @@ class PortraitSettlement(BaseModel): """On-chain settlement of the enrollment payment.""" success: bool - tx_hash: Optional[str] = None - network: Optional[str] = None + tx_hash: str | None = None + network: str | None = None class Config: extra = "allow" @@ -775,13 +774,13 @@ class PortraitEnrollment(BaseModel): object: str = "virtual_portrait" asset_id: str # ta_xxxxxxxx — pass as real_face_asset_id on Seedance - group_id: Optional[str] = None + group_id: str | None = None name: str image_url: str - created_at: Optional[str] = None - usage: Optional[PortraitUsage] = None - price: Optional[Dict[str, Any]] = None # {amount, currency} - settlement: Optional[PortraitSettlement] = None + created_at: str | None = None + usage: PortraitUsage | None = None + price: dict[str, Any] | None = None # {amount, currency} + settlement: PortraitSettlement | None = None class Config: extra = "allow" @@ -792,11 +791,11 @@ class PortraitListItem(BaseModel): # Upstream uses camelCase here, keep matching for transparent ingestion. assetId: str - groupId: Optional[str] = None - name: Optional[str] = None - imageUrl: Optional[str] = None - createdAt: Optional[str] = None - enrollmentTxHash: Optional[str] = None + groupId: str | None = None + name: str | None = None + imageUrl: str | None = None + createdAt: str | None = None + enrollmentTxHash: str | None = None class Config: extra = "allow" @@ -806,8 +805,8 @@ class PortraitList(BaseModel): """Response from GET /v1/wallet/
/portraits.""" wallet: str - portraits: List[PortraitListItem] = [] - count: Optional[int] = None + portraits: list[PortraitListItem] = [] + count: int | None = None class Config: extra = "allow" @@ -829,10 +828,10 @@ class RealFaceInit(BaseModel): object: str = "realface.init" group_id: str # legacy_rf_xxxx — pass to status()/enroll() h5_link: str # URL the real person scans on their phone for liveness - status: Optional[str] = None # pending_validation | active - expires_in_seconds: Optional[int] = None # H5 session validity (~120s) - next_steps: Optional[Dict[str, Any]] = None - refreshed: Optional[bool] = None # True when re-issued for an existing group + status: str | None = None # pending_validation | active + expires_in_seconds: int | None = None # H5 session validity (~120s) + next_steps: dict[str, Any] | None = None + refreshed: bool | None = None # True when re-issued for an existing group class Config: extra = "allow" @@ -844,7 +843,7 @@ class RealFaceStatus(BaseModel): object: str = "realface.status" group_id: str status: str # pending_validation | active | … - asset_count: Optional[int] = None + asset_count: int | None = None ready_to_finalize: bool = False # True once status == "active" class Config: @@ -856,14 +855,14 @@ class RealFaceEnrollment(BaseModel): object: str = "realface" asset_id: str # ta_xxxxxxxx — pass as real_face_asset_id on Seedance - group_id: Optional[str] = None - byteplus_asset_id: Optional[str] = None + group_id: str | None = None + byteplus_asset_id: str | None = None name: str image_url: str - created_at: Optional[str] = None - usage: Optional[PortraitUsage] = None - price: Optional[Dict[str, Any]] = None # {amount, currency} - settlement: Optional[PortraitSettlement] = None + created_at: str | None = None + usage: PortraitUsage | None = None + price: dict[str, Any] | None = None # {amount, currency} + settlement: PortraitSettlement | None = None class Config: extra = "allow" @@ -874,12 +873,12 @@ class RealFaceListItem(BaseModel): # Upstream uses camelCase here, keep matching for transparent ingestion. assetId: str - groupId: Optional[str] = None - name: Optional[str] = None - imageUrl: Optional[str] = None - createdAt: Optional[str] = None - enrollmentTxHash: Optional[str] = None - byteplusAssetId: Optional[str] = None + groupId: str | None = None + name: str | None = None + imageUrl: str | None = None + createdAt: str | None = None + enrollmentTxHash: str | None = None + byteplusAssetId: str | None = None class Config: extra = "allow" @@ -889,8 +888,8 @@ class RealFaceList(BaseModel): """Response from GET /v1/wallet/
/realfaces.""" wallet: str - realfaces: List[RealFaceListItem] = [] - count: Optional[int] = None + realfaces: list[RealFaceListItem] = [] + count: int | None = None class Config: extra = "allow" diff --git a/blockrun_llm/validation.py b/blockrun_llm/validation.py index 859c5a6..e043867 100644 --- a/blockrun_llm/validation.py +++ b/blockrun_llm/validation.py @@ -9,8 +9,10 @@ - Resource URLs match expected domains """ +from __future__ import annotations + import re -from typing import Optional, Dict, Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse if TYPE_CHECKING: @@ -62,7 +64,7 @@ def _looks_like_solana_key(key: str) -> bool: EVM 64-char range — so a malformed 64-char hex key still routes to the regular hex error rather than the Solana hint. """ - candidate = key[2:] if key.startswith("0x") else key + candidate = key.removeprefix("0x") if len(candidate) == 64 or not (40 <= len(candidate) <= 90): return False return any(c in _BASE58_ONLY_CHARS for c in candidate) @@ -167,7 +169,7 @@ def validate_model(model: str) -> None: pass -def validate_video_input_type(input_type: Optional[str]) -> None: +def validate_video_input_type(input_type: str | None) -> None: """ Validate the optional `input_type` seed-mode assertion on video generation. @@ -193,7 +195,7 @@ def validate_video_input_type(input_type: Optional[str]) -> None: ) -def validate_image_quality(quality: Optional[str]) -> None: +def validate_image_quality(quality: str | None) -> None: """ Validate the optional `quality` knob on Solana image generation/editing. @@ -239,7 +241,7 @@ def validate_image_quality(quality: Optional[str]) -> None: MAX_TOKENS_SANITY_LIMIT = 1_000_000 -def validate_max_tokens(max_tokens: Optional[int]) -> None: +def validate_max_tokens(max_tokens: int | None) -> None: """ Validate max_tokens parameter. @@ -283,7 +285,7 @@ def validate_max_tokens(max_tokens: Optional[int]) -> None: ) -def validate_temperature(temperature: Optional[float]) -> None: +def validate_temperature(temperature: float | None) -> None: """ Validate temperature parameter. @@ -309,7 +311,7 @@ def validate_temperature(temperature: Optional[float]) -> None: raise ValueError("temperature must be between 0 and 2") -def validate_top_p(top_p: Optional[float]) -> None: +def validate_top_p(top_p: float | None) -> None: """ Validate top_p parameter (nucleus sampling). @@ -370,7 +372,7 @@ def validate_api_url(url: str) -> None: ) -def build_payment_rejected_error(response: Any) -> "PaymentError": +def build_payment_rejected_error(response: Any) -> PaymentError: """Translate a 402 retry response into a :class:`PaymentError` that preserves the gateway's original failure reason. @@ -427,7 +429,7 @@ def build_payment_rejected_error(response: Any) -> "PaymentError": return PaymentError(msg, status_code=402, response=sanitized) -def sanitize_error_response(error_body: Any) -> Dict[str, Any]: +def sanitize_error_response(error_body: Any) -> dict[str, Any]: """ Sanitize API error responses to prevent information leakage. @@ -465,7 +467,7 @@ def sanitize_error_response(error_body: Any) -> Dict[str, Any]: if isinstance(nested, dict): message = nested.get("message") code = nested.get("code") or error_body.get("code") - result: Dict[str, Any] = { + result: dict[str, Any] = { "message": message if isinstance(message, str) else "API request failed", "code": code if isinstance(code, str) else None, } @@ -537,7 +539,7 @@ def validate_resource_url(url: str, base_url: str) -> str: return f"{base_url}/v1/chat/completions" -def resolve_spend_limit(explicit: Optional[float], env_var: str) -> Optional[float]: +def resolve_spend_limit(explicit: float | None, env_var: str) -> float | None: """Resolve a spend limit from the constructor argument or its env var. ``None`` means unlimited, which is the default and the pre-1.9.0 behavior. @@ -566,10 +568,10 @@ def resolve_spend_limit(explicit: Optional[float], env_var: str) -> Optional[flo def check_spend_limits( cost_usd: float, *, - max_cost_per_call: Optional[float], - max_session_cost: Optional[float], + max_cost_per_call: float | None, + max_session_cost: float | None, session_spent_usd: float, - model: Optional[str] = None, + model: str | None = None, ) -> None: """Refuse a quote that would breach a caller-configured spend limit. diff --git a/blockrun_llm/video.py b/blockrun_llm/video.py index 5f9252b..f79802c 100644 --- a/blockrun_llm/video.py +++ b/blockrun_llm/video.py @@ -28,21 +28,24 @@ print(result.data[0].duration_seconds) """ +from __future__ import annotations + import os import time -from typing import Optional, Dict, Any, List +from typing import Any + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account -from .types import VideoResponse, APIError, PaymentError -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details +from .types import APIError, PaymentError, VideoResponse from .validation import ( - validate_private_key, - validate_api_url, sanitize_error_response, + validate_api_url, + validate_private_key, validate_video_input_type, ) +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required load_dotenv() @@ -93,8 +96,8 @@ class VideoClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = 360.0, ): """ @@ -136,20 +139,20 @@ def generate( self, prompt: str, *, - model: Optional[str] = None, - image_url: Optional[str] = None, - last_frame_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, - real_face_asset_id: Optional[str] = None, - duration_seconds: Optional[int] = None, - aspect_ratio: Optional[str] = None, - resolution: Optional[str] = None, - generate_audio: Optional[bool] = None, - seed: Optional[int] = None, - watermark: Optional[bool] = None, - return_last_frame: Optional[bool] = None, - input_type: Optional[str] = None, - budget_seconds: Optional[float] = None, + model: str | None = None, + image_url: str | None = None, + last_frame_url: str | None = None, + reference_image_urls: list[str] | None = None, + real_face_asset_id: str | None = None, + duration_seconds: int | None = None, + aspect_ratio: str | None = None, + resolution: str | None = None, + generate_audio: bool | None = None, + seed: int | None = None, + watermark: bool | None = None, + return_last_frame: bool | None = None, + input_type: str | None = None, + budget_seconds: float | None = None, ) -> VideoResponse: """ Generate a video clip from a text prompt (or text + image / face asset). @@ -247,7 +250,7 @@ def generate( ) validate_video_input_type(input_type) - body: Dict[str, Any] = { + body: dict[str, Any] = { "model": model or self.DEFAULT_MODEL, "prompt": prompt, } @@ -284,10 +287,10 @@ def generate( def generate_from_content( self, - content: List[Dict[str, Any]], + content: list[dict[str, Any]], *, - model: Optional[str] = None, - budget_seconds: Optional[float] = None, + model: str | None = None, + budget_seconds: float | None = None, **options: Any, ) -> VideoResponse: """ @@ -321,7 +324,7 @@ def generate_from_content( if not content: raise ValueError("content must be a non-empty list of Seedance content items.") - body: Dict[str, Any] = {"content": content, **options} + body: dict[str, Any] = {"content": content, **options} if model is not None: body["model"] = model @@ -336,7 +339,7 @@ def generate_from_content( def _submit_and_poll( self, - body: Dict[str, Any], + body: dict[str, Any], budget_seconds: float, submit_path: str = "/v1/videos/generations", ) -> VideoResponse: @@ -486,13 +489,13 @@ def _sign_from_challenge(self, resp402: httpx.Response, fallback_url: str) -> st ) def _absolute(self, url: str) -> str: - if url.startswith("http://") or url.startswith("https://"): + if url.startswith(("http://", "https://")): return url # self.api_url already ends without '/'; poll_url starts with '/api/...' - base = self.api_url[: -len("/api")] if self.api_url.endswith("/api") else self.api_url + base = self.api_url.removesuffix("/api") return f"{base}{url}" - def _extract_payment_required(self, resp: httpx.Response) -> Dict[str, Any]: + def _extract_payment_required(self, resp: httpx.Response) -> dict[str, Any]: header = resp.headers.get("payment-required") if header: return parse_payment_required(header) diff --git a/blockrun_llm/voice.py b/blockrun_llm/voice.py index db39288..8a9ded0 100644 --- a/blockrun_llm/voice.py +++ b/blockrun_llm/voice.py @@ -33,29 +33,32 @@ Pricing: $0.54 per outbound call (regardless of duration up to max_duration). """ +from __future__ import annotations + import os -from typing import Optional, Dict, Any, List +from typing import Any + import httpx -from eth_account import Account from dotenv import load_dotenv +from eth_account import Account +from .tx_log import paid_request_error_prefix from .types import APIError, PaymentError -from .x402 import create_payment_payload, parse_payment_required, extract_payment_details from .validation import ( - validate_private_key, - validate_api_url, sanitize_error_response, + validate_api_url, + validate_private_key, ) -from .tx_log import paid_request_error_prefix +from .x402 import create_payment_payload, extract_payment_details, parse_payment_required load_dotenv() # Built-in Bland.ai voice presets — any string accepted by Bland is also valid. -VOICE_PRESETS: List[str] = ["nat", "josh", "maya", "june", "paige", "derek", "florian"] +VOICE_PRESETS: list[str] = ["nat", "josh", "maya", "june", "paige", "derek", "florian"] # Bland.ai conversation models -CALL_MODELS: List[str] = ["base", "enhanced", "turbo"] +CALL_MODELS: list[str] = ["base", "enhanced", "turbo"] # Settled price per call (USD) CALL_PRICE_USD: float = 0.54 @@ -80,8 +83,8 @@ class VoiceClient: def __init__( self, - private_key: Optional[str] = None, - api_url: Optional[str] = None, + private_key: str | None = None, + api_url: str | None = None, timeout: float = 60.0, ): """ @@ -124,15 +127,15 @@ def call( to: str, task: str, *, - from_: Optional[str] = None, - voice: Optional[str] = None, + from_: str | None = None, + voice: str | None = None, max_duration: int = 5, language: str = "en-US", - first_sentence: Optional[str] = None, - wait_for_greeting: Optional[bool] = None, - interruption_threshold: Optional[int] = None, - model: Optional[str] = None, - ) -> Dict[str, Any]: + first_sentence: str | None = None, + wait_for_greeting: bool | None = None, + interruption_threshold: int | None = None, + model: str | None = None, + ) -> dict[str, Any]: """ Initiate an AI-powered outbound phone call. @@ -196,7 +199,7 @@ def call( if interruption_threshold is not None and not (50 <= interruption_threshold <= 500): raise ValueError("interruption_threshold must be between 50 and 500") - body: Dict[str, Any] = { + body: dict[str, Any] = { "to": to.strip(), "task": task.strip(), "max_duration": max_duration, @@ -217,7 +220,7 @@ def call( return self._request_with_payment("/v1/voice/call", body) - def get_status(self, call_id: str) -> Dict[str, Any]: + def get_status(self, call_id: str) -> dict[str, Any]: """ Poll the status of an in-progress or completed call. Free — no payment. @@ -256,7 +259,7 @@ def get_status(self, call_id: str) -> Dict[str, Any]: return response.json() - def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> Dict[str, Any]: + def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]: """Make a POST with automatic x402 payment handling.""" url = f"{self.api_url}{endpoint}" @@ -285,9 +288,9 @@ def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> Dict[str def _handle_payment_and_retry( self, url: str, - body: Dict[str, Any], + body: dict[str, Any], response: httpx.Response, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Handle 402: parse requirements, sign payment, retry.""" payment_header = response.headers.get("payment-required") if not payment_header: diff --git a/blockrun_llm/wallet.py b/blockrun_llm/wallet.py index b6f5d23..0ca1f5d 100644 --- a/blockrun_llm/wallet.py +++ b/blockrun_llm/wallet.py @@ -13,7 +13,7 @@ import os import time from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING from eth_account import Account @@ -31,7 +31,7 @@ BASE_CHAIN_ID = "8453" -def create_wallet() -> Tuple[str, str]: +def create_wallet() -> tuple[str, str]: """ Create a new Ethereum wallet. @@ -59,7 +59,7 @@ def save_wallet(private_key: str) -> Path: return WALLET_FILE -def scan_wallets() -> List[Dict[str, str]]: +def scan_wallets() -> list[dict[str, str]]: """ Discover ~/./wallet.json files from other providers. @@ -73,7 +73,7 @@ def scan_wallets() -> List[Dict[str, str]]: for an address derived from the key. """ home = Path.home() - results: List[tuple] = [] # (mtime, private_key, address, source) + results: list[tuple] = [] # (mtime, private_key, address, source) try: for entry in home.iterdir(): @@ -99,7 +99,7 @@ def scan_wallets() -> List[Dict[str, str]]: return [{"private_key": pk, "address": addr, "source": src} for _, pk, addr, src in results] -def list_discovered_wallets() -> List[Dict[str, str]]: +def list_discovered_wallets() -> list[dict[str, str]]: """ List wallets from other applications, safe to show to a user. @@ -172,7 +172,7 @@ def import_wallet(address: str) -> str: ) -def load_wallet() -> Optional[str]: +def load_wallet() -> str | None: """ Load wallet private key from file. @@ -200,7 +200,7 @@ def load_wallet() -> Optional[str]: return None -def get_or_create_wallet() -> Tuple[str, str, bool]: +def get_or_create_wallet() -> tuple[str, str, bool]: """ Get existing wallet or create new one. @@ -235,7 +235,7 @@ def get_or_create_wallet() -> Tuple[str, str, bool]: return address, key, True -def get_wallet_address() -> Optional[str]: +def get_wallet_address() -> str | None: """ Get wallet address without exposing private key. @@ -299,9 +299,10 @@ def generate_wallet_qr_ascii(address: str) -> str: # Generate new QR try: - import qrcode from io import StringIO + import qrcode + qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, @@ -328,7 +329,7 @@ def generate_wallet_qr_ascii(address: str) -> str: return f"[QR code requires 'qrcode' package: pip install qrcode[pil]]\nAddress: {address}" -def save_wallet_qr(address: str, path: Optional[str] = None, with_logo: bool = True) -> str: +def save_wallet_qr(address: str, path: str | None = None, with_logo: bool = True) -> str: """ Save QR code as PNG image (EIP-681 format with optional Base logo). @@ -341,10 +342,11 @@ def save_wallet_qr(address: str, path: Optional[str] = None, with_logo: bool = T Path to saved QR image """ try: + import io + import urllib.request + import qrcode from PIL import Image - import urllib.request - import io # Use EIP-681 format for MetaMask compatibility eip681_uri = get_eip681_uri(address) @@ -404,8 +406,8 @@ def open_wallet_qr(address: str) -> str: Returns: Path to saved QR image """ - import subprocess import platform + import subprocess qr_path = save_wallet_qr(address) if qr_path: @@ -443,7 +445,7 @@ def get_payment_links(address: str) -> dict: } -def format_wallet_migration_notice(new_address: str) -> Optional[str]: +def format_wallet_migration_notice(new_address: str) -> str | None: """ Warn when a new wallet was created while other provider wallets exist. @@ -606,7 +608,7 @@ def format_funding_message_compact(address: str) -> str: Check my balance: {links['basescan']}""" -def setup_agent_wallet(silent: bool = False) -> "LLMClient": +def setup_agent_wallet(silent: bool = False) -> LLMClient: """ Set up wallet for agent use and return an LLMClient. diff --git a/blockrun_llm/x402.py b/blockrun_llm/x402.py index 97656a4..491f75a 100644 --- a/blockrun_llm/x402.py +++ b/blockrun_llm/x402.py @@ -5,15 +5,17 @@ The private key is used ONLY for local signing and NEVER leaves the client. """ -import json -import time +from __future__ import annotations + import base64 +import json import secrets -from typing import Dict, Any, Optional +import time +from typing import Any + from eth_account import Account from eth_account.messages import encode_typed_data - # Chain and token constants for mainnet BASE_CHAIN_ID = 8453 USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" @@ -30,15 +32,15 @@ def with_builder_code_service_code( - extensions: Optional[Dict[str, Any]], -) -> Dict[str, Any]: + extensions: dict[str, Any] | None, +) -> dict[str, Any]: """Merge BlockRun's service code (``s``) into the payload's ``builder-code`` extension, preserving any app code (``a``) the server echoed back in its 402. The CDP facilitator reads ``builder-code.info.s`` and encodes it into the settlement calldata suffix — no CBOR/encoding happens client-side. """ - merged: Dict[str, Any] = dict(extensions or {}) + merged: dict[str, Any] = dict(extensions or {}) existing = dict(merged.get("builder-code") or {}) info = dict(existing.get("info") or {}) info["s"] = [BLOCKRUN_SERVICE_CODE] @@ -93,9 +95,9 @@ def create_payment_payload( resource_url: str = "https://blockrun.ai/api/v1/chat/completions", resource_description: str = "BlockRun AI API call", max_timeout_seconds: int = 300, - extra: Optional[Dict[str, str]] = None, - extensions: Optional[Dict[str, Any]] = None, - asset: Optional[str] = None, + extra: dict[str, str] | None = None, + extensions: dict[str, Any] | None = None, + asset: str | None = None, ) -> str: """ Create a signed x402 v2 payment payload. @@ -205,7 +207,7 @@ def create_payment_payload( return base64.b64encode(json.dumps(payment_data).encode()).decode() -def parse_payment_required(header_value: str) -> Dict[str, Any]: +def parse_payment_required(header_value: str) -> dict[str, Any]: """ Parse the X-Payment-Required header from a 402 response. @@ -223,7 +225,7 @@ def parse_payment_required(header_value: str) -> Dict[str, Any]: raise ValueError("Failed to parse payment required header: invalid format") -def extract_payment_details(payment_required: Dict[str, Any]) -> Dict[str, Any]: +def extract_payment_details(payment_required: dict[str, Any]) -> dict[str, Any]: """ Extract payment details from parsed payment required response. diff --git a/examples/arbitrage_analyzer.py b/examples/arbitrage_analyzer.py index 888add6..477fec8 100644 --- a/examples/arbitrage_analyzer.py +++ b/examples/arbitrage_analyzer.py @@ -13,7 +13,8 @@ """ from dataclasses import dataclass -from blockrun_llm import LLMClient, AsyncLLMClient, PaymentError, APIError + +from blockrun_llm import APIError, AsyncLLMClient, LLMClient, PaymentError @dataclass diff --git a/examples/benchmark_claude.py b/examples/benchmark_claude.py old mode 100644 new mode 100755 index 5b0bcba..cfd9b24 --- a/examples/benchmark_claude.py +++ b/examples/benchmark_claude.py @@ -43,7 +43,7 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from typing import Any SOLANA_API_URL = "https://sol.blockrun.ai/api" BASE_API_URL = "https://blockrun.ai/api" @@ -55,7 +55,7 @@ ) -def _percentile(values: List[float], pct: float) -> float: +def _percentile(values: list[float], pct: float) -> float: """Nearest-rank percentile (pct in [0,100]). Empty → nan.""" if not values: return float("nan") @@ -85,8 +85,8 @@ def _count_tokens(text: str, model_hint: str = "") -> int: @dataclass class ReqResult: ok: bool - ttft: Optional[float] = None # seconds to first content token - latency: Optional[float] = None # seconds request → last token + ttft: float | None = None # seconds to first content token + latency: float | None = None # seconds request → last token out_tokens: int = 0 error: str = "" @@ -100,8 +100,8 @@ class Bench: concurrency: int prompt: str max_tokens: int - private_key: Optional[str] = None - results: List[ReqResult] = field(default_factory=list) + private_key: str | None = None + results: list[ReqResult] = field(default_factory=list) def _client(self): if self.chain == "solana": @@ -122,8 +122,8 @@ def _client(self): def _one_streaming(self, client) -> ReqResult: messages = [{"role": "user", "content": self.prompt}] start = time.perf_counter() - ttft: Optional[float] = None - text_parts: List[str] = [] + ttft: float | None = None + text_parts: list[str] = [] try: for chunk in client.chat_completion_stream( model=self.model, messages=messages, max_tokens=self.max_tokens @@ -139,7 +139,7 @@ def _one_streaming(self, client) -> ReqResult: latency = time.perf_counter() - start out = _count_tokens("".join(text_parts), self.model) return ReqResult(ok=True, ttft=ttft, latency=latency, out_tokens=out) - except Exception as exc: # noqa: BLE001 - benchmark records, never crashes + except Exception as exc: return ReqResult(ok=False, error=f"{type(exc).__name__}: {exc}") def run_throughput_phase(self) -> float: @@ -180,7 +180,7 @@ def cache_probe(self) -> float: usage = getattr(resp, "usage", None) if usage is None: return 0.0 - u: Dict[str, Any] = ( + u: dict[str, Any] = ( usage.model_dump(exclude_none=True) if hasattr(usage, "model_dump") else dict(usage) ) prompt_tokens = u.get("prompt_tokens") or 0 @@ -198,7 +198,7 @@ def cache_probe(self) -> float: return 100.0 * cached / prompt_tokens return 0.0 - def report(self, wall: float, cache_hit: Optional[float]) -> None: + def report(self, wall: float, cache_hit: float | None) -> None: ok = [r for r in self.results if r.ok] ttfts = [r.ttft for r in ok if r.ttft is not None] lats = [r.latency for r in ok if r.latency is not None] @@ -209,7 +209,7 @@ def report(self, wall: float, cache_hit: Optional[float]) -> None: succ = 100.0 * len(ok) / self.requests if self.requests else 0.0 def fmt(x: float) -> str: - return "nan" if x != x else f"{x:.3f}" # x!=x → NaN + return "nan" if x != x else f"{x:.3f}" # noqa: PLR0124 — x!=x is the NaN test print("\n" + "=" * 56) print(f" Claude E2E benchmark — {self.model} ({self.chain})") @@ -280,7 +280,7 @@ def main() -> None: print("[benchmark] cache probe (2 non-streaming calls) …") try: cache_hit = bench.cache_probe() - except Exception as exc: # noqa: BLE001 + except Exception as exc: print(f"[benchmark] cache probe failed (→ 0): {type(exc).__name__}: {exc}") cache_hit = 0.0 bench.report(wall, cache_hit) diff --git a/examples/sweep_all_chat_models.py b/examples/sweep_all_chat_models.py index ea20c66..a0914a0 100644 --- a/examples/sweep_all_chat_models.py +++ b/examples/sweep_all_chat_models.py @@ -25,21 +25,20 @@ import sys import time from dataclasses import asdict, dataclass, field -from typing import Any, Dict, List, Optional +from typing import Any import httpx from blockrun_llm import AsyncLLMClient, LLMClient from blockrun_llm.types import APIError, PaymentError - # --------------------------------------------------------------------------- # Sweep targets — hardcoded so we also probe hidden / retired model ids that # the /v1/models endpoint deliberately omits. Mutually-exclusive groups, in # the order the report displays them. # --------------------------------------------------------------------------- -SWEEP_TARGETS: List[str] = [ +SWEEP_TARGETS: list[str] = [ # OpenAI "openai/gpt-5.5", "openai/gpt-5.4", @@ -157,16 +156,16 @@ class ProbeResult: provider: str status: str latency_ms: int - tokens_in: Optional[int] = None - tokens_out: Optional[int] = None - tokens_total: Optional[int] = None + tokens_in: int | None = None + tokens_out: int | None = None + tokens_total: int | None = None cost_delta_usd: float = 0.0 - expected_cost_usd: Optional[float] = None - cost_drift_pct: Optional[float] = None - redirected_to: Optional[str] = None + expected_cost_usd: float | None = None + cost_drift_pct: float | None = None + redirected_to: str | None = None response_preview: str = "" contains_4: bool = False - error_message: Optional[str] = None + error_message: str | None = None timestamp: float = field(default_factory=time.time) @@ -253,7 +252,7 @@ def preflight() -> LLMClient: # --------------------------------------------------------------------------- -def forward_compat_check(client: LLMClient) -> Dict[str, Dict[str, Any]]: +def forward_compat_check(client: LLMClient) -> dict[str, dict[str, Any]]: print(">>> Forward-compat check vs /v1/models") try: listed_raw = client.list_models() @@ -262,7 +261,7 @@ def forward_compat_check(client: LLMClient) -> Dict[str, Dict[str, Any]]: print() return {} - listed_chat: Dict[str, Dict[str, Any]] = {} + listed_chat: dict[str, dict[str, Any]] = {} for m in listed_raw: cats = m.get("categories") if cats is None or "chat" in cats: @@ -300,7 +299,7 @@ def forward_compat_check(client: LLMClient) -> Dict[str, Dict[str, Any]]: def probe_one( client: LLMClient, model_id: str, - pricing: Dict[str, Dict[str, Any]], + pricing: dict[str, dict[str, Any]], ) -> ProbeResult: provider = provider_of(model_id) max_toks = PROBE_MAX_TOKENS_REASONING if model_id in REASONING_MODELS else PROBE_MAX_TOKENS @@ -440,11 +439,11 @@ def probe_one( def run_sweep( client: LLMClient, - targets: List[str], + targets: list[str], args: argparse.Namespace, - pricing: Dict[str, Dict[str, Any]], -) -> List[ProbeResult]: - results: List[ProbeResult] = [] + pricing: dict[str, dict[str, Any]], +) -> list[ProbeResult]: + results: list[ProbeResult] = [] n = len(targets) warned = False @@ -496,7 +495,7 @@ def run_sweep( # --------------------------------------------------------------------------- -async def _async_probe(client: AsyncLLMClient, model_id: str) -> Dict[str, Any]: +async def _async_probe(client: AsyncLLMClient, model_id: str) -> dict[str, Any]: t0 = time.monotonic() try: response = await client.chat_completion( @@ -528,13 +527,13 @@ async def _async_probe(client: AsyncLLMClient, model_id: str) -> Dict[str, Any]: } -async def _async_smoke() -> List[Dict[str, Any]]: +async def _async_smoke() -> list[dict[str, Any]]: async with AsyncLLMClient() as client: coros = [_async_probe(client, m) for m in ASYNC_SMOKE_MODELS] return await asyncio.gather(*coros) -def run_async_smoke() -> List[Dict[str, Any]]: +def run_async_smoke() -> list[dict[str, Any]]: print(">>> Async smoke (asyncio.gather over 3 models)") t0 = time.monotonic() results = asyncio.run(_async_smoke()) @@ -559,8 +558,8 @@ def run_async_smoke() -> List[Dict[str, Any]]: def report( client: LLMClient, - results: List[ProbeResult], - async_results: Optional[List[Dict[str, Any]]], + results: list[ProbeResult], + async_results: list[dict[str, Any]] | None, started_at: float, args: argparse.Namespace, ) -> bool: @@ -586,7 +585,7 @@ def report( print() print(">>> Provider summary") - by_provider: Dict[str, List[ProbeResult]] = {} + by_provider: dict[str, list[ProbeResult]] = {} for r in results: by_provider.setdefault(r.provider, []).append(r) for provider in sorted(by_provider): @@ -680,7 +679,7 @@ def main() -> int: results = run_sweep(client, targets, args, listed) - async_results: Optional[List[Dict[str, Any]]] = None + async_results: list[dict[str, Any]] | None = None if not args.skip_async: async_results = run_async_smoke() diff --git a/examples/sweep_all_media_models.py b/examples/sweep_all_media_models.py index e8d6ceb..82d7fb7 100644 --- a/examples/sweep_all_media_models.py +++ b/examples/sweep_all_media_models.py @@ -23,15 +23,14 @@ import sys import time from dataclasses import asdict, dataclass, field -from typing import Any, Dict, List, Optional +from typing import Any import httpx from blockrun_llm import ImageClient, LLMClient, MusicClient from blockrun_llm.types import APIError, PaymentError - -IMAGE_TARGETS: List[Dict[str, Any]] = [ +IMAGE_TARGETS: list[dict[str, Any]] = [ # Each entry: model_id + size override if model has a constrained set. {"model": "google/nano-banana", "size": "1024x1024"}, {"model": "google/nano-banana-pro", "size": "1024x1024"}, @@ -43,7 +42,7 @@ {"model": "xai/grok-imagine-image-pro", "size": "1024x1024"}, ] -MUSIC_TARGETS: List[str] = [ +MUSIC_TARGETS: list[str] = [ "minimax/music-2.5+", "minimax/music-2.5", ] @@ -59,8 +58,8 @@ class ProbeResult: status: str # ok / http_error / timeout / payment_error / unexpected latency_ms: int cost_delta_usd: float = 0.0 - artifact_url: Optional[str] = None # first asset URL/data preview - error_message: Optional[str] = None + artifact_url: str | None = None # first asset URL/data preview + error_message: str | None = None timestamp: float = field(default_factory=time.time) @@ -95,8 +94,8 @@ def preview_url(url: str, max_len: int = 60) -> str: def probe_image( client: ImageClient, - target: Dict[str, Any], - pricing: Dict[str, float], + target: dict[str, Any], + pricing: dict[str, float], ) -> ProbeResult: model_id = target["model"] t0 = time.monotonic() @@ -153,7 +152,7 @@ def probe_image( def probe_music( client: MusicClient, model_id: str, - pricing: Dict[str, float], + pricing: dict[str, float], ) -> ProbeResult: t0 = time.monotonic() try: @@ -236,8 +235,8 @@ def preflight() -> tuple: # Image + music pricing both come from /v1/models filtered by category; # the legacy /v1/images/models endpoint currently returns 404 server-side # (2026-05-09), so don't rely on it. - image_pricing: Dict[str, float] = {} - music_pricing: Dict[str, float] = {} + image_pricing: dict[str, float] = {} + music_pricing: dict[str, float] = {} try: for m in llm.list_models(): mid = m.get("id", "") @@ -262,13 +261,13 @@ def preflight() -> tuple: def run_image_sweep( client: ImageClient, - pricing: Dict[str, float], + pricing: dict[str, float], args: argparse.Namespace, spent_so_far: float = 0.0, -) -> List[ProbeResult]: +) -> list[ProbeResult]: print(f">>> Image sweep ({len(IMAGE_TARGETS)} models)") print() - results: List[ProbeResult] = [] + results: list[ProbeResult] = [] n = len(IMAGE_TARGETS) warned = False spent = spent_so_far @@ -306,14 +305,14 @@ def run_image_sweep( def run_music_sweep( - pricing: Dict[str, float], + pricing: dict[str, float], args: argparse.Namespace, spent_so_far: float = 0.0, -) -> List[ProbeResult]: +) -> list[ProbeResult]: print(f">>> Music sweep ({len(MUSIC_TARGETS)} models)") print() client = MusicClient() - results: List[ProbeResult] = [] + results: list[ProbeResult] = [] n = len(MUSIC_TARGETS) spent = spent_so_far for i, model_id in enumerate(MUSIC_TARGETS, start=1): @@ -347,7 +346,7 @@ def run_music_sweep( def report( - results: List[ProbeResult], + results: list[ProbeResult], started_at: float, args: argparse.Namespace, initial_balance: float, @@ -365,7 +364,7 @@ def report( print() print(">>> Modality summary") - by_mod: Dict[str, List[ProbeResult]] = {} + by_mod: dict[str, list[ProbeResult]] = {} for r in results: by_mod.setdefault(r.modality, []).append(r) for modality in sorted(by_mod): @@ -421,7 +420,7 @@ def main() -> int: started_at = time.monotonic() image_client, image_pricing, music_pricing, initial_balance = preflight() - results: List[ProbeResult] = [] + results: list[ProbeResult] = [] spent = 0.0 if not args.skip_image: image_results = run_image_sweep(image_client, image_pricing, args, spent) diff --git a/pyproject.toml b/pyproject.toml index 0cd7b9e..b66688a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dev = [ "pytest-asyncio>=0.21.0", "black==24.10.0", # Pin version for consistent formatting "mypy>=1.0.0", - "ruff==0.14.11", # Pin version: an unpinned linter breaks CI with no code change + "ruff==0.16.0", # Pin version: an unpinned linter breaks CI with no code change ] anthropic = [ "anthropic>=0.40.0", @@ -76,6 +76,23 @@ target-version = ["py39"] line-length = 100 target-version = "py39" +[tool.ruff.lint] +# Deferred, NOT blessed. Each of these is a behaviour change in a payments SDK +# and belongs in its own reviewable PR, not folded into a typing sweep: +# +# BLE001 157 blind `except Exception` — several are deliberate best-effort +# paths (telemetry, cleanup) where raising would be worse than +# swallowing. Which ones are deliberate has to be read case by case. +# S110/ 54 try/except/pass and try/except/continue — same question. +# S112 +# TRY004 8 raising something other than TypeError on a type check. +# DTZ005/ 4 naive datetime.now()/fromtimestamp() in cache.py, tx_log.py and +# DTZ006 wallet.py. Worth fixing — a transaction log without timezone is +# genuinely ambiguous — but it changes recorded values, so it needs +# its own change and its own migration thought. +# RUF012 2 mutable class defaults, both in examples/ and tests/. +ignore = ["BLE001", "S110", "S112", "TRY004", "DTZ005", "DTZ006", "RUF012"] + [tool.mypy] python_version = "3.9" strict = true diff --git a/tests/helpers.py b/tests/helpers.py index c394c78..990a209 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,9 +2,12 @@ Test utilities and mock builders for BlockRun LLM SDK tests. """ -import json +from __future__ import annotations + import base64 -from typing import Dict, Any, Optional +import json +from typing import Any + from eth_account import Account # Test private key (DO NOT use in production) @@ -23,7 +26,7 @@ def build_payment_required_response( amount: str = "1000000", recipient: str = TEST_RECIPIENT, network: str = "eip155:8453", - resource: Optional[Dict[str, str]] = None, + resource: dict[str, str] | None = None, ) -> str: """Build a mock 402 Payment Required response.""" payment_required = { @@ -54,7 +57,7 @@ def build_chat_response( model: str = "gpt-5.2", prompt_tokens: int = 10, completion_tokens: int = 20, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Build a mock successful chat response.""" return { "id": "chatcmpl-test123", @@ -80,7 +83,7 @@ def build_error_response( error: str = "Test error message", code: str = "test_error", include_sensitive: bool = True, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Build a mock error response.""" response = {"error": error, "code": code} @@ -97,7 +100,7 @@ def build_error_response( return response -def build_models_response() -> Dict[str, Any]: +def build_models_response() -> dict[str, Any]: """Build a mock models list response.""" return { "data": [ @@ -132,16 +135,16 @@ class MockResponse: def __init__( self, status_code: int, - json_data: Optional[Dict[str, Any]] = None, - text_data: Optional[str] = None, - headers: Optional[Dict[str, str]] = None, + json_data: dict[str, Any] | None = None, + text_data: str | None = None, + headers: dict[str, str] | None = None, ): self.status_code = status_code self._json_data = json_data self._text_data = text_data or (json.dumps(json_data) if json_data else "") self.headers = headers or {} - def json(self) -> Dict[str, Any]: + def json(self) -> dict[str, Any]: if self._json_data is None: raise ValueError("No JSON data available") return self._json_data diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index bf40f6f..2e2aee5 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,6 +1,7 @@ """Pytest configuration for integration tests.""" import os + import pytest diff --git a/tests/integration/test_production_api.py b/tests/integration/test_production_api.py index 88feba2..1ca60c5 100644 --- a/tests/integration/test_production_api.py +++ b/tests/integration/test_production_api.py @@ -14,7 +14,8 @@ import time import pytest -from blockrun_llm import LLMClient, AsyncLLMClient + +from blockrun_llm import AsyncLLMClient, LLMClient WALLET_KEY = os.environ.get("BASE_CHAIN_WALLET_KEY") PRODUCTION_API = "https://blockrun.ai/api" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 883399c..65b549c 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,13 +1,16 @@ """Unit tests for LLMClient.""" -import pytest from unittest.mock import Mock, patch -from blockrun_llm import LLMClient, APIError + +import pytest + +from blockrun_llm import APIError, LLMClient + from ..helpers import ( TEST_PRIVATE_KEY, + MockResponse, build_error_response, build_models_response, - MockResponse, ) diff --git a/tests/unit/test_cost_log.py b/tests/unit/test_cost_log.py index 4360acd..40a41bd 100644 --- a/tests/unit/test_cost_log.py +++ b/tests/unit/test_cost_log.py @@ -16,8 +16,7 @@ def _write_log(path, rows): with open(path, "w") as f: - for row in rows: - f.write(json.dumps(row) + "\n") + f.writelines(json.dumps(row) + "\n" for row in rows) @pytest.fixture diff --git a/tests/unit/test_image_edit.py b/tests/unit/test_image_edit.py index 9ac8fe4..2e65122 100644 --- a/tests/unit/test_image_edit.py +++ b/tests/unit/test_image_edit.py @@ -11,7 +11,6 @@ from __future__ import annotations import json -from typing import List import httpx @@ -22,7 +21,7 @@ DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC" -def _image_edit_transport(calls: List[httpx.Request]) -> httpx.MockTransport: +def _image_edit_transport(calls: list[httpx.Request]) -> httpx.MockTransport: """First POST → 402 with payment requirements; retry with signature → 200.""" def handler(request: httpx.Request) -> httpx.Response: @@ -48,14 +47,14 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.MockTransport(handler) -def _make_client(calls: List[httpx.Request]) -> ImageClient: +def _make_client(calls: list[httpx.Request]) -> ImageClient: client = ImageClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client(transport=_image_edit_transport(calls)) return client def test_edit_single_image_passes_string_through(): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(calls) result = client.edit("Make the sky purple", image=DATA_URI) @@ -72,7 +71,7 @@ def test_edit_single_image_passes_string_through(): def test_edit_defaults_to_gpt_image_2(): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(calls) client.edit("Make the sky purple", image=DATA_URI) @@ -83,7 +82,7 @@ def test_edit_defaults_to_gpt_image_2(): def test_edit_multi_image_passes_list_through(): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(calls) images = [DATA_URI, DATA_URI] diff --git a/tests/unit/test_image_poll.py b/tests/unit/test_image_poll.py index 5d2c74f..acc897d 100644 --- a/tests/unit/test_image_poll.py +++ b/tests/unit/test_image_poll.py @@ -13,8 +13,6 @@ from __future__ import annotations -from typing import List - import httpx import pytest @@ -47,7 +45,7 @@ def test_image_generate_polls_to_completion_on_202(monkeypatch: pytest.MonkeyPat status=completed, then return the image.""" monkeypatch.setattr(ImageClient, "IMAGE_POLL_INTERVAL_SECONDS", 0.0) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] poll_state = {"count": 0} def handler(request: httpx.Request) -> httpx.Response: @@ -210,7 +208,7 @@ def test_image_generate_fast_path_unchanged(monkeypatch: pytest.MonkeyPatch) -> identically — the poll path is only entered on 202.""" monkeypatch.setattr(ImageClient, "IMAGE_POLL_INTERVAL_SECONDS", 0.0) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) diff --git a/tests/unit/test_invalid_message_fail_fast.py b/tests/unit/test_invalid_message_fail_fast.py index 9aa29fb..d53f17f 100644 --- a/tests/unit/test_invalid_message_fail_fast.py +++ b/tests/unit/test_invalid_message_fail_fast.py @@ -15,8 +15,8 @@ from typing import Any from blockrun_llm.solana_client import ( - _is_unrecoverable_payment_error, _is_permanent_payment_error, + _is_unrecoverable_payment_error, ) from blockrun_llm.validation import build_payment_rejected_error diff --git a/tests/unit/test_passthrough_defi_dex_modal.py b/tests/unit/test_passthrough_defi_dex_modal.py index 17ba20a..a7ab409 100644 --- a/tests/unit/test_passthrough_defi_dex_modal.py +++ b/tests/unit/test_passthrough_defi_dex_modal.py @@ -1,6 +1,7 @@ """Unit tests for the DefiLlama / 0x DEX / Modal passthrough methods.""" import os + import pytest from blockrun_llm import LLMClient diff --git a/tests/unit/test_payment_error_helper.py b/tests/unit/test_payment_error_helper.py index db1f75d..945f5ea 100644 --- a/tests/unit/test_payment_error_helper.py +++ b/tests/unit/test_payment_error_helper.py @@ -8,8 +8,7 @@ from __future__ import annotations -from typing import Any, Dict - +from typing import Any from blockrun_llm.types import PaymentError from blockrun_llm.validation import build_payment_rejected_error @@ -55,7 +54,7 @@ class TestBuildPaymentRejectedError: def test_preserves_gateway_details(self) -> None: """The whole reason this helper exists: ``details`` must survive from the gateway's body to ``exc.response`` and into ``str(exc)``.""" - gateway_body: Dict[str, Any] = { + gateway_body: dict[str, Any] = { "error": "Payment settlement failed", "details": "transaction_simulation_failed", } diff --git a/tests/unit/test_portrait.py b/tests/unit/test_portrait.py index 6b3b7e6..3d2b8bc 100644 --- a/tests/unit/test_portrait.py +++ b/tests/unit/test_portrait.py @@ -1,6 +1,7 @@ """Unit tests for PortraitClient input validation.""" import os + import pytest from blockrun_llm import PortraitClient diff --git a/tests/unit/test_realface.py b/tests/unit/test_realface.py index 3e60a4f..5719ff1 100644 --- a/tests/unit/test_realface.py +++ b/tests/unit/test_realface.py @@ -1,6 +1,7 @@ """Unit tests for RealFaceClient input validation.""" import os + import pytest from blockrun_llm import RealFaceClient diff --git a/tests/unit/test_rpc.py b/tests/unit/test_rpc.py index c4f6f1d..ba56729 100644 --- a/tests/unit/test_rpc.py +++ b/tests/unit/test_rpc.py @@ -1,10 +1,11 @@ """Unit tests for RpcClient request construction and response parsing.""" import os + import httpx import pytest -from blockrun_llm import RpcClient, RpcResponse, SUPPORTED_NETWORKS, NETWORK_ALIASES +from blockrun_llm import NETWORK_ALIASES, SUPPORTED_NETWORKS, RpcClient, RpcResponse @pytest.fixture diff --git a/tests/unit/test_solana_client.py b/tests/unit/test_solana_client.py index c427c63..9778aec 100644 --- a/tests/unit/test_solana_client.py +++ b/tests/unit/test_solana_client.py @@ -1,8 +1,10 @@ """Unit tests for SolanaLLMClient.""" -import pytest import os -from blockrun_llm.solana_client import SolanaLLMClient, AsyncSolanaLLMClient + +import pytest + +from blockrun_llm.solana_client import AsyncSolanaLLMClient, SolanaLLMClient TEST_BS58_KEY = ( "433C7KFcM4y1ZEVdZYSH7wheSNAM384UcbgXEyD5FV7Q2HsQ1BwjEDx4GbBZUqPkZTVhFPyLyuZnzK8wCeAkU7wG" diff --git a/tests/unit/test_solana_max_tokens.py b/tests/unit/test_solana_max_tokens.py index d8d0464..614a87d 100644 --- a/tests/unit/test_solana_max_tokens.py +++ b/tests/unit/test_solana_max_tokens.py @@ -12,7 +12,7 @@ from __future__ import annotations -import unittest.mock as mock +from unittest import mock import httpx import pytest @@ -20,8 +20,8 @@ pytest.importorskip("x402") pytest.importorskip("solders") -from blockrun_llm import SolanaLLMClient # noqa: E402 -from blockrun_llm.validation import MAX_TOKENS_SANITY_LIMIT # noqa: E402 +from blockrun_llm import SolanaLLMClient +from blockrun_llm.validation import MAX_TOKENS_SANITY_LIMIT MESSAGES = [{"role": "user", "content": "hi"}] diff --git a/tests/unit/test_solana_media.py b/tests/unit/test_solana_media.py index 92e06f3..4d618a5 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -10,7 +10,7 @@ from __future__ import annotations from types import SimpleNamespace -from typing import Any, Dict, List +from typing import Any from unittest import mock import httpx @@ -22,19 +22,18 @@ pytest.importorskip("x402") pytest.importorskip("solders") -from blockrun_llm.solana_client import ( # noqa: E402 +from blockrun_llm.solana_client import ( AsyncSolanaLLMClient, SolanaLLMClient, _assert_same_payment_terms, ) -from blockrun_llm.types import ( # noqa: E402 +from blockrun_llm.types import ( APIError, MusicResponse, PaymentError, SpeechResponse, ) - # --------------------------------------------------------------------------- # _assert_same_payment_terms — the mid-poll re-sign guard # --------------------------------------------------------------------------- @@ -109,7 +108,7 @@ class accepted: return client -def _paid_flow(calls: List[httpx.Request], ok_body: Dict[str, Any]): +def _paid_flow(calls: list[httpx.Request], ok_body: dict[str, Any]): """402 on the unsigned probe, then ``ok_body`` once signed. Captures the signed request so tests can assert the forwarded JSON body + path.""" @@ -138,7 +137,7 @@ class TestMediaDispatch: def test_music_body_and_response(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _MUSIC_OK)) resp = client.music("lo-fi beats") assert isinstance(resp, MusicResponse) @@ -151,7 +150,7 @@ def test_music_body_and_response(self) -> None: def test_speech_body_and_response(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _SPEECH_OK)) resp = client.speech("hello world", voice="sarah") assert isinstance(resp, SpeechResponse) @@ -162,7 +161,7 @@ def test_speech_body_and_response(self) -> None: assert sent["voice"] == "sarah" def test_sound_effect_endpoint(self) -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _SPEECH_OK)) client.sound_effect("thunder clap") assert calls[-1].url.path == "/api/v1/audio/sound-effects" @@ -305,7 +304,7 @@ class accepted: return client -def _resign_handler(signed_poll_codes: List[int]): +def _resign_handler(signed_poll_codes: list[int]): """Drive a video job through the mid-poll re-sign path. probe → 402; signed POST → 202 + poll_url; each *signed* GET poll returns @@ -346,7 +345,7 @@ def handler(request: httpx.Request) -> httpx.Response: return handler -_HELPER_KW: Dict[str, Any] = { +_HELPER_KW: dict[str, Any] = { "poll_budget_seconds": 5.0, "poll_interval_seconds": 0.001, "max_resigns": 2, @@ -413,7 +412,7 @@ async def test_async_resign_reprice_propagates(self, monkeypatch: pytest.MonkeyP # --------------------------------------------------------------------------- -def _fresh_sig_handler(n_in_progress: int, poll_sigs: List[str]): +def _fresh_sig_handler(n_in_progress: int, poll_sigs: list[str]): """Video job that NEVER 402s on a poll: n_in_progress in-progress polls, then completed. Records the PAYMENT-SIGNATURE seen on every signed poll so a test can assert the proactive re-sign refreshed it each time.""" @@ -464,7 +463,7 @@ def test_sync_refreshes_signature_every_poll(self, monkeypatch: pytest.MonkeyPat # Fire the proactive re-sign on every poll (0s freshness window). monkeypatch.setattr(SolanaLLMClient, "MEDIA_RESIGN_FRESH_SECONDS", 0.0) - poll_sigs: List[str] = [] + poll_sigs: list[str] = [] client = _make_client(_fresh_sig_handler(3, poll_sigs)) data = client._request_image_with_payment( "/v1/videos/generations", dict(_VIDEO_BODY), **_HELPER_KW @@ -489,7 +488,7 @@ async def test_async_refreshes_signature_every_poll( ) monkeypatch.setattr(SolanaLLMClient, "MEDIA_RESIGN_FRESH_SECONDS", 0.0) - poll_sigs: List[str] = [] + poll_sigs: list[str] = [] client = _make_async_client(_fresh_sig_handler(3, poll_sigs)) try: data = await client._request_image_with_payment( @@ -504,7 +503,7 @@ async def test_async_refreshes_signature_every_poll( # max_resigns == 0 (the image path) must NOT proactively re-sign, even with a # 0s freshness window: every poll reuses the single submit-time signature so # the image flow is provably untouched by the video-only fix. - _IMAGE_KW: Dict[str, Any] = { + _IMAGE_KW: dict[str, Any] = { "poll_budget_seconds": 5.0, "poll_interval_seconds": 0.001, "max_resigns": 0, @@ -521,7 +520,7 @@ def test_sync_image_path_never_resigns(self, monkeypatch: pytest.MonkeyPatch) -> ) monkeypatch.setattr(SolanaLLMClient, "MEDIA_RESIGN_FRESH_SECONDS", 0.0) - poll_sigs: List[str] = [] + poll_sigs: list[str] = [] client = _make_client(_fresh_sig_handler(3, poll_sigs)) data = client._request_image_with_payment( "/v1/images/generations", dict(_VIDEO_BODY), **self._IMAGE_KW @@ -543,7 +542,7 @@ async def test_async_image_path_never_resigns(self, monkeypatch: pytest.MonkeyPa ) monkeypatch.setattr(SolanaLLMClient, "MEDIA_RESIGN_FRESH_SECONDS", 0.0) - poll_sigs: List[str] = [] + poll_sigs: list[str] = [] client = _make_async_client(_fresh_sig_handler(3, poll_sigs)) try: data = await client._request_image_with_payment( @@ -575,7 +574,7 @@ class TestSolanaImageQuality: def test_image_forwards_quality(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _IMAGE_OK)) client.image("a cat", model="openai/gpt-image-2", quality="low") sent = json.loads(calls[-1].content) @@ -584,7 +583,7 @@ def test_image_forwards_quality(self) -> None: def test_image_omits_quality_when_unset(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _IMAGE_OK)) client.image("a cat") assert "quality" not in json.loads(calls[-1].content) @@ -592,7 +591,7 @@ def test_image_omits_quality_when_unset(self) -> None: def test_image_edit_forwards_quality(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _IMAGE_OK)) client.image_edit("make it green", _DATA_URI, quality="high") sent = json.loads(calls[-1].content) @@ -603,20 +602,20 @@ def test_image_edit_forwards_quality(self) -> None: def test_image_accepts_every_gateway_quality(self, value: str) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _IMAGE_OK)) client.image("a cat", model="openai/gpt-image-2", quality=value) assert json.loads(calls[-1].content)["quality"] == value def test_image_rejects_unknown_quality_before_paying(self) -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _IMAGE_OK)) with pytest.raises(ValueError, match="quality must be one of"): client.image("a cat", model="openai/gpt-image-2", quality="hd") assert calls == [] # rejected locally — no request, no payment def test_image_edit_rejects_unknown_quality_before_paying(self) -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _IMAGE_OK)) with pytest.raises(ValueError, match="quality must be one of"): client.image_edit("make it green", _DATA_URI, quality="ultra") @@ -627,7 +626,7 @@ class TestSolanaVideoInputType: def test_video_forwards_input_type(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _VIDEO_OK)) client.video( "the flower blooms", @@ -640,13 +639,13 @@ def test_video_forwards_input_type(self) -> None: def test_video_omits_input_type_when_unset(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _VIDEO_OK)) client.video("a calm lake") assert "input_type" not in json.loads(calls[-1].content) def test_video_rejects_unknown_input_type_before_paying(self) -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_paid_flow(calls, _VIDEO_OK)) with pytest.raises(ValueError, match="input_type must be one of"): client.video("x", input_type="img") @@ -686,14 +685,14 @@ class TestAsyncMediaParamParity: async def test_async_video_forwards_input_type(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_async_client(_paid_flow(calls, _VIDEO_OK)) await client.video("a calm lake", input_type="text") assert json.loads(calls[-1].content)["input_type"] == "text" @pytest.mark.asyncio async def test_async_video_rejects_unknown_input_type_before_paying(self) -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_async_client(_paid_flow(calls, _VIDEO_OK)) with pytest.raises(ValueError, match="input_type must be one of"): await client.video("x", input_type="img") @@ -703,7 +702,7 @@ async def test_async_video_rejects_unknown_input_type_before_paying(self) -> Non async def test_async_image_forwards_quality(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_async_client(_paid_flow(calls, _IMAGE_OK)) await client.image("a cat", model="openai/gpt-image-2", quality="low") assert json.loads(calls[-1].content)["quality"] == "low" @@ -712,7 +711,7 @@ async def test_async_image_forwards_quality(self) -> None: async def test_async_image_edit_forwards_quality(self) -> None: import json - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_async_client(_paid_flow(calls, _IMAGE_OK)) await client.image_edit("make it green", _DATA_URI, quality="high") assert json.loads(calls[-1].content)["quality"] == "high" @@ -720,7 +719,7 @@ async def test_async_image_edit_forwards_quality(self) -> None: @pytest.mark.asyncio async def test_async_image_rejects_unknown_quality_before_paying(self) -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_async_client(_paid_flow(calls, _IMAGE_OK)) with pytest.raises(ValueError, match="quality must be one of"): await client.image("a cat", quality="hd") diff --git a/tests/unit/test_solana_settled_payment.py b/tests/unit/test_solana_settled_payment.py index 1d4d000..51a8049 100644 --- a/tests/unit/test_solana_settled_payment.py +++ b/tests/unit/test_solana_settled_payment.py @@ -16,9 +16,9 @@ pytest.importorskip("x402") pytest.importorskip("solders") -from blockrun_llm.client import _mark_settled # noqa: E402 -from blockrun_llm.solana_client import _should_fallback_solana # noqa: E402 -from blockrun_llm.types import APIError, PaymentError # noqa: E402 +from blockrun_llm.client import _mark_settled +from blockrun_llm.solana_client import _should_fallback_solana +from blockrun_llm.types import APIError, PaymentError class TestSolanaSettledTag: diff --git a/tests/unit/test_solana_timeout_routing.py b/tests/unit/test_solana_timeout_routing.py index 1909181..eba6d78 100644 --- a/tests/unit/test_solana_timeout_routing.py +++ b/tests/unit/test_solana_timeout_routing.py @@ -17,8 +17,7 @@ from __future__ import annotations -import unittest.mock as mock -from typing import List +from unittest import mock import httpx import pytest @@ -26,9 +25,8 @@ pytest.importorskip("x402") pytest.importorskip("solders") -from blockrun_llm import SolanaLLMClient # noqa: E402 -from blockrun_llm import solana_client as sol # noqa: E402 - +from blockrun_llm import SolanaLLMClient +from blockrun_llm import solana_client as sol # --------------------------------------------------------------------------- # Fixtures / helpers @@ -105,7 +103,7 @@ def _payment_required(request: httpx.Request) -> httpx.Response: _SEARCH_OK = {"query": "q", "summary": "s"} -def _json_flow(calls: List[httpx.Request], ok_body: dict) -> httpx.MockTransport: +def _json_flow(calls: list[httpx.Request], ok_body: dict) -> httpx.MockTransport: """402 on the unsigned probe, then the success body once signed.""" def handler(request: httpx.Request) -> httpx.Response: @@ -123,14 +121,14 @@ def handler(request: httpx.Request) -> httpx.Response: def test_chat_uses_chat_timeout_default() -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_json_flow(calls, _CHAT_OK)) client.chat_completion("openai/gpt-5.2", [{"role": "user", "content": "hi"}]) assert _read_timeout(calls[-1]) == sol.DEFAULT_CHAT_TIMEOUT def test_image_uses_image_timeout_default() -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_json_flow(calls, _IMAGE_OK)) client.image("a cat", model="openai/gpt-image-2") # Probe + signed submit both carry the image budget, not the chat one. @@ -140,14 +138,14 @@ def test_image_uses_image_timeout_default() -> None: def test_search_uses_search_timeout_default() -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_json_flow(calls, _SEARCH_OK)) client.search("deep query") assert _read_timeout(calls[-1]) == sol.DEFAULT_SEARCH_TIMEOUT def test_exa_uses_search_timeout_default() -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_json_flow(calls, {"results": []})) client.exa_search("latest AI papers") assert _read_timeout(calls[-1]) == sol.DEFAULT_SEARCH_TIMEOUT @@ -159,7 +157,7 @@ def test_exa_uses_search_timeout_default() -> None: def test_chat_per_call_override() -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_json_flow(calls, _CHAT_OK)) client.chat_completion("openai/gpt-5.2", [{"role": "user", "content": "hi"}], timeout=7.0) assert _read_timeout(calls[0]) == 7.0 @@ -167,14 +165,14 @@ def test_chat_per_call_override() -> None: def test_image_per_call_override() -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_json_flow(calls, _IMAGE_OK)) client.image("a cat", model="openai/gpt-image-2", timeout=9.0) assert _read_timeout(calls[-1]) == 9.0 def test_search_per_call_override() -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_json_flow(calls, _SEARCH_OK)) client.search("q", timeout=11.0) assert _read_timeout(calls[-1]) == 11.0 @@ -186,12 +184,12 @@ def test_search_per_call_override() -> None: def test_constructor_image_and_search_timeout_respected() -> None: - img_calls: List[httpx.Request] = [] + img_calls: list[httpx.Request] = [] img_client = _make_client(_json_flow(img_calls, _IMAGE_OK), image_timeout=42.0) img_client.image("a cat", model="openai/gpt-image-2") assert _read_timeout(img_calls[-1]) == 42.0 - s_calls: List[httpx.Request] = [] + s_calls: list[httpx.Request] = [] s_client = _make_client(_json_flow(s_calls, _SEARCH_OK), search_timeout=99.0) s_client.search("q") assert _read_timeout(s_calls[-1]) == 99.0 @@ -200,7 +198,7 @@ def test_constructor_image_and_search_timeout_respected() -> None: def test_legacy_flat_timeout_still_governs_chat() -> None: """Backwards-compat: old ``SolanaLLMClient(timeout=...)`` callers keep controlling the chat budget through the single keyword.""" - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_client(_json_flow(calls, _CHAT_OK), timeout=33.0) client.chat_completion("openai/gpt-5.2", [{"role": "user", "content": "hi"}]) assert _read_timeout(calls[-1]) == 33.0 @@ -239,7 +237,7 @@ class accepted: async def test_async_chat_uses_chat_timeout_default() -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_async_client(_json_flow(calls, _CHAT_OK)) await client.chat_completion("openai/gpt-5.2", [{"role": "user", "content": "hi"}]) assert _read_timeout(calls[-1]) == sol.DEFAULT_CHAT_TIMEOUT @@ -247,7 +245,7 @@ async def test_async_chat_uses_chat_timeout_default() -> None: async def test_async_chat_per_call_override() -> None: - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = _make_async_client(_json_flow(calls, _CHAT_OK)) await client.chat_completion( "openai/gpt-5.2", [{"role": "user", "content": "hi"}], timeout=13.0 diff --git a/tests/unit/test_solana_wallet.py b/tests/unit/test_solana_wallet.py index ab47e2b..8f6ea6e 100644 --- a/tests/unit/test_solana_wallet.py +++ b/tests/unit/test_solana_wallet.py @@ -1,10 +1,11 @@ """Unit tests for Solana wallet utilities.""" import pytest + from blockrun_llm.solana_wallet import ( create_solana_wallet, - solana_key_to_bytes, get_solana_public_key, + solana_key_to_bytes, ) # A valid test bs58 secret key (64 bytes, valid keypair from deterministic seed) diff --git a/tests/unit/test_speech.py b/tests/unit/test_speech.py index e918efe..3042b47 100644 --- a/tests/unit/test_speech.py +++ b/tests/unit/test_speech.py @@ -1,6 +1,7 @@ """Unit tests for SpeechClient request construction and response parsing.""" import os + import pytest from blockrun_llm import SpeechClient, SpeechResponse diff --git a/tests/unit/test_streaming.py b/tests/unit/test_streaming.py index 2ba29c9..572f7f6 100644 --- a/tests/unit/test_streaming.py +++ b/tests/unit/test_streaming.py @@ -18,7 +18,6 @@ from __future__ import annotations import json -from typing import List import httpx import pytest @@ -28,15 +27,14 @@ from ..helpers import TEST_PRIVATE_KEY, build_payment_required_response - # --------------------------------------------------------------------------- # Synthetic SSE bodies # --------------------------------------------------------------------------- -def _sse_events(deltas: List[str], finish: str = "stop", model: str = "test/model") -> bytes: +def _sse_events(deltas: list[str], finish: str = "stop", model: str = "test/model") -> bytes: """Render a list of content deltas as raw SSE bytes ending with [DONE].""" - lines: List[str] = [] + lines: list[str] = [] # First chunk — role only. lines.append( "data: " @@ -82,7 +80,7 @@ def _sse_events(deltas: List[str], finish: str = "stop", model: str = "test/mode return body.encode("utf-8") -def _sse_with_garbage(deltas: List[str]) -> bytes: +def _sse_with_garbage(deltas: list[str]) -> bytes: """Same as ``_sse_events`` but with a couple of malformed lines mixed in to verify the parser is tolerant.""" base = _sse_events(deltas).decode("utf-8") @@ -98,7 +96,7 @@ def _sse_with_garbage(deltas: List[str]) -> bytes: # --------------------------------------------------------------------------- -def _make_free_model_transport(sse_body: bytes, calls: List[httpx.Request]) -> httpx.MockTransport: +def _make_free_model_transport(sse_body: bytes, calls: list[httpx.Request]) -> httpx.MockTransport: def handler(request: httpx.Request) -> httpx.Response: calls.append(request) return httpx.Response( @@ -110,7 +108,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.MockTransport(handler) -def _make_paid_model_transport(sse_body: bytes, calls: List[httpx.Request]) -> httpx.MockTransport: +def _make_paid_model_transport(sse_body: bytes, calls: list[httpx.Request]) -> httpx.MockTransport: """First call → 402 with valid payment-required header; second → 200 SSE.""" def handler(request: httpx.Request) -> httpx.Response: @@ -140,13 +138,13 @@ def handler(request: httpx.Request) -> httpx.Response: class TestSyncStreaming: def test_free_model_streams_without_payment(self): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = LLMClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client( transport=_make_free_model_transport(_sse_events(["Hello", " world"]), calls) ) - chunks: List[ChatCompletionChunk] = list( + chunks: list[ChatCompletionChunk] = list( client.chat_completion_stream( "nvidia/deepseek-v4-flash", [{"role": "user", "content": "hi"}], @@ -168,7 +166,7 @@ def test_free_model_streams_without_payment(self): assert finishes == ["stop"] def test_paid_model_signs_and_retries(self): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = LLMClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client( transport=_make_paid_model_transport(_sse_events(["Paid"]), calls) @@ -199,7 +197,7 @@ def test_paid_stream_chunks_carry_real_cost(self): """Every paid-path chunk carries the real per-call x402 charge as ``chunk.cost_usd`` (race-free, vs the shared ``_last_call_cost``), so a streaming consumer can report the actual wallet deduction.""" - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = LLMClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client( transport=_make_paid_model_transport(_sse_events(["Paid"]), calls) @@ -221,7 +219,7 @@ def test_paid_stream_chunks_carry_real_cost(self): def test_free_stream_chunks_have_no_cost(self): """Free models skip the 402/sign path (and the archive), so chunks carry no ``cost_usd`` — consumers treat that as 'no real charge'.""" - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = LLMClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client( transport=_make_free_model_transport(_sse_events(["hi"]), calls) @@ -236,7 +234,7 @@ def test_free_stream_chunks_have_no_cost(self): assert all(getattr(c, "cost_usd", None) is None for c in chunks) def test_malformed_chunks_dont_abort_stream(self): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = LLMClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client( transport=_make_free_model_transport(_sse_with_garbage(["A", "B"]), calls) @@ -254,7 +252,7 @@ def test_malformed_chunks_dont_abort_stream(self): def test_paid_path_propagates_payment_rejected(self): """If the retry also returns 402, surface PaymentError.""" - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) @@ -289,7 +287,7 @@ def handler(request: httpx.Request) -> httpx.Response: class TestAsyncStreaming: @pytest.mark.asyncio async def test_async_free_model(self): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = AsyncLLMClient(private_key=TEST_PRIVATE_KEY) # Swap in mock transport (same pattern as sync). await client._client.aclose() @@ -297,7 +295,7 @@ async def test_async_free_model(self): transport=_make_free_model_transport(_sse_events(["Hi", "!"]), calls) ) - chunks: List[ChatCompletionChunk] = [] + chunks: list[ChatCompletionChunk] = [] async for chunk in client.chat_completion_stream( "nvidia/deepseek-v4-flash", [{"role": "user", "content": "hi"}], @@ -313,14 +311,14 @@ async def test_async_free_model(self): @pytest.mark.asyncio async def test_async_paid_model_signs_and_retries(self): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = AsyncLLMClient(private_key=TEST_PRIVATE_KEY) await client._client.aclose() client._client = httpx.AsyncClient( transport=_make_paid_model_transport(_sse_events(["X"]), calls) ) - chunks: List[ChatCompletionChunk] = [] + chunks: list[ChatCompletionChunk] = [] async for chunk in client.chat_completion_stream( "openai/gpt-5.5", [{"role": "user", "content": "hi"}], @@ -341,7 +339,7 @@ async def test_async_paid_model_signs_and_retries(self): def _make_flaky_free_transport( sse_body: bytes, fail_count: int, - calls: List[httpx.Request], + calls: list[httpx.Request], status: int = 503, ) -> httpx.MockTransport: """Returns ``status`` (default 503) for the first ``fail_count`` requests, @@ -366,7 +364,7 @@ def test_recovers_after_two_503s(self, monkeypatch): # Zero out sleeps to keep tests fast. monkeypatch.setattr("time.sleep", lambda _s: None) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = LLMClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client( transport=_make_flaky_free_transport(_sse_events(["OK"]), fail_count=2, calls=calls) @@ -384,7 +382,7 @@ def test_recovers_after_two_503s(self, monkeypatch): def test_raises_after_exhausting_retries(self, monkeypatch): monkeypatch.setattr("time.sleep", lambda _s: None) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) @@ -414,7 +412,7 @@ def test_5xx_retry_also_works_after_payment(self, monkeypatch): also trigger in-band retries before raising.""" monkeypatch.setattr("time.sleep", lambda _s: None) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] body = _sse_events(["paid-OK"]) def handler(request: httpx.Request) -> httpx.Response: @@ -462,7 +460,7 @@ class TestStreamingFallback: def test_falls_back_to_next_model_on_503(self, monkeypatch): monkeypatch.setattr("time.sleep", lambda _s: None) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) @@ -502,15 +500,15 @@ def test_no_fallback_after_first_chunk(self, monkeypatch): # Build SSE that's truncated (no [DONE]) so iter_lines simulates a # mid-stream connection drop via httpx parsing exception. truncated = ( - 'data: {"id":"x","object":"chat.completion.chunk","created":1,' - '"model":"primary/bad","choices":[{"index":0,"delta":{"role":"assistant"},' - '"finish_reason":null}]}\n\n' - 'data: {"id":"x","object":"chat.completion.chunk","created":1,' - '"model":"primary/bad","choices":[{"index":0,"delta":{"content":"par"},' - '"finish_reason":null}]}\n\n' - ).encode() + b'data: {"id":"x","object":"chat.completion.chunk","created":1,' + b'"model":"primary/bad","choices":[{"index":0,"delta":{"role":"assistant"},' + b'"finish_reason":null}]}\n\n' + b'data: {"id":"x","object":"chat.completion.chunk","created":1,' + b'"model":"primary/bad","choices":[{"index":0,"delta":{"content":"par"},' + b'"finish_reason":null}]}\n\n' + ) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) @@ -545,7 +543,7 @@ def test_non_retriable_error_does_not_fall_back(self, monkeypatch): permanent client errors, not transient upstream issues.""" monkeypatch.setattr("time.sleep", lambda _s: None) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) @@ -634,8 +632,8 @@ def _sse_with_tool_call(model: str = "anthropic/claude-haiku-4-5") -> bytes: return ("\n\n".join(lines) + "\n\n").encode("utf-8") -def _collect_tool_args(chunks: List[ChatCompletionChunk]) -> str: - out: List[str] = [] +def _collect_tool_args(chunks: list[ChatCompletionChunk]) -> str: + out: list[str] = [] for c in chunks: if not c.choices: continue @@ -655,7 +653,7 @@ class TestStreamedToolCalls: """ def test_sync_streamed_tool_call(self): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = LLMClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client( transport=_make_paid_model_transport(_sse_with_tool_call(), calls) @@ -679,13 +677,13 @@ def test_sync_streamed_tool_call(self): @pytest.mark.asyncio async def test_async_streamed_tool_call(self): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = AsyncLLMClient(private_key=TEST_PRIVATE_KEY) await client._client.aclose() client._client = httpx.AsyncClient( transport=_make_paid_model_transport(_sse_with_tool_call(), calls) ) - chunks: List[ChatCompletionChunk] = [] + chunks: list[ChatCompletionChunk] = [] async for chunk in client.chat_completion_stream( "openai/gpt-5.5", [{"role": "user", "content": "weather?"}], @@ -736,7 +734,7 @@ def test_sync_streamed_tool_call_non_function_type(self): sse = ( "\n\n".join("data: " + json.dumps(f) for f in frames) + "\n\ndata: [DONE]\n\n" ).encode() - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = LLMClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client(transport=_make_paid_model_transport(sse, calls)) chunks = list( @@ -777,7 +775,7 @@ def test_sync_stream_archive_survives_model_construct_chunk_missing_id(self): sse = ( "\n\n".join("data: " + json.dumps(f) for f in frames) + "\n\ndata: [DONE]\n\n" ).encode() - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] client = LLMClient(private_key=TEST_PRIVATE_KEY) client._client = httpx.Client(transport=_make_paid_model_transport(sse, calls)) # Must not raise: the archive loop reads chunk.id via the dict/attr-tolerant diff --git a/tests/unit/test_streaming_solana.py b/tests/unit/test_streaming_solana.py index dafc22f..386db30 100644 --- a/tests/unit/test_streaming_solana.py +++ b/tests/unit/test_streaming_solana.py @@ -11,7 +11,6 @@ from __future__ import annotations import json -from typing import List import httpx import pytest @@ -23,14 +22,13 @@ from blockrun_llm import SolanaLLMClient from blockrun_llm.types import APIError, PaymentError - # --------------------------------------------------------------------------- # Helpers — synthetic SSE bodies (same shape Base tests use) # --------------------------------------------------------------------------- -def _sse_events(deltas: List[str], finish: str = "stop", model: str = "test/model") -> bytes: - lines: List[str] = [] +def _sse_events(deltas: list[str], finish: str = "stop", model: str = "test/model") -> bytes: + lines: list[str] = [] lines.append( "data: " + json.dumps( @@ -82,7 +80,7 @@ def solana_client(): """Build a SolanaLLMClient without going through the x402 SDK signer init (which needs real keys + an RPC). We monkey-patch the signer after construction by replacing the x402_client with a fake.""" - import unittest.mock as mock + from unittest import mock with ( mock.patch("blockrun_llm.solana_client.register_exact_svm_client"), @@ -123,7 +121,7 @@ def _patch_sse_helpers(monkeypatch): # --------------------------------------------------------------------------- -def _free_transport(sse_body: bytes, calls: List[httpx.Request]) -> httpx.MockTransport: +def _free_transport(sse_body: bytes, calls: list[httpx.Request]) -> httpx.MockTransport: def handler(request: httpx.Request) -> httpx.Response: calls.append(request) return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=sse_body) @@ -131,7 +129,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.MockTransport(handler) -def _paid_transport(sse_body: bytes, calls: List[httpx.Request]) -> httpx.MockTransport: +def _paid_transport(sse_body: bytes, calls: list[httpx.Request]) -> httpx.MockTransport: """First call → 402; second call (with PAYMENT-SIGNATURE) → 200 SSE.""" def handler(request: httpx.Request) -> httpx.Response: @@ -151,7 +149,7 @@ def handler(request: httpx.Request) -> httpx.Response: def _flaky_transport( - sse_body: bytes, fail_count: int, calls: List[httpx.Request], status: int = 503 + sse_body: bytes, fail_count: int, calls: list[httpx.Request], status: int = 503 ) -> httpx.MockTransport: def handler(request: httpx.Request) -> httpx.Response: calls.append(request) @@ -169,7 +167,7 @@ def handler(request: httpx.Request) -> httpx.Response: class TestSolanaStreaming: def test_free_model_streams_directly(self, solana_client, monkeypatch): - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] solana_client._client = httpx.Client( transport=_free_transport(_sse_events(["Hello", " world"]), calls) ) @@ -188,7 +186,7 @@ def test_free_model_streams_directly(self, solana_client, monkeypatch): def test_paid_model_signs_and_retries(self, solana_client, monkeypatch): _patch_sse_helpers(monkeypatch) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] solana_client._client = httpx.Client( transport=_paid_transport(_sse_events(["Paid"]), calls) ) @@ -210,7 +208,7 @@ def test_paid_model_signs_and_retries(self, solana_client, monkeypatch): def test_retries_5xx_with_backoff(self, solana_client, monkeypatch): monkeypatch.setattr("time.sleep", lambda _s: None) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] solana_client._client = httpx.Client( transport=_flaky_transport(_sse_events(["OK"]), fail_count=2, calls=calls) ) @@ -227,7 +225,7 @@ def test_retries_5xx_with_backoff(self, solana_client, monkeypatch): def test_raises_after_exhausting_retries(self, solana_client, monkeypatch): monkeypatch.setattr("time.sleep", lambda _s: None) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) @@ -248,7 +246,7 @@ def handler(request: httpx.Request) -> httpx.Response: def test_fallback_models_walks_chain(self, solana_client, monkeypatch): _patch_sse_helpers(monkeypatch) monkeypatch.setattr("time.sleep", lambda _s: None) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) @@ -277,7 +275,7 @@ def handler(request: httpx.Request) -> httpx.Response: def test_payment_rejected_raises_payment_error(self, solana_client, monkeypatch): _patch_sse_helpers(monkeypatch) monkeypatch.setattr("time.sleep", lambda _s: None) - calls: List[httpx.Request] = [] + calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) diff --git a/tests/unit/test_tx_log.py b/tests/unit/test_tx_log.py index 9be1ff5..e39a243 100644 --- a/tests/unit/test_tx_log.py +++ b/tests/unit/test_tx_log.py @@ -16,7 +16,6 @@ import json from pathlib import Path - from blockrun_llm.tx_log import ( DEFAULT_LOG_DIR, TransactionLogger, @@ -25,7 +24,6 @@ format_row, ) - # --------------------------------------------------------------------------- # Logger writes # --------------------------------------------------------------------------- diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 89ea00b..3ae142e 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -1,16 +1,17 @@ """Unit tests for validation module.""" import pytest + from blockrun_llm.validation import ( MAX_TOKENS_SANITY_LIMIT, - validate_private_key, + sanitize_error_response, validate_api_url, - validate_model, validate_max_tokens, + validate_model, + validate_private_key, + validate_resource_url, validate_temperature, validate_top_p, - sanitize_error_response, - validate_resource_url, ) diff --git a/tests/unit/test_video_params.py b/tests/unit/test_video_params.py index b35dcbe..4cd3d6e 100644 --- a/tests/unit/test_video_params.py +++ b/tests/unit/test_video_params.py @@ -1,6 +1,7 @@ """Unit tests for VideoClient.generate() parameter validation and body construction.""" import os + import pytest from blockrun_llm import VideoClient diff --git a/tests/unit/test_x402.py b/tests/unit/test_x402.py index a93ac25..0ccb26d 100644 --- a/tests/unit/test_x402.py +++ b/tests/unit/test_x402.py @@ -1,14 +1,17 @@ """Unit tests for x402 payment protocol.""" -import pytest import base64 import json + +import pytest + from blockrun_llm.x402 import ( create_nonce, create_payment_payload, - parse_payment_required, extract_payment_details, + parse_payment_required, ) + from ..helpers import TEST_ACCOUNT, TEST_RECIPIENT @@ -286,8 +289,8 @@ def test_decode_solana_payment_required(self): def test_keypair_signer_address(self): """KeypairSigner should derive correct public key from bs58 secret.""" - from x402.mechanisms.svm import KeypairSigner from solders.keypair import Keypair + from x402.mechanisms.svm import KeypairSigner # Generate a valid keypair and get its base58 representation kp = Keypair() From da84ea77dd7e0db479e9d6fda5b1b95c6a624c27 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 27 Jul 2026 22:48:12 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20keep=20types.py=20on=20typing=20cons?= =?UTF-8?q?tructs=20=E2=80=94=20pydantic=20evaluates=20annotations=20at=20?= =?UTF-8?q?runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on Python 3.9 caught what my local check did not: TypeError: Unable to evaluate type annotation 'str | None' `from __future__ import annotations` makes annotations strings, which is what made the PEP 604 rewrite safe everywhere else. pydantic then EVALUATES those strings to build each model, and on 3.9 evaluating "str | None" is a TypeError regardless of how it got there. My verification was wrong in a specific way worth naming: I ran compileall under a real 3.9 and it passed, because parsing is not the constraint — evaluation is. Nothing that only parses the file can catch this. types.py goes back to typing.Optional/List with no future import, and carries a per-file ruff ignore saying why. The other 15 files are unaffected: they have no runtime annotation reader. Now verified by running, not compiling: a real 3.9.21 venv with the package installed passes 339 tests, and 3.13 passes 436. --- blockrun_llm/types.py | 290 +++++++++++++++++++++--------------------- pyproject.toml | 11 ++ 2 files changed, 156 insertions(+), 145 deletions(-) diff --git a/blockrun_llm/types.py b/blockrun_llm/types.py index 613f0df..9b15850 100644 --- a/blockrun_llm/types.py +++ b/blockrun_llm/types.py @@ -1,8 +1,6 @@ """Type definitions for BlockRun LLM SDK.""" -from __future__ import annotations - -from typing import Any, Literal, Union +from typing import Any, Dict, List, Literal, Optional, Union from pydantic import BaseModel @@ -12,9 +10,9 @@ class FunctionDefinition(BaseModel): """Function definition for tool calling.""" name: str - description: str | None = None - parameters: dict[str, Any] | None = None - strict: bool | None = None + description: Optional[str] = None + parameters: Optional[Dict[str, Any]] = None + strict: Optional[bool] = None class Tool(BaseModel): @@ -40,7 +38,7 @@ class ToolCall(BaseModel): # Tool choice can be a string or object specifying which tool to use -ToolChoiceFunction = dict[str, Any] # {"type": "function", "function": {"name": "..."}} +ToolChoiceFunction = Dict[str, Any] # {"type": "function", "function": {"name": "..."}} ToolChoice = Union[Literal["none", "auto", "required"], ToolChoiceFunction] @@ -53,16 +51,16 @@ class ChatMessage(BaseModel): """ role: Literal["system", "user", "assistant", "tool"] - content: str | None = None - name: str | None = None # For tool messages - tool_call_id: str | None = None # For tool result messages - tool_calls: list[ToolCall] | None = None # For assistant messages with tool calls + content: Optional[str] = None + name: Optional[str] = None # For tool messages + tool_call_id: Optional[str] = None # For tool result messages + tool_calls: Optional[List[ToolCall]] = None # For assistant messages with tool calls # Extended fields returned by reasoning-capable upstream providers # (DeepSeek Reasoner, Grok 4 reasoning, xAI multi-agent, etc.). # Backend strips these from inbound requests but may forward them on the # response side, so we accept them as optional. - reasoning_content: str | None = None - thinking: str | None = None + reasoning_content: Optional[str] = None + thinking: Optional[str] = None class Config: extra = "allow" @@ -73,7 +71,7 @@ class ChatChoice(BaseModel): index: int message: ChatMessage - finish_reason: str | None = None # OpenAI-compatible; upstreams may add new values + finish_reason: Optional[str] = None # OpenAI-compatible; upstreams may add new values class Config: extra = "allow" @@ -85,11 +83,11 @@ class ChatUsage(BaseModel): prompt_tokens: int completion_tokens: int total_tokens: int - num_sources_used: int | None = None # xAI Live Search sources used + num_sources_used: Optional[int] = None # xAI Live Search sources used # Anthropic prompt caching — populated on anthropic/* models when cache # headers are sent. Reads are cheaper; writes incur a one-time surcharge. - cache_read_input_tokens: int | None = None - cache_creation_input_tokens: int | None = None + cache_read_input_tokens: Optional[int] = None + cache_creation_input_tokens: Optional[int] = None class Config: extra = "allow" @@ -107,9 +105,9 @@ class ChatResponse(BaseModel): object: str = "chat.completion" created: int model: str - choices: list[ChatChoice] - usage: ChatUsage | None = None - citations: list[str] | None = None # xAI Live Search citation URLs + choices: List[ChatChoice] + usage: Optional[ChatUsage] = None + citations: Optional[List[str]] = None # xAI Live Search citation URLs # Real x402 charge for THIS call, in USD — the exact amount debited from the # wallet (0.0 for free / cached calls). This is the authoritative number to @@ -118,8 +116,8 @@ class ChatResponse(BaseModel): # the client on every chat completion. ``settlement`` carries the decoded # on-chain receipt (tx hash / micro-USDC / network) when the facilitator # returned an X-PAYMENT-RESPONSE header. - cost_usd: float | None = None - settlement: dict[str, Any] | None = None + cost_usd: Optional[float] = None + settlement: Optional[Dict[str, Any]] = None class Config: extra = "allow" @@ -139,8 +137,8 @@ class ChatChunkFunctionCall(BaseModel): frame and ``arguments`` in fragments afterwards, so both are optional here — unlike the non-stream :class:`FunctionCall` where both are required.""" - name: str | None = None - arguments: str | None = None + name: Optional[str] = None + arguments: Optional[str] = None class Config: extra = "allow" @@ -160,13 +158,13 @@ class ChatChunkToolCall(BaseModel): lenient type keeps streamed tool calls parsing into real objects. """ - index: int | None = None - id: str | None = None + index: Optional[int] = None + id: Optional[str] = None # Kept as a free-form ``str`` (not ``Literal["function"]``) so an upstream # that streams a non-"function" tool type can't fail validation and re-trigger # the very ``model_construct`` fallback this lenient type exists to avoid. - type: str | None = None - function: ChatChunkFunctionCall | None = None + type: Optional[str] = None + function: Optional[ChatChunkFunctionCall] = None class Config: extra = "allow" @@ -181,11 +179,11 @@ class ChatChunkDelta(BaseModel): reasoning-capable upstreams. """ - role: Literal["system", "user", "assistant", "tool"] | None = None - content: str | None = None - tool_calls: list[ChatChunkToolCall] | None = None - reasoning_content: str | None = None - thinking: str | None = None + role: Optional[Literal["system", "user", "assistant", "tool"]] = None + content: Optional[str] = None + tool_calls: Optional[List[ChatChunkToolCall]] = None + reasoning_content: Optional[str] = None + thinking: Optional[str] = None class Config: extra = "allow" @@ -196,7 +194,7 @@ class ChatChunkChoice(BaseModel): index: int delta: ChatChunkDelta - finish_reason: str | None = None # OpenAI-compatible; upstreams may add new values + finish_reason: Optional[str] = None # OpenAI-compatible; upstreams may add new values class Config: extra = "allow" @@ -209,17 +207,17 @@ class ChatCompletionChunk(BaseModel): object: str = "chat.completion.chunk" created: int model: str - choices: list[ChatChunkChoice] + choices: List[ChatChunkChoice] # Usage is populated only on the final chunk for providers that support it # (some upstreams omit it entirely — callers must tolerate ``None``). - usage: ChatUsage | None = None - citations: list[str] | None = None # xAI Live Search citation URLs (final chunk only) + usage: Optional[ChatUsage] = None + citations: Optional[List[str]] = None # xAI Live Search citation URLs (final chunk only) class Config: extra = "allow" -def stream_choice_content(choice: Any) -> str | None: +def stream_choice_content(choice: Any) -> Optional[str]: """Text delta from a streaming choice, tolerant of a raw ``dict`` choice. A chunk that fails strict validation falls back to ``model_construct``, @@ -235,14 +233,14 @@ def stream_choice_content(choice: Any) -> str | None: return getattr(delta, "content", None) if delta is not None else None -def stream_choice_finish_reason(choice: Any) -> str | None: +def stream_choice_finish_reason(choice: Any) -> Optional[str]: """``finish_reason`` from a streaming choice, tolerant of a raw dict choice.""" if isinstance(choice, dict): return choice.get("finish_reason") return getattr(choice, "finish_reason", None) -def chunk_meta(chunk: Any) -> tuple[str | None, str | None, int | None]: +def chunk_meta(chunk: Any) -> "tuple[Optional[str], Optional[str], Optional[int]]": """``(id, model, created)`` of a chunk, tolerant of a ``model_construct``'d chunk that omits required fields. @@ -259,7 +257,7 @@ def chunk_meta(chunk: Any) -> tuple[str | None, str | None, int | None]: ) -def chunk_usage_dict(chunk: Any) -> dict[str, Any] | None: +def chunk_usage_dict(chunk: Any) -> Optional[Dict[str, Any]]: """``usage`` of a chunk as a dict, tolerant of a model_construct'd chunk whose ``usage`` is a raw dict (no ``.model_dump``).""" usage = getattr(chunk, "usage", None) @@ -284,10 +282,10 @@ class Model(BaseModel): available: bool = True # Extended metadata surfaced by /v1/models. `billing_mode` is one of # "paid" (per-token), "flat" (flat_price per request) or "free". - billing_mode: Literal["paid", "flat", "free"] | None = None - flat_price: float | None = None - categories: list[str] | None = None # e.g. ["chat","reasoning","coding","vision"] - hidden: bool | None = None # True for deprecated/superseded models still routable + billing_mode: Optional[Literal["paid", "flat", "free"]] = None + flat_price: Optional[float] = None + categories: Optional[List[str]] = None # e.g. ["chat","reasoning","coding","vision"] + hidden: Optional[bool] = None # True for deprecated/superseded models still routable class PaymentRequirement(BaseModel): @@ -305,7 +303,7 @@ class PaymentRequired(BaseModel): """x402 payment required response.""" x402_version: int = 1 - accepts: list[PaymentRequirement] + accepts: List[PaymentRequirement] class BlockrunError(Exception): @@ -325,8 +323,8 @@ def __init__( self, message: str, *, - status_code: int | None = None, - response: dict | None = None, + status_code: Optional[int] = None, + response: Optional[dict] = None, ) -> None: super().__init__(message) self.status_code = status_code @@ -363,7 +361,7 @@ def __init__( class APIError(BlockrunError): """API-related error.""" - def __init__(self, message: str, status_code: int, response: dict | None = None): + def __init__(self, message: str, status_code: int, response: Optional[dict] = None): super().__init__(message) self.status_code = status_code self.response = response @@ -378,16 +376,16 @@ class ImageData(BaseModel): # permanent blockrun-hosted URL and `source_url` is the original upstream. # `backed_up` is True iff the mirror step succeeded. For data-URI results # (e.g. openai/gpt-image-1) both fields are omitted. - source_url: str | None = None - backed_up: bool | None = None - revised_prompt: str | None = None + source_url: Optional[str] = None + backed_up: Optional[bool] = None + revised_prompt: Optional[str] = None class ImageResponse(BaseModel): """Response from image generation.""" created: int - data: list[ImageData] + data: List[ImageData] class ImageModel(BaseModel): @@ -408,8 +406,8 @@ class AudioTrack(BaseModel): """A single generated audio track.""" url: str - duration_seconds: float | None = None - lyrics: str | None = None + duration_seconds: Optional[float] = None + lyrics: Optional[str] = None class MusicResponse(BaseModel): @@ -417,8 +415,8 @@ class MusicResponse(BaseModel): created: int model: str - data: list[AudioTrack] - txHash: str | None = None + data: List[AudioTrack] + txHash: Optional[str] = None class AudioModel(BaseModel): @@ -439,9 +437,9 @@ class SpeechAudio(BaseModel): """A single synthesized audio clip.""" url: str - format: str | None = None - characters: int | None = None - credits: float | None = None + format: Optional[str] = None + characters: Optional[int] = None + credits: Optional[float] = None class SpeechResponse(BaseModel): @@ -449,8 +447,8 @@ class SpeechResponse(BaseModel): created: int model: str - data: list[SpeechAudio] - txHash: str | None = None + data: List[SpeechAudio] + txHash: Optional[str] = None # Multi-chain RPC types @@ -459,9 +457,9 @@ class SpeechResponse(BaseModel): class RpcError(BaseModel): """A JSON-RPC 2.0 error object.""" - code: int | None = None - message: str | None = None - data: Any | None = None + code: Optional[int] = None + message: Optional[str] = None + data: Optional[Any] = None class RpcResponse(BaseModel): @@ -471,14 +469,14 @@ class RpcResponse(BaseModel): from response headers (X-Network / X-Cache / X-Payment-Receipt). """ - jsonrpc: str | None = None - id: str | int | None = None - result: Any | None = None - error: RpcError | None = None + jsonrpc: Optional[str] = None + id: Optional[Union[str, int]] = None + result: Optional[Any] = None + error: Optional[RpcError] = None # Gateway metadata (response headers) - network: str | None = None # canonical network key, e.g. "ethereum" + network: Optional[str] = None # canonical network key, e.g. "ethereum" cache_hit: bool = False # served from the gateway's method-aware cache - tx_hash: str | None = None # x402 settlement tx (single calls) + tx_hash: Optional[str] = None # x402 settlement tx (single calls) # Video generation types @@ -488,10 +486,10 @@ class VideoClip(BaseModel): """A single generated video clip.""" url: str # Permanent blockrun-hosted URL (falls back to upstream if backup fails) - source_url: str | None = None # Original upstream URL (e.g. vidgen.x.ai) - duration_seconds: int | None = None - request_id: str | None = None # Upstream provider's request id (xAI) - backed_up: bool | None = None + source_url: Optional[str] = None # Original upstream URL (e.g. vidgen.x.ai) + duration_seconds: Optional[int] = None + request_id: Optional[str] = None # Upstream provider's request id (xAI) + backed_up: Optional[bool] = None class VideoResponse(BaseModel): @@ -499,8 +497,8 @@ class VideoResponse(BaseModel): created: int model: str - data: list[VideoClip] - txHash: str | None = None + data: List[VideoClip] + txHash: Optional[str] = None class VideoModel(BaseModel): @@ -524,9 +522,11 @@ class WebSearchSource(BaseModel): """Web search source configuration.""" type: Literal["web"] = "web" - country: str | None = None # ISO alpha-2 country code - excluded_websites: list[str] | None = None # Max 5 websites - allowed_websites: list[str] | None = None # Max 5 websites (mutually exclusive with excluded) + country: Optional[str] = None # ISO alpha-2 country code + excluded_websites: Optional[List[str]] = None # Max 5 websites + allowed_websites: Optional[List[str]] = ( + None # Max 5 websites (mutually exclusive with excluded) + ) safe_search: bool = True @@ -534,19 +534,19 @@ class XSearchSource(BaseModel): """X/Twitter search source configuration.""" type: Literal["x"] = "x" - included_x_handles: list[str] | None = None # Max 10 handles - excluded_x_handles: list[str] | None = None # Max 10 handles - post_favorite_count: int | None = None # Minimum favorites threshold - post_view_count: int | None = None # Minimum views threshold + included_x_handles: Optional[List[str]] = None # Max 10 handles + excluded_x_handles: Optional[List[str]] = None # Max 10 handles + post_favorite_count: Optional[int] = None # Minimum favorites threshold + post_view_count: Optional[int] = None # Minimum views threshold class NewsSearchSource(BaseModel): """News search source configuration.""" type: Literal["news"] = "news" - country: str | None = None # ISO alpha-2 country code - excluded_websites: list[str] | None = None # Max 5 websites - allowed_websites: list[str] | None = None # Max 5 websites + country: Optional[str] = None # ISO alpha-2 country code + excluded_websites: Optional[List[str]] = None # Max 5 websites + allowed_websites: Optional[List[str]] = None # Max 5 websites safe_search: bool = True @@ -554,11 +554,11 @@ class RssSearchSource(BaseModel): """RSS feed search source configuration.""" type: Literal["rss"] = "rss" - links: list[str] # RSS feed URLs (currently supports one) + links: List[str] # RSS feed URLs (currently supports one) SearchSource = Union[ - WebSearchSource, XSearchSource, NewsSearchSource, RssSearchSource, dict[str, Any] + WebSearchSource, XSearchSource, NewsSearchSource, RssSearchSource, Dict[str, Any] ] @@ -578,17 +578,17 @@ class SearchParameters(BaseModel): """ mode: Literal["off", "auto", "on"] = "auto" - sources: list[SearchSource] | None = None # Default: web, news, x + sources: Optional[List[SearchSource]] = None # Default: web, news, x return_citations: bool = True - from_date: str | None = None # YYYY-MM-DD format - to_date: str | None = None # YYYY-MM-DD format + from_date: Optional[str] = None # YYYY-MM-DD format + to_date: Optional[str] = None # YYYY-MM-DD format max_search_results: int = 10 # Max sources (default 10, ~$0.26 with margin) class SearchUsage(BaseModel): """Search usage information from xAI Live Search.""" - num_sources_used: int | None = None + num_sources_used: Optional[int] = None class CostEstimate(BaseModel): @@ -666,7 +666,7 @@ class RoutingDecision(BaseModel): cost_estimate: float baseline_cost: float savings: float # 0-1 percentage - fallbacks: list[str] = [] # remaining models in tier order, for runtime fallback + fallbacks: List[str] = [] # remaining models in tier order, for runtime fallback class SmartChatResponse(BaseModel): @@ -691,9 +691,9 @@ class SearchResult(BaseModel): query: str summary: str - citations: list[dict[str, str]] | None = None - sources_used: int | None = None - model: str | None = None + citations: Optional[List[Dict[str, str]]] = None + sources_used: Optional[int] = None + model: Optional[str] = None # Pyth-backed market data types (crypto, stocks, fx, commodity) @@ -702,9 +702,9 @@ class PricePoint(BaseModel): symbol: str price: float - publish_time: int | None = None # Unix seconds - confidence: float | None = None - feed_id: str | None = None + publish_time: Optional[int] = None # Unix seconds + confidence: Optional[float] = None + feed_id: Optional[str] = None class Config: extra = "allow" @@ -713,12 +713,12 @@ class Config: class PriceBar(BaseModel): """OHLC bar in a historical price series.""" - t: int | None = None # Bar open time (unix seconds) - o: float | None = None - h: float | None = None - l: float | None = None - c: float | None = None - v: float | None = None + t: Optional[int] = None # Bar open time (unix seconds) + o: Optional[float] = None + h: Optional[float] = None + l: Optional[float] = None + c: Optional[float] = None + v: Optional[float] = None class Config: extra = "allow" @@ -728,8 +728,8 @@ class PriceHistoryResponse(BaseModel): """Response from a historical price endpoint.""" symbol: str - resolution: str | None = None - bars: list[PriceBar] = [] + resolution: Optional[str] = None + bars: List[PriceBar] = [] class Config: extra = "allow" @@ -738,8 +738,8 @@ class Config: class SymbolListResponse(BaseModel): """Response from a market symbol list endpoint.""" - symbols: list[dict[str, Any]] = [] - count: int | None = None + symbols: List[Dict[str, Any]] = [] + count: Optional[int] = None class Config: extra = "allow" @@ -751,8 +751,8 @@ class Config: class PortraitUsage(BaseModel): """How the enrolled portrait can be used.""" - compatible_models: list[str] = [] - how_to_use: str | None = None + compatible_models: List[str] = [] + how_to_use: Optional[str] = None class Config: extra = "allow" @@ -762,8 +762,8 @@ class PortraitSettlement(BaseModel): """On-chain settlement of the enrollment payment.""" success: bool - tx_hash: str | None = None - network: str | None = None + tx_hash: Optional[str] = None + network: Optional[str] = None class Config: extra = "allow" @@ -774,13 +774,13 @@ class PortraitEnrollment(BaseModel): object: str = "virtual_portrait" asset_id: str # ta_xxxxxxxx — pass as real_face_asset_id on Seedance - group_id: str | None = None + group_id: Optional[str] = None name: str image_url: str - created_at: str | None = None - usage: PortraitUsage | None = None - price: dict[str, Any] | None = None # {amount, currency} - settlement: PortraitSettlement | None = None + created_at: Optional[str] = None + usage: Optional[PortraitUsage] = None + price: Optional[Dict[str, Any]] = None # {amount, currency} + settlement: Optional[PortraitSettlement] = None class Config: extra = "allow" @@ -791,11 +791,11 @@ class PortraitListItem(BaseModel): # Upstream uses camelCase here, keep matching for transparent ingestion. assetId: str - groupId: str | None = None - name: str | None = None - imageUrl: str | None = None - createdAt: str | None = None - enrollmentTxHash: str | None = None + groupId: Optional[str] = None + name: Optional[str] = None + imageUrl: Optional[str] = None + createdAt: Optional[str] = None + enrollmentTxHash: Optional[str] = None class Config: extra = "allow" @@ -805,8 +805,8 @@ class PortraitList(BaseModel): """Response from GET /v1/wallet/
/portraits.""" wallet: str - portraits: list[PortraitListItem] = [] - count: int | None = None + portraits: List[PortraitListItem] = [] + count: Optional[int] = None class Config: extra = "allow" @@ -828,10 +828,10 @@ class RealFaceInit(BaseModel): object: str = "realface.init" group_id: str # legacy_rf_xxxx — pass to status()/enroll() h5_link: str # URL the real person scans on their phone for liveness - status: str | None = None # pending_validation | active - expires_in_seconds: int | None = None # H5 session validity (~120s) - next_steps: dict[str, Any] | None = None - refreshed: bool | None = None # True when re-issued for an existing group + status: Optional[str] = None # pending_validation | active + expires_in_seconds: Optional[int] = None # H5 session validity (~120s) + next_steps: Optional[Dict[str, Any]] = None + refreshed: Optional[bool] = None # True when re-issued for an existing group class Config: extra = "allow" @@ -843,7 +843,7 @@ class RealFaceStatus(BaseModel): object: str = "realface.status" group_id: str status: str # pending_validation | active | … - asset_count: int | None = None + asset_count: Optional[int] = None ready_to_finalize: bool = False # True once status == "active" class Config: @@ -855,14 +855,14 @@ class RealFaceEnrollment(BaseModel): object: str = "realface" asset_id: str # ta_xxxxxxxx — pass as real_face_asset_id on Seedance - group_id: str | None = None - byteplus_asset_id: str | None = None + group_id: Optional[str] = None + byteplus_asset_id: Optional[str] = None name: str image_url: str - created_at: str | None = None - usage: PortraitUsage | None = None - price: dict[str, Any] | None = None # {amount, currency} - settlement: PortraitSettlement | None = None + created_at: Optional[str] = None + usage: Optional[PortraitUsage] = None + price: Optional[Dict[str, Any]] = None # {amount, currency} + settlement: Optional[PortraitSettlement] = None class Config: extra = "allow" @@ -873,12 +873,12 @@ class RealFaceListItem(BaseModel): # Upstream uses camelCase here, keep matching for transparent ingestion. assetId: str - groupId: str | None = None - name: str | None = None - imageUrl: str | None = None - createdAt: str | None = None - enrollmentTxHash: str | None = None - byteplusAssetId: str | None = None + groupId: Optional[str] = None + name: Optional[str] = None + imageUrl: Optional[str] = None + createdAt: Optional[str] = None + enrollmentTxHash: Optional[str] = None + byteplusAssetId: Optional[str] = None class Config: extra = "allow" @@ -888,8 +888,8 @@ class RealFaceList(BaseModel): """Response from GET /v1/wallet/
/realfaces.""" wallet: str - realfaces: list[RealFaceListItem] = [] - count: int | None = None + realfaces: List[RealFaceListItem] = [] + count: Optional[int] = None class Config: extra = "allow" diff --git a/pyproject.toml b/pyproject.toml index b66688a..db82f26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,3 +96,14 @@ ignore = ["BLE001", "S110", "S112", "TRY004", "DTZ005", "DTZ006", "RUF012"] [tool.mypy] python_version = "3.9" strict = true + +[tool.ruff.lint.per-file-ignores] +# pydantic EVALUATES annotations at runtime to build each model. Under +# `from __future__ import annotations` they are strings, and on Python 3.9 +# evaluating "str | None" is a TypeError — PEP 604 does not exist there. +# +# Parsing is not the constraint; evaluation is. compileall passes on 3.9 and +# the import still fails, which is exactly how this got shipped to CI once. +# +# So this file keeps typing.Optional/List and no future import. +"blockrun_llm/types.py" = ["FA100", "UP006", "UP007", "UP035", "UP045"]