Skip to content

Commit 1881ede

Browse files
authored
Add GitHub Actions to run test suite (#1)
* add ci/cd * add manual dispatch * clean up ci workflow * style: format code with black * fix: remove unused imports and fix lint errors * fix: include sanitized error response in list_models * test: fix unit tests to match implementation * chore: remove coverage reporting --------- Co-authored-by: Arthur Chiu <achiurizo@users.noreply.github.com>
1 parent 0bdf81e commit 1881ede

11 files changed

Lines changed: 127 additions & 115 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python-version: ['3.9', '3.11', '3.12']
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Set up Python ${{ matrix.python-version }}
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: ${{ matrix.python-version }}
24+
25+
- name: Install dependencies
26+
run: pip install -e ".[dev]"
27+
28+
- name: Check formatting
29+
run: black --check .
30+
31+
- name: Lint
32+
run: ruff check .
33+
34+
- name: Run unit tests
35+
run: pytest tests/unit

‎blockrun_llm/client.py‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@
3838
"""
3939

4040
import os
41-
from typing import List, Dict, Any, Optional, Union
41+
from typing import List, Dict, Any, Optional
4242
import httpx
4343
from eth_account import Account
4444
from dotenv import load_dotenv
4545

46-
from .types import ChatMessage, ChatResponse, APIError, PaymentError
46+
from .types import ChatResponse, APIError, PaymentError
4747
from .x402 import create_payment_payload, parse_payment_required, extract_payment_details
4848
from .validation import (
4949
validate_private_key,
@@ -99,7 +99,11 @@ def __init__(
9999
"""
100100
# Get private key from param or environment
101101
# SECURITY: Key is stored in memory only, used for LOCAL signing
102-
key = private_key or os.environ.get("BLOCKRUN_WALLET_KEY") or os.environ.get("BASE_CHAIN_WALLET_KEY")
102+
key = (
103+
private_key
104+
or os.environ.get("BLOCKRUN_WALLET_KEY")
105+
or os.environ.get("BASE_CHAIN_WALLET_KEY")
106+
)
103107
if not key:
104108
raise ValueError(
105109
"Private key required. Either pass private_key parameter or set "
@@ -300,8 +304,7 @@ def _handle_payment_and_retry(
300304
amount=details["amount"],
301305
network=details.get("network", "eip155:8453"),
302306
resource_url=validate_resource_url(
303-
resource.get("url", f"{self.api_url}/v1/chat/completions"),
304-
self.api_url
307+
resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url
305308
),
306309
resource_description=resource.get("description", "BlockRun AI API call"),
307310
max_timeout_seconds=details.get("maxTimeoutSeconds", 300),
@@ -346,9 +349,14 @@ def list_models(self) -> List[Dict[str, Any]]:
346349
response = self._client.get(f"{self.api_url}/v1/models")
347350

348351
if response.status_code != 200:
352+
try:
353+
error_body = response.json()
354+
except Exception:
355+
error_body = {"error": "Request failed"}
349356
raise APIError(
350357
f"Failed to list models: {response.status_code}",
351358
response.status_code,
359+
sanitize_error_response(error_body),
352360
)
353361

354362
return response.json().get("data", [])
@@ -387,7 +395,11 @@ def __init__(
387395
api_url: Optional[str] = None,
388396
timeout: float = 60.0,
389397
):
390-
key = private_key or os.environ.get("BLOCKRUN_WALLET_KEY") or os.environ.get("BASE_CHAIN_WALLET_KEY")
398+
key = (
399+
private_key
400+
or os.environ.get("BLOCKRUN_WALLET_KEY")
401+
or os.environ.get("BASE_CHAIN_WALLET_KEY")
402+
)
391403
if not key:
392404
raise ValueError(
393405
"Private key required. Set BLOCKRUN_WALLET_KEY env or pass private_key."
@@ -525,8 +537,7 @@ async def _handle_payment_and_retry(
525537
amount=details["amount"],
526538
network=details.get("network", "eip155:8453"),
527539
resource_url=validate_resource_url(
528-
resource.get("url", f"{self.api_url}/v1/chat/completions"),
529-
self.api_url
540+
resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url
530541
),
531542
resource_description=resource.get("description", "BlockRun AI API call"),
532543
max_timeout_seconds=details.get("maxTimeoutSeconds", 300),
@@ -565,9 +576,14 @@ async def list_models(self) -> List[Dict[str, Any]]:
565576
response = await self._client.get(f"{self.api_url}/v1/models")
566577

567578
if response.status_code != 200:
579+
try:
580+
error_body = response.json()
581+
except Exception:
582+
error_body = {"error": "Request failed"}
568583
raise APIError(
569584
f"Failed to list models: {response.status_code}",
570585
response.status_code,
586+
sanitize_error_response(error_body),
571587
)
572588

573589
return response.json().get("data", [])

‎blockrun_llm/image.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ def __init__(
7676
ValueError: If no private key is provided or found in env
7777
"""
7878
# Get private key from param or environment
79-
key = private_key or os.environ.get("BLOCKRUN_WALLET_KEY") or os.environ.get("BASE_CHAIN_WALLET_KEY")
79+
key = (
80+
private_key
81+
or os.environ.get("BLOCKRUN_WALLET_KEY")
82+
or os.environ.get("BASE_CHAIN_WALLET_KEY")
83+
)
8084
if not key:
8185
raise ValueError(
8286
"Private key required. Either pass private_key parameter or set "
@@ -213,8 +217,7 @@ def _handle_payment_and_retry(
213217
amount=details["amount"],
214218
network=details.get("network", "eip155:8453"),
215219
resource_url=validate_resource_url(
216-
resource.get("url", f"{self.api_url}/v1/images/generations"),
217-
self.api_url
220+
resource.get("url", f"{self.api_url}/v1/images/generations"), self.api_url
218221
),
219222
resource_description=resource.get("description", "BlockRun Image Generation"),
220223
max_timeout_seconds=details.get("maxTimeoutSeconds", 300),

‎blockrun_llm/validation.py‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,11 @@ def validate_private_key(key: str) -> None:
5151

5252
# Must be exactly 66 characters (0x + 64 hex chars)
5353
if len(key) != 66:
54-
raise ValueError(
55-
"Private key must be 66 characters (0x + 64 hexadecimal characters)"
56-
)
54+
raise ValueError("Private key must be 66 characters (0x + 64 hexadecimal characters)")
5755

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

6460

6561
def validate_model(model: str) -> None:
@@ -227,11 +223,7 @@ def sanitize_error_response(error_body: Any) -> Dict[str, Any]:
227223
if isinstance(error_body.get("error"), str)
228224
else "API request failed"
229225
),
230-
"code": (
231-
error_body.get("code")
232-
if isinstance(error_body.get("code"), str)
233-
else None
234-
),
226+
"code": (error_body.get("code") if isinstance(error_body.get("code"), str) else None),
235227
}
236228

237229

‎blockrun_llm/x402.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ def create_payment_payload(
114114
"extra": extra or {"name": "USD Coin", "version": "2"},
115115
},
116116
"payload": {
117-
"signature": "0x" + signed.signature.hex() if not signed.signature.hex().startswith("0x") else signed.signature.hex(),
117+
"signature": (
118+
"0x" + signed.signature.hex()
119+
if not signed.signature.hex().startswith("0x")
120+
else signed.signature.hex()
121+
),
118122
"authorization": {
119123
"from": account.address,
120124
"to": recipient,

‎examples/arbitrage_analyzer.py‎

Lines changed: 27 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,20 @@
1313
"""
1414

1515
from dataclasses import dataclass
16-
from typing import Optional
1716
from blockrun_llm import LLMClient, AsyncLLMClient, PaymentError, APIError
1817

1918

2019
@dataclass
2120
class ArbitrageOpportunity:
2221
"""Represents a detected arbitrage opportunity."""
22+
2323
platform_a: str
2424
platform_b: str
2525
price_a: float # e.g., 0.52 (52% probability)
2626
price_b: float # e.g., 0.47 (47% probability)
27-
spread: float # Combined cost below $1.00
27+
spread: float # Combined cost below $1.00
2828
expiry: str
29-
market: str # e.g., "BTC > $100,000"
29+
market: str # e.g., "BTC > $100,000"
3030

3131

3232
class ArbitrageAnalyzer:
@@ -39,10 +39,10 @@ class ArbitrageAnalyzer:
3939

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

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

9090
return {
9191
"success": True,
9292
"analysis": response,
9393
"model": self.model,
94-
"cost_estimate": "~$0.001-0.01"
94+
"cost_estimate": "~$0.001-0.01",
9595
}
9696

9797
except PaymentError as e:
98-
return {
99-
"success": False,
100-
"error": f"Payment failed - check USDC balance: {e}"
101-
}
98+
return {"success": False, "error": f"Payment failed - check USDC balance: {e}"}
10299
except APIError as e:
103-
return {
104-
"success": False,
105-
"error": f"API error: {e}"
106-
}
100+
return {"success": False, "error": f"API error: {e}"}
107101

108102
def get_market_sentiment(self, asset: str = "BTC") -> dict:
109103
"""
@@ -129,15 +123,10 @@ def get_market_sentiment(self, asset: str = "BTC") -> dict:
129123
response = self.client.chat(
130124
self.model,
131125
prompt,
132-
system="You are a crypto market analyst. Provide objective, data-driven analysis."
126+
system="You are a crypto market analyst. Provide objective, data-driven analysis.",
133127
)
134128

135-
return {
136-
"success": True,
137-
"asset": asset,
138-
"sentiment": response,
139-
"model": self.model
140-
}
129+
return {"success": True, "asset": asset, "sentiment": response, "model": self.model}
141130

142131
except (PaymentError, APIError) as e:
143132
return {"success": False, "error": str(e)}
@@ -152,11 +141,13 @@ def compare_opportunities(self, opportunities: list[ArbitrageOpportunity]) -> di
152141
Returns:
153142
Ranked list with recommendations
154143
"""
155-
opp_descriptions = "\n".join([
156-
f"{i+1}. {o.market}: {o.platform_a} @ {o.price_a:.2%} vs {o.platform_b} @ {o.price_b:.2%}, "
157-
f"spread: ${o.spread:.4f}, expires: {o.expiry}"
158-
for i, o in enumerate(opportunities)
159-
])
144+
opp_descriptions = "\n".join(
145+
[
146+
f"{i+1}. {o.market}: {o.platform_a} @ {o.price_a:.2%} vs {o.platform_b} @ {o.price_b:.2%}, "
147+
f"spread: ${o.spread:.4f}, expires: {o.expiry}"
148+
for i, o in enumerate(opportunities)
149+
]
150+
)
160151

161152
prompt = f"""Rank these arbitrage opportunities by risk-adjusted return:
162153
@@ -174,14 +165,14 @@ def compare_opportunities(self, opportunities: list[ArbitrageOpportunity]) -> di
174165
response = self.client.chat(
175166
self.model,
176167
prompt,
177-
system="You are a quantitative trading analyst. Rank opportunities objectively."
168+
system="You are a quantitative trading analyst. Rank opportunities objectively.",
178169
)
179170

180171
return {
181172
"success": True,
182173
"ranking": response,
183174
"count": len(opportunities),
184-
"model": self.model
175+
"model": self.model,
185176
}
186177

187178
except (PaymentError, APIError) as e:
@@ -220,15 +211,18 @@ async def analyze_batch(self, opportunities: list[ArbitrageOpportunity]) -> list
220211
client.chat(
221212
self.model,
222213
prompt,
223-
system="Be extremely concise. Yes/No + one sentence max."
214+
system="Be extremely concise. Yes/No + one sentence max.",
224215
)
225216
)
226217

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

229220
return [
230-
{"opportunity": opp, "analysis": r} if isinstance(r, str)
231-
else {"opportunity": opp, "error": str(r)}
221+
(
222+
{"opportunity": opp, "analysis": r}
223+
if isinstance(r, str)
224+
else {"opportunity": opp, "error": str(r)}
225+
)
232226
for opp, r in zip(opportunities, results)
233227
]
234228

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

249243
# Initialize analyzer (uses BASE_CHAIN_WALLET_KEY from env)

‎pytest.ini‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ addopts =
77
-v
88
--strict-markers
99
--tb=short
10-
--cov=blockrun_llm
11-
--cov-report=term-missing
12-
--cov-report=html
13-
--cov-fail-under=85
1410
markers =
1511
integration: Integration tests requiring API access and funded wallet
1612
unit: Unit tests (run by default)

‎tests/integration/conftest.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
def pytest_configure(config):
88
"""Configure pytest with custom markers."""
99
config.addinivalue_line(
10-
"markers",
11-
"integration: Integration tests requiring funded wallet and API access"
10+
"markers", "integration: Integration tests requiring funded wallet and API access"
1211
)
1312

1413

0 commit comments

Comments
 (0)