web3-agent-kit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/llm.py ADDED
@@ -0,0 +1,272 @@
1
+ """LLM integration — multi-provider cascade for agent reasoning.
2
+
3
+ Supports: OpenAI, Anthropic, Groq, DeepSeek, OpenRouter.
4
+ Falls back through providers on 429/5xx/timeout.
5
+
6
+ Usage:
7
+ from web3_agent_kit.llm import LLM
8
+
9
+ llm = LLM() # auto-detect from env vars
10
+ response = llm.chat("What is the best yield on Base?")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import os
18
+ from dataclasses import dataclass
19
+ from typing import Any, Optional
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class LLMConfig:
26
+ """Configuration for LLM providers."""
27
+
28
+ providers: Optional[list[dict]] = None # ordered list of providers to try
29
+ temperature: float = 0.1
30
+ max_tokens: int = 2048
31
+ timeout: int = 30
32
+
33
+ def __post_init__(self):
34
+ if not self.providers:
35
+ self.providers = self._auto_detect()
36
+
37
+ def _auto_detect(self) -> list[dict]:
38
+ """Auto-detect available providers from environment variables."""
39
+ providers = []
40
+
41
+ # Cascade order: Anthropic → Kimi → OpenRouter → DeepSeek → Groq
42
+ if os.environ.get("ANTHROPIC_API_KEY"):
43
+ providers.append({
44
+ "name": "anthropic",
45
+ "api_key": os.environ["ANTHROPIC_API_KEY"],
46
+ "model": os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"),
47
+ "base_url": "https://api.anthropic.com/v1",
48
+ })
49
+
50
+ if os.environ.get("KIMI_API_KEY"):
51
+ providers.append({
52
+ "name": "openai",
53
+ "api_key": os.environ["KIMI_API_KEY"],
54
+ "model": os.environ.get("KIMI_MODEL", "moonshot-v1-8k"),
55
+ "base_url": os.environ.get("KIMI_BASE_URL", "https://api.moonshot.cn/v1"),
56
+ })
57
+
58
+ if os.environ.get("OPENROUTER_API_KEY"):
59
+ providers.append({
60
+ "name": "openai",
61
+ "api_key": os.environ["OPENROUTER_API_KEY"],
62
+ "model": os.environ.get("OPENROUTER_MODEL", "anthropic/claude-sonnet-4"),
63
+ "base_url": "https://openrouter.ai/api/v1",
64
+ })
65
+
66
+ if os.environ.get("DEEPSEEK_API_KEY"):
67
+ providers.append({
68
+ "name": "openai",
69
+ "api_key": os.environ["DEEPSEEK_API_KEY"],
70
+ "model": os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
71
+ "base_url": "https://api.deepseek.com/v1",
72
+ })
73
+
74
+ if os.environ.get("GROQ_API_KEY"):
75
+ providers.append({
76
+ "name": "openai",
77
+ "api_key": os.environ["GROQ_API_KEY"],
78
+ "model": os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile"),
79
+ "base_url": "https://api.groq.com/openai/v1",
80
+ })
81
+
82
+ if os.environ.get("OPENAI_API_KEY"):
83
+ providers.append({
84
+ "name": "openai",
85
+ "api_key": os.environ["OPENAI_API_KEY"],
86
+ "model": os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
87
+ "base_url": "https://api.openai.com/v1",
88
+ })
89
+
90
+ return providers
91
+
92
+
93
+ class LLM:
94
+ """
95
+ Multi-provider LLM client with cascade fallback.
96
+
97
+ Tries providers in order. On 429/5xx/timeout, moves to next provider.
98
+
99
+ Example:
100
+ llm = LLM()
101
+ response = llm.chat("Analyze this swap: 0.1 ETH to USDC")
102
+ """
103
+
104
+ def __init__(self, config: Optional[LLMConfig] = None, **kwargs):
105
+ self.config = config or LLMConfig(**kwargs)
106
+ if not self.config.providers:
107
+ raise ValueError(
108
+ "No LLM providers configured. Set one of: "
109
+ "OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, "
110
+ "DEEPSEEK_API_KEY, OPENROUTER_API_KEY, KIMI_API_KEY"
111
+ )
112
+
113
+ def chat(
114
+ self,
115
+ prompt: str,
116
+ system: Optional[str] = None,
117
+ messages: Optional[list[dict]] = None,
118
+ response_format: Optional[str] = None,
119
+ ) -> str:
120
+ """
121
+ Send a chat completion request with cascade fallback.
122
+
123
+ Args:
124
+ prompt: User message
125
+ system: System prompt
126
+ messages: Full message list (overrides prompt/system if provided)
127
+ response_format: "json" for JSON mode
128
+
129
+ Returns:
130
+ Assistant response text
131
+ """
132
+ if messages is None:
133
+ messages = []
134
+ if system:
135
+ messages.append({"role": "system", "content": system})
136
+ messages.append({"role": "user", "content": prompt})
137
+
138
+ last_error = None
139
+ for provider in self.config.providers:
140
+ try:
141
+ return self._call_provider(provider, messages, response_format)
142
+ except Exception as e:
143
+ last_error = e
144
+ logger.warning(f"Provider {provider['name']} failed: {e}")
145
+ continue
146
+
147
+ raise RuntimeError(f"All LLM providers failed. Last error: {last_error}")
148
+
149
+ def chat_json(self, prompt: str, system: Optional[str] = None) -> dict:
150
+ """
151
+ Chat with JSON response parsing.
152
+
153
+ Returns parsed JSON dict.
154
+ """
155
+ response = self.chat(prompt, system=system, response_format="json")
156
+ # Try to extract JSON from response
157
+ try:
158
+ return json.loads(response)
159
+ except json.JSONDecodeError:
160
+ # Try to find JSON in response
161
+ start = response.find("{")
162
+ end = response.rfind("}") + 1
163
+ if start >= 0 and end > start:
164
+ return json.loads(response[start:end])
165
+ raise ValueError(f"Could not parse JSON from response: {response[:200]}")
166
+
167
+ def _call_provider(
168
+ self, provider: dict, messages: list[dict], response_format: Optional[str]
169
+ ) -> str:
170
+ """Call a specific LLM provider."""
171
+ import requests
172
+
173
+ headers = {
174
+ "Content-Type": "application/json",
175
+ }
176
+
177
+ payload = {
178
+ "model": provider["model"],
179
+ "messages": messages,
180
+ "temperature": self.config.temperature,
181
+ "max_tokens": self.config.max_tokens,
182
+ }
183
+
184
+ if response_format == "json":
185
+ if provider["name"] == "openai":
186
+ payload["response_format"] = {"type": "json_object"}
187
+ elif provider["name"] == "anthropic":
188
+ # Anthropic doesn't have native JSON mode, use system prompt
189
+ pass
190
+
191
+ if provider["name"] == "anthropic":
192
+ return self._call_anthropic(provider, messages, payload)
193
+ else:
194
+ return self._call_openai_compatible(provider, headers, payload)
195
+
196
+ def _call_openai_compatible(
197
+ self, provider: dict, headers: dict, payload: dict
198
+ ) -> str:
199
+ """Call OpenAI-compatible API."""
200
+ import requests
201
+
202
+ headers["Authorization"] = f"Bearer {provider['api_key']}"
203
+
204
+ # Add provider-specific headers
205
+ if provider.get("base_url", "").startswith("https://openrouter.ai"):
206
+ headers["HTTP-Referer"] = "https://github.com/ulsreall/web3-agent-kit"
207
+ headers["X-Title"] = "web3-agent-kit"
208
+
209
+ url = f"{provider['base_url']}/chat/completions"
210
+
211
+ resp = requests.post(
212
+ url, headers=headers, json=payload, timeout=self.config.timeout
213
+ )
214
+
215
+ if resp.status_code == 429:
216
+ raise RuntimeError(f"Rate limited by {provider['name']}")
217
+ if resp.status_code >= 500:
218
+ raise RuntimeError(f"Server error from {provider['name']}: {resp.status_code}")
219
+
220
+ resp.raise_for_status()
221
+ data = resp.json()
222
+
223
+ return data["choices"][0]["message"]["content"]
224
+
225
+ def _call_anthropic(
226
+ self, provider: dict, messages: list[dict], payload: dict
227
+ ) -> str:
228
+ """Call Anthropic API."""
229
+ import requests
230
+
231
+ headers = {
232
+ "Content-Type": "application/json",
233
+ "x-api-key": provider["api_key"],
234
+ "anthropic-version": "2023-06-01",
235
+ }
236
+
237
+ # Convert messages format
238
+ system_msg = None
239
+ api_messages = []
240
+ for msg in messages:
241
+ if msg["role"] == "system":
242
+ system_msg = msg["content"]
243
+ else:
244
+ api_messages.append(msg)
245
+
246
+ payload = {
247
+ "model": provider["model"],
248
+ "max_tokens": self.config.max_tokens,
249
+ "messages": api_messages,
250
+ }
251
+ if system_msg:
252
+ payload["system"] = system_msg
253
+
254
+ url = f"{provider['base_url']}/messages"
255
+
256
+ resp = requests.post(
257
+ url, headers=headers, json=payload, timeout=self.config.timeout
258
+ )
259
+
260
+ if resp.status_code == 429:
261
+ raise RuntimeError(f"Rate limited by Anthropic")
262
+ if resp.status_code >= 500:
263
+ raise RuntimeError(f"Server error from Anthropic: {resp.status_code}")
264
+
265
+ resp.raise_for_status()
266
+ data = resp.json()
267
+
268
+ return data["content"][0]["text"]
269
+
270
+ def __repr__(self) -> str:
271
+ names = [p["name"] for p in self.config.providers]
272
+ return f"LLM(providers={names})"
src/portfolio.py ADDED
@@ -0,0 +1,326 @@
1
+ """Portfolio dashboard — real-time balance, P&L, positions across chains.
2
+
3
+ Tracks wallet balances, token holdings, and calculates total portfolio value.
4
+
5
+ Usage:
6
+ from web3_agent_kit.portfolio import PortfolioTracker
7
+
8
+ tracker = PortfolioTracker(chain_manager, wallet)
9
+ summary = tracker.get_summary()
10
+ print(summary)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import Optional
20
+
21
+ from .wallet import Wallet
22
+ from .chain import Chain, ChainManager
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ERC20 ABI (minimal)
28
+ ERC20_ABI = json.loads("""[
29
+ {
30
+ "inputs": [{"internalType": "address", "name": "account", "type": "address"}],
31
+ "name": "balanceOf",
32
+ "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
33
+ "stateMutability": "view",
34
+ "type": "function"
35
+ },
36
+ {
37
+ "inputs": [],
38
+ "name": "decimals",
39
+ "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}],
40
+ "stateMutability": "view",
41
+ "type": "function"
42
+ },
43
+ {
44
+ "inputs": [],
45
+ "name": "symbol",
46
+ "outputs": [{"internalType": "string", "name": "", "type": "string"}],
47
+ "stateMutability": "view",
48
+ "type": "function"
49
+ },
50
+ {
51
+ "inputs": [],
52
+ "name": "name",
53
+ "outputs": [{"internalType": "string", "name": "", "type": "string"}],
54
+ "stateMutability": "view",
55
+ "type": "function"
56
+ }
57
+ ]""")
58
+
59
+ # Common token addresses per chain
60
+ KNOWN_TOKENS = {
61
+ Chain.ETHEREUM: {
62
+ "WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
63
+ "USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
64
+ "USDT": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
65
+ "DAI": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
66
+ "WBTC": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
67
+ "LINK": "0x514910771AF9Ca656af840dff83E8264EcF986CA",
68
+ "UNI": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984",
69
+ },
70
+ Chain.BASE: {
71
+ "WETH": "0x4200000000000000000000000000000000000006",
72
+ "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
73
+ "USDbC": "0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA",
74
+ "DAI": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
75
+ "AERO": "0x940181a94A35A4569E4529A3CDfB74e38FD98631",
76
+ },
77
+ Chain.ARBITRUM: {
78
+ "WETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
79
+ "USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
80
+ "USDT": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
81
+ "ARB": "0x912CE59144191C1204E64559FE8253a0e49E6548",
82
+ "GMX": "0xfc5A1A6EB076a2C7aD06eD22C90d7E710E35ad0a",
83
+ },
84
+ }
85
+
86
+ # Approximate USD prices (would need oracle in production)
87
+ ETH_PRICE_USD = 3500.0
88
+
89
+
90
+ @dataclass
91
+ class TokenBalance:
92
+ """Balance of a single token."""
93
+
94
+ symbol: str
95
+ address: str
96
+ balance: float
97
+ decimals: int
98
+ chain: Chain
99
+ price_usd: float = 0.0
100
+ value_usd: float = 0.0
101
+
102
+ def to_dict(self) -> dict:
103
+ return {
104
+ "symbol": self.symbol,
105
+ "address": self.address,
106
+ "balance": self.balance,
107
+ "chain": self.chain.value,
108
+ "price_usd": self.price_usd,
109
+ "value_usd": self.value_usd,
110
+ }
111
+
112
+
113
+ @dataclass
114
+ class ChainPortfolio:
115
+ """Portfolio summary for a single chain."""
116
+
117
+ chain: Chain
118
+ native_balance: float
119
+ native_value_usd: float
120
+ tokens: list[TokenBalance]
121
+ total_value_usd: float
122
+
123
+ def to_dict(self) -> dict:
124
+ return {
125
+ "chain": self.chain.value,
126
+ "native_balance": self.native_balance,
127
+ "native_value_usd": self.native_value_usd,
128
+ "tokens": [t.to_dict() for t in self.tokens],
129
+ "total_value_usd": self.total_value_usd,
130
+ }
131
+
132
+
133
+ @dataclass
134
+ class PortfolioSummary:
135
+ """Full portfolio summary across all chains."""
136
+
137
+ address: str
138
+ timestamp: float
139
+ chains: list[ChainPortfolio]
140
+ total_value_usd: float
141
+ total_native_balances: dict[str, float] # chain -> ETH balance
142
+
143
+ def to_dict(self) -> dict:
144
+ return {
145
+ "address": self.address,
146
+ "timestamp": self.timestamp,
147
+ "chains": [c.to_dict() for c in self.chains],
148
+ "total_value_usd": self.total_value_usd,
149
+ "total_native_balances": self.total_native_balances,
150
+ }
151
+
152
+ def __str__(self) -> str:
153
+ lines = [
154
+ f"📊 Portfolio: {self.address[:10]}...",
155
+ f"💰 Total Value: ${self.total_value_usd:,.2f}",
156
+ "",
157
+ ]
158
+ for cp in self.chains:
159
+ lines.append(f" 🔗 {cp.chain.value.upper()}: ${cp.total_value_usd:,.2f}")
160
+ lines.append(f" Native: {cp.native_balance:.4f} ETH (${cp.native_value_usd:,.2f})")
161
+ for token in cp.tokens:
162
+ if token.balance > 0:
163
+ lines.append(f" {token.symbol}: {token.balance:.4f} (${token.value_usd:,.2f})")
164
+ return "\n".join(lines)
165
+
166
+
167
+ class PortfolioTracker:
168
+ """
169
+ Track wallet portfolio across multiple chains.
170
+
171
+ Example:
172
+ tracker = PortfolioTracker(chain_manager, wallet)
173
+ summary = tracker.get_summary()
174
+ print(summary)
175
+ """
176
+
177
+ def __init__(
178
+ self,
179
+ chain_manager: ChainManager,
180
+ wallet: Wallet,
181
+ eth_price: float = ETH_PRICE_USD,
182
+ ):
183
+ self.chain_manager = chain_manager
184
+ self.wallet = wallet
185
+ self.eth_price = eth_price
186
+ self._history: list[PortfolioSummary] = []
187
+
188
+ def get_summary(self, chains: Optional[list[Chain]] = None) -> PortfolioSummary:
189
+ """
190
+ Get full portfolio summary.
191
+
192
+ Args:
193
+ chains: Chains to check (defaults to all configured chains)
194
+
195
+ Returns:
196
+ PortfolioSummary with all balances and values
197
+ """
198
+ if chains is None:
199
+ chains = self.chain_manager.list_chains()
200
+
201
+ chain_portfolios = []
202
+ total_value = 0.0
203
+ native_balances = {}
204
+
205
+ for chain in chains:
206
+ try:
207
+ cp = self._get_chain_portfolio(chain)
208
+ chain_portfolios.append(cp)
209
+ total_value += cp.total_value_usd
210
+ native_balances[chain.value] = cp.native_balance
211
+ except Exception as e:
212
+ logger.warning(f"Failed to get portfolio for {chain.value}: {e}")
213
+
214
+ summary = PortfolioSummary(
215
+ address=self.wallet.address,
216
+ timestamp=time.time(),
217
+ chains=chain_portfolios,
218
+ total_value_usd=total_value,
219
+ total_native_balances=native_balances,
220
+ )
221
+
222
+ self._history.append(summary)
223
+ return summary
224
+
225
+ def _get_chain_portfolio(self, chain: Chain) -> ChainPortfolio:
226
+ """Get portfolio for a single chain."""
227
+ # Get native balance
228
+ native_balance = self.wallet.get_balance(chain)
229
+ native_value = native_balance * self.eth_price
230
+
231
+ # Get token balances
232
+ tokens = []
233
+ tokens_value = 0.0
234
+
235
+ known = KNOWN_TOKENS.get(chain, {})
236
+ for symbol, address in known.items():
237
+ try:
238
+ token = self._get_token_balance(address, chain)
239
+ if token and token.balance > 0:
240
+ # Estimate value (simplified — would need oracle in production)
241
+ if symbol in ("USDC", "USDT", "DAI", "USDbC"):
242
+ token.price_usd = 1.0
243
+ token.value_usd = token.balance
244
+ elif symbol == "WETH":
245
+ token.price_usd = self.eth_price
246
+ token.value_usd = token.balance * self.eth_price
247
+ elif symbol == "WBTC":
248
+ token.price_usd = 60000.0
249
+ token.value_usd = token.balance * 60000.0
250
+ else:
251
+ # Unknown price — would need oracle
252
+ token.price_usd = 0.0
253
+ token.value_usd = 0.0
254
+
255
+ tokens.append(token)
256
+ tokens_value += token.value_usd
257
+ except Exception as e:
258
+ logger.debug(f"Failed to get {symbol} balance: {e}")
259
+
260
+ return ChainPortfolio(
261
+ chain=chain,
262
+ native_balance=native_balance,
263
+ native_value_usd=native_value,
264
+ tokens=tokens,
265
+ total_value_usd=native_value + tokens_value,
266
+ )
267
+
268
+ def _get_token_balance(self, token_address: str, chain: Chain) -> Optional[TokenBalance]:
269
+ """Get balance of a specific token."""
270
+ w3 = self.chain_manager.get_web3(chain)
271
+ token = w3.eth.contract(
272
+ address=w3.to_checksum_address(token_address),
273
+ abi=ERC20_ABI,
274
+ )
275
+
276
+ try:
277
+ balance_raw = token.functions.balanceOf(
278
+ w3.to_checksum_address(self.wallet.address)
279
+ ).call()
280
+ decimals = token.functions.decimals().call()
281
+ symbol = token.functions.symbol().call()
282
+
283
+ balance = balance_raw / (10 ** decimals)
284
+
285
+ return TokenBalance(
286
+ symbol=symbol,
287
+ address=token_address,
288
+ balance=balance,
289
+ decimals=decimals,
290
+ chain=chain,
291
+ )
292
+ except Exception as e:
293
+ logger.debug(f"Failed to get token balance: {e}")
294
+ return None
295
+
296
+ def get_history(self) -> list[PortfolioSummary]:
297
+ """Get portfolio history."""
298
+ return self._history
299
+
300
+ def get_pnl(self) -> dict:
301
+ """
302
+ Calculate P&L between first and last snapshot.
303
+
304
+ Returns:
305
+ Dict with pnl_absolute and pnl_percent
306
+ """
307
+ if len(self._history) < 2:
308
+ return {"pnl_absolute": 0.0, "pnl_percent": 0.0}
309
+
310
+ first = self._history[0]
311
+ last = self._history[-1]
312
+
313
+ pnl = last.total_value_usd - first.total_value_usd
314
+ pnl_pct = (pnl / first.total_value_usd * 100) if first.total_value_usd > 0 else 0
315
+
316
+ return {
317
+ "initial_value": first.total_value_usd,
318
+ "current_value": last.total_value_usd,
319
+ "pnl_absolute": pnl,
320
+ "pnl_percent": pnl_pct,
321
+ "timestamp_start": first.timestamp,
322
+ "timestamp_end": last.timestamp,
323
+ }
324
+
325
+ def __repr__(self) -> str:
326
+ return f"PortfolioTracker(wallet={self.wallet.address[:10]}...)"