Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.11', '3.12']

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: pip install -e ".[dev]"

- name: Check formatting
run: black --check .

- name: Lint
run: ruff check .

- name: Run unit tests
run: pytest tests/unit
32 changes: 24 additions & 8 deletions blockrun_llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@
"""

import os
from typing import List, Dict, Any, Optional, Union
from typing import List, Dict, Any, Optional
import httpx
from eth_account import Account
from dotenv import load_dotenv

from .types import ChatMessage, ChatResponse, APIError, PaymentError
from .types import ChatResponse, APIError, PaymentError
from .x402 import create_payment_payload, parse_payment_required, extract_payment_details
from .validation import (
validate_private_key,
Expand Down Expand Up @@ -99,7 +99,11 @@ def __init__(
"""
# Get private key from param or environment
# SECURITY: Key is stored in memory only, used for LOCAL signing
key = private_key or os.environ.get("BLOCKRUN_WALLET_KEY") or os.environ.get("BASE_CHAIN_WALLET_KEY")
key = (
private_key
or os.environ.get("BLOCKRUN_WALLET_KEY")
or os.environ.get("BASE_CHAIN_WALLET_KEY")
)
if not key:
raise ValueError(
"Private key required. Either pass private_key parameter or set "
Expand Down Expand Up @@ -300,8 +304,7 @@ def _handle_payment_and_retry(
amount=details["amount"],
network=details.get("network", "eip155:8453"),
resource_url=validate_resource_url(
resource.get("url", f"{self.api_url}/v1/chat/completions"),
self.api_url
resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url
),
resource_description=resource.get("description", "BlockRun AI API call"),
max_timeout_seconds=details.get("maxTimeoutSeconds", 300),
Expand Down Expand Up @@ -346,9 +349,14 @@ def list_models(self) -> List[Dict[str, Any]]:
response = self._client.get(f"{self.api_url}/v1/models")

if response.status_code != 200:
try:
error_body = response.json()
except Exception:
error_body = {"error": "Request failed"}
raise APIError(
f"Failed to list models: {response.status_code}",
response.status_code,
sanitize_error_response(error_body),
)

return response.json().get("data", [])
Expand Down Expand Up @@ -387,7 +395,11 @@ def __init__(
api_url: Optional[str] = None,
timeout: float = 60.0,
):
key = private_key or os.environ.get("BLOCKRUN_WALLET_KEY") or os.environ.get("BASE_CHAIN_WALLET_KEY")
key = (
private_key
or os.environ.get("BLOCKRUN_WALLET_KEY")
or os.environ.get("BASE_CHAIN_WALLET_KEY")
)
if not key:
raise ValueError(
"Private key required. Set BLOCKRUN_WALLET_KEY env or pass private_key."
Expand Down Expand Up @@ -525,8 +537,7 @@ async def _handle_payment_and_retry(
amount=details["amount"],
network=details.get("network", "eip155:8453"),
resource_url=validate_resource_url(
resource.get("url", f"{self.api_url}/v1/chat/completions"),
self.api_url
resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url
),
resource_description=resource.get("description", "BlockRun AI API call"),
max_timeout_seconds=details.get("maxTimeoutSeconds", 300),
Expand Down Expand Up @@ -565,9 +576,14 @@ async def list_models(self) -> List[Dict[str, Any]]:
response = await self._client.get(f"{self.api_url}/v1/models")

if response.status_code != 200:
try:
error_body = response.json()
except Exception:
error_body = {"error": "Request failed"}
raise APIError(
f"Failed to list models: {response.status_code}",
response.status_code,
sanitize_error_response(error_body),
)

return response.json().get("data", [])
Expand Down
9 changes: 6 additions & 3 deletions blockrun_llm/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ def __init__(
ValueError: If no private key is provided or found in env
"""
# Get private key from param or environment
key = private_key or os.environ.get("BLOCKRUN_WALLET_KEY") or os.environ.get("BASE_CHAIN_WALLET_KEY")
key = (
private_key
or os.environ.get("BLOCKRUN_WALLET_KEY")
or os.environ.get("BASE_CHAIN_WALLET_KEY")
)
if not key:
raise ValueError(
"Private key required. Either pass private_key parameter or set "
Expand Down Expand Up @@ -213,8 +217,7 @@ def _handle_payment_and_retry(
amount=details["amount"],
network=details.get("network", "eip155:8453"),
resource_url=validate_resource_url(
resource.get("url", f"{self.api_url}/v1/images/generations"),
self.api_url
resource.get("url", f"{self.api_url}/v1/images/generations"), self.api_url
),
resource_description=resource.get("description", "BlockRun Image Generation"),
max_timeout_seconds=details.get("maxTimeoutSeconds", 300),
Expand Down
14 changes: 3 additions & 11 deletions blockrun_llm/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,11 @@ def validate_private_key(key: str) -> None:

# Must be exactly 66 characters (0x + 64 hex chars)
if len(key) != 66:
raise ValueError(
"Private key must be 66 characters (0x + 64 hexadecimal characters)"
)
raise ValueError("Private key must be 66 characters (0x + 64 hexadecimal characters)")

# Must contain only valid hexadecimal characters
if not re.match(r"^0x[0-9a-fA-F]{64}$", key):
raise ValueError(
"Private key must contain only hexadecimal characters (0-9, a-f, A-F)"
)
raise ValueError("Private key must contain only hexadecimal characters (0-9, a-f, A-F)")


def validate_model(model: str) -> None:
Expand Down Expand Up @@ -227,11 +223,7 @@ def sanitize_error_response(error_body: Any) -> Dict[str, Any]:
if isinstance(error_body.get("error"), str)
else "API request failed"
),
"code": (
error_body.get("code")
if isinstance(error_body.get("code"), str)
else None
),
"code": (error_body.get("code") if isinstance(error_body.get("code"), str) else None),
}


Expand Down
6 changes: 5 additions & 1 deletion blockrun_llm/x402.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ def create_payment_payload(
"extra": extra or {"name": "USD Coin", "version": "2"},
},
"payload": {
"signature": "0x" + signed.signature.hex() if not signed.signature.hex().startswith("0x") else signed.signature.hex(),
"signature": (
"0x" + signed.signature.hex()
if not signed.signature.hex().startswith("0x")
else signed.signature.hex()
),
"authorization": {
"from": account.address,
"to": recipient,
Expand Down
60 changes: 27 additions & 33 deletions examples/arbitrage_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@
"""

from dataclasses import dataclass
from typing import Optional
from blockrun_llm import LLMClient, AsyncLLMClient, PaymentError, APIError


@dataclass
class ArbitrageOpportunity:
"""Represents a detected arbitrage opportunity."""

platform_a: str
platform_b: str
price_a: float # e.g., 0.52 (52% probability)
price_b: float # e.g., 0.47 (47% probability)
spread: float # Combined cost below $1.00
spread: float # Combined cost below $1.00
expiry: str
market: str # e.g., "BTC > $100,000"
market: str # e.g., "BTC > $100,000"


class ArbitrageAnalyzer:
Expand All @@ -39,10 +39,10 @@ class ArbitrageAnalyzer:

# Model recommendations by use case
MODELS = {
"fast": "openai/gpt-4o-mini", # $0.15/M input - quick analysis
"fast": "openai/gpt-4o-mini", # $0.15/M input - quick analysis
"balanced": "anthropic/claude-haiku-4.5", # $1.00/M input - good reasoning
"deep": "anthropic/claude-sonnet-4", # $3.00/M input - thorough analysis
"frontier": "openai/gpt-5.2", # $1.75/M input - latest capabilities
"frontier": "openai/gpt-5.2", # $1.75/M input - latest capabilities
}

def __init__(self, model_tier: str = "fast"):
Expand Down Expand Up @@ -84,26 +84,20 @@ def analyze_opportunity(self, opp: ArbitrageOpportunity) -> dict:
response = self.client.chat(
self.model,
prompt,
system="You are a quantitative trading analyst specializing in prediction market arbitrage. Be concise and actionable."
system="You are a quantitative trading analyst specializing in prediction market arbitrage. Be concise and actionable.",
)

return {
"success": True,
"analysis": response,
"model": self.model,
"cost_estimate": "~$0.001-0.01"
"cost_estimate": "~$0.001-0.01",
}

except PaymentError as e:
return {
"success": False,
"error": f"Payment failed - check USDC balance: {e}"
}
return {"success": False, "error": f"Payment failed - check USDC balance: {e}"}
except APIError as e:
return {
"success": False,
"error": f"API error: {e}"
}
return {"success": False, "error": f"API error: {e}"}

def get_market_sentiment(self, asset: str = "BTC") -> dict:
"""
Expand All @@ -129,15 +123,10 @@ def get_market_sentiment(self, asset: str = "BTC") -> dict:
response = self.client.chat(
self.model,
prompt,
system="You are a crypto market analyst. Provide objective, data-driven analysis."
system="You are a crypto market analyst. Provide objective, data-driven analysis.",
)

return {
"success": True,
"asset": asset,
"sentiment": response,
"model": self.model
}
return {"success": True, "asset": asset, "sentiment": response, "model": self.model}

except (PaymentError, APIError) as e:
return {"success": False, "error": str(e)}
Expand All @@ -152,11 +141,13 @@ def compare_opportunities(self, opportunities: list[ArbitrageOpportunity]) -> di
Returns:
Ranked list with recommendations
"""
opp_descriptions = "\n".join([
f"{i+1}. {o.market}: {o.platform_a} @ {o.price_a:.2%} vs {o.platform_b} @ {o.price_b:.2%}, "
f"spread: ${o.spread:.4f}, expires: {o.expiry}"
for i, o in enumerate(opportunities)
])
opp_descriptions = "\n".join(
[
f"{i+1}. {o.market}: {o.platform_a} @ {o.price_a:.2%} vs {o.platform_b} @ {o.price_b:.2%}, "
f"spread: ${o.spread:.4f}, expires: {o.expiry}"
for i, o in enumerate(opportunities)
]
)

prompt = f"""Rank these arbitrage opportunities by risk-adjusted return:

Expand All @@ -174,14 +165,14 @@ def compare_opportunities(self, opportunities: list[ArbitrageOpportunity]) -> di
response = self.client.chat(
self.model,
prompt,
system="You are a quantitative trading analyst. Rank opportunities objectively."
system="You are a quantitative trading analyst. Rank opportunities objectively.",
)

return {
"success": True,
"ranking": response,
"count": len(opportunities),
"model": self.model
"model": self.model,
}

except (PaymentError, APIError) as e:
Expand Down Expand Up @@ -220,15 +211,18 @@ async def analyze_batch(self, opportunities: list[ArbitrageOpportunity]) -> list
client.chat(
self.model,
prompt,
system="Be extremely concise. Yes/No + one sentence max."
system="Be extremely concise. Yes/No + one sentence max.",
)
)

results = await asyncio.gather(*tasks, return_exceptions=True)

return [
{"opportunity": opp, "analysis": r} if isinstance(r, str)
else {"opportunity": opp, "error": str(r)}
(
{"opportunity": opp, "analysis": r}
if isinstance(r, str)
else {"opportunity": opp, "error": str(r)}
)
for opp, r in zip(opportunities, results)
]

Expand All @@ -243,7 +237,7 @@ async def analyze_batch(self, opportunities: list[ArbitrageOpportunity]) -> list
price_b=0.47,
spread=0.99, # $0.99 combined cost
expiry="2024-01-15 17:00 UTC",
market="BTC > $100,000 by Jan 15"
market="BTC > $100,000 by Jan 15",
)

# Initialize analyzer (uses BASE_CHAIN_WALLET_KEY from env)
Expand Down
4 changes: 0 additions & 4 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ addopts =
-v
--strict-markers
--tb=short
--cov=blockrun_llm
--cov-report=term-missing
--cov-report=html
--cov-fail-under=85
markers =
integration: Integration tests requiring API access and funded wallet
unit: Unit tests (run by default)
Expand Down
3 changes: 1 addition & 2 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
def pytest_configure(config):
"""Configure pytest with custom markers."""
config.addinivalue_line(
"markers",
"integration: Integration tests requiring funded wallet and API access"
"markers", "integration: Integration tests requiring funded wallet and API access"
)


Expand Down
Loading