sigma-terminal 3.4.0__py3-none-any.whl → 3.5.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.
@@ -0,0 +1,2 @@
1
+ from .service import SERVICE, BacktestService
2
+ from .simple_engine import run_backtest, get_available_strategies
@@ -0,0 +1,116 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import shlex
5
+ import subprocess
6
+ from typing import AsyncIterator, Dict, Any, Optional
7
+ import shutil
8
+
9
+ class BacktestService:
10
+ def __init__(self, data_dir: str = "~/.sigma/lean_data"):
11
+ self.data_dir = os.path.expanduser(data_dir)
12
+ self.lean_cli = shutil.which("lean") or "lean"
13
+
14
+ async def run_lean(self, algorithm_file: str, project_dir: str) -> AsyncIterator[Dict[str, Any]]:
15
+ """Run LEAN backtest and stream results."""
16
+
17
+ # Validate project structure
18
+ # lean backtest "Project Name"
19
+
20
+ # We assume the project is already "init-ed" or valid for LEAN.
21
+ # But for ad-hoc strategies, we might need a temp project.
22
+
23
+ project_name = os.path.basename(project_dir)
24
+ parent_dir = os.path.dirname(project_dir)
25
+
26
+ cmd = [
27
+ self.lean_cli, "backtest", project_name,
28
+ "--output", "backtest-result",
29
+ "--verbose"
30
+ ]
31
+
32
+ # LEAN runs in CWD usually or expects config.
33
+ # We should run execution in the parent dir of the project so "lean backtest Project" works.
34
+
35
+ process = await asyncio.create_subprocess_exec(
36
+ *cmd,
37
+ cwd=parent_dir,
38
+ stdout=asyncio.subprocess.PIPE,
39
+ stderr=asyncio.subprocess.PIPE
40
+ )
41
+
42
+ # Stream output
43
+ yield {"type": "status", "message": "Starting LEAN engine..."}
44
+
45
+ if process.stdout:
46
+ while True:
47
+ line = await process.stdout.readline()
48
+ if not line:
49
+ break
50
+
51
+ line_str = line.decode().strip()
52
+ if not line_str: continue
53
+
54
+ # Parse typical LEAN logs if possible
55
+ if "Error" in line_str:
56
+ yield {"type": "error", "message": line_str}
57
+ else:
58
+ yield {"type": "log", "message": line_str}
59
+
60
+ # Trying to detect progress or stats in logs
61
+ # LEAN logs: "STATISTICS:: ..."
62
+
63
+ await process.wait()
64
+
65
+ if process.returncode != 0:
66
+ err_msg = "Unknown error"
67
+ if process.stderr:
68
+ stderr_data = await process.stderr.read()
69
+ err_msg = stderr_data.decode()
70
+ yield {"type": "error", "message": f"LEAN failed: {err_msg}"}
71
+ return
72
+
73
+ # Parse results
74
+ json_file = os.path.join(project_dir, "backtest-result", f"{project_name}.json") # Filename varies usually?
75
+ # Actually lean backtest creates a file in the output dir with a name.
76
+ # We need to find the latest json in that dir.
77
+
78
+ result_dir = os.path.join(project_dir, "backtest-result")
79
+ if os.path.exists(result_dir):
80
+ files = [f for f in os.listdir(result_dir) if f.endswith(".json")]
81
+ if files:
82
+ # Get newest
83
+ latest = max([os.path.join(result_dir, f) for f in files], key=os.path.getctime)
84
+ try:
85
+ with open(latest, 'r') as f:
86
+ data = json.load(f)
87
+ yield {"type": "result", "data": data}
88
+ except Exception as e:
89
+ yield {"type": "error", "message": f"Failed to parse results: {e}"}
90
+ else:
91
+ yield {"type": "error", "message": "No result JSON found."}
92
+ else:
93
+ yield {"type": "error", "message": "No result directory found."}
94
+
95
+ async def create_project(self, name: str, code: str) -> str:
96
+ """Create a LEAN project with the given code."""
97
+ # Typically ~/.sigma/lean_projects/Name
98
+ projects_dir = os.path.expanduser("~/.sigma/lean_projects")
99
+ os.makedirs(projects_dir, exist_ok=True)
100
+
101
+ project_path = os.path.join(projects_dir, name)
102
+ os.makedirs(project_path, exist_ok=True)
103
+
104
+ # content
105
+ # main.py
106
+ with open(os.path.join(project_path, "main.py"), "w") as f:
107
+ f.write(code)
108
+
109
+ # config.json needed?
110
+ # lean init usually creates `lean.json` in root.
111
+ # We assume the user has a workspace or we set one up in ~/.sigma/lean_data?
112
+ # The service should ensure a workspace exists.
113
+
114
+ return project_path
115
+
116
+ SERVICE = BacktestService()
sigma/charts.py CHANGED
@@ -234,8 +234,8 @@ def create_technical_chart(
234
234
  go.Scatter(x=data.index, y=rsi, name="RSI", line=dict(color=SIGMA_THEME["accent"])),
235
235
  row=row, col=1
236
236
  )
237
- fig.add_hline(y=70, line_dash="dash", line_color=SIGMA_THEME["negative"], row=row, col=1)
238
- fig.add_hline(y=30, line_dash="dash", line_color=SIGMA_THEME["positive"], row=row, col=1)
237
+ fig.add_hline(y=70, line_dash="dash", line_color=SIGMA_THEME["negative"], row=row, col=1) # type: ignore
238
+ fig.add_hline(y=30, line_dash="dash", line_color=SIGMA_THEME["positive"], row=row, col=1) # type: ignore
239
239
  fig.update_yaxes(range=[0, 100], row=row, col=1)
240
240
  row += 1
241
241
 
sigma/cli.py CHANGED
@@ -1,4 +1,4 @@
1
- """CLI entry point for Sigma v3.4.0."""
1
+ """CLI entry point for Sigma v3.5.0."""
2
2
 
3
3
  import argparse
4
4
  import json
@@ -17,7 +17,7 @@ from .config import (
17
17
  )
18
18
 
19
19
 
20
- __version__ = "3.4.0"
20
+ __version__ = "3.5.0"
21
21
 
22
22
  console = Console()
23
23
 
@@ -32,7 +32,7 @@ def show_banner():
32
32
  [bold #3b82f6]███████║██║╚██████╔╝██║ ╚═╝ ██║██║ ██║[/bold #3b82f6]
33
33
  [bold #1d4ed8]╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝[/bold #1d4ed8]
34
34
 
35
- [dim]v3.4.0[/dim] [bold cyan]σ[/bold cyan] [bold]Finance Research Agent[/bold]
35
+ [dim]v3.5.0[/dim] [bold cyan]σ[/bold cyan] [bold]Finance Research Agent[/bold]
36
36
  """
37
37
  console.print(banner)
38
38
 
@@ -41,7 +41,7 @@ def main():
41
41
  """Main CLI entry point."""
42
42
  parser = argparse.ArgumentParser(
43
43
  prog="sigma",
44
- description="Sigma v3.4.0 - Finance Research Agent",
44
+ description="Sigma v3.5.0 - Finance Research Agent",
45
45
  formatter_class=argparse.RawDescriptionHelpFormatter,
46
46
  epilog="""
47
47
  Examples:
@@ -129,7 +129,7 @@ Examples:
129
129
  return 0
130
130
 
131
131
  if args.setup:
132
- from .setup import run_setup
132
+ from .setup_agent import run_setup
133
133
  result = run_setup()
134
134
  if result:
135
135
  mark_first_run_complete()
@@ -143,12 +143,12 @@ Examples:
143
143
  if is_first_run():
144
144
  console.print("\n[bold cyan]σ[/bold cyan] [bold]Welcome to Sigma![/bold]")
145
145
  console.print("[dim]First time setup detected. Launching setup wizard...[/dim]\n")
146
- from .setup import run_setup
146
+ from .setup_agent import run_setup
147
147
  result = run_setup()
148
148
  mark_first_run_complete() # Always mark complete to not ask again
149
149
 
150
150
  if result:
151
- console.print("\n[bold green][ok] Setup complete![/bold green]")
151
+ console.print("\n[bold green]Setup complete![/bold green]")
152
152
  console.print("[dim]Launching Sigma...[/dim]\n")
153
153
  import time
154
154
  time.sleep(1) # Brief pause for user to see message
@@ -238,15 +238,15 @@ Examples:
238
238
  def handle_ask(query: str) -> int:
239
239
  """Handle ask command."""
240
240
  import asyncio
241
- from .llm import get_llm
242
- from .tools import TOOLS, execute_tool
241
+ from .llm import get_router
242
+ from .tools import get_tools_for_llm, execute_tool
243
243
 
244
244
  settings = get_settings()
245
245
 
246
- console.print(f"\n[dim]Using {settings.default_provider.value} / {settings.default_model}[/dim]\n")
246
+ console.print(f"\n[dim]Using model: {settings.default_model}[/dim]\n")
247
247
 
248
248
  try:
249
- llm = get_llm(settings.default_provider, settings.default_model)
249
+ router = get_router(settings)
250
250
 
251
251
  async def run_query():
252
252
  """Run the query asynchronously."""
@@ -260,13 +260,15 @@ def handle_ask(query: str) -> int:
260
260
  console.print(f"[dim]Executing: {name}[/dim]")
261
261
  return execute_tool(name, args)
262
262
 
263
- response = await llm.generate(messages, TOOLS, handle_tool)
263
+ # Using stream=False for CLI quick question to get full text at once
264
+ response = await router.chat(messages, tools=get_tools_for_llm(), on_tool_call=handle_tool, stream=False)
264
265
  return response
265
266
 
266
267
  with console.status("[bold blue]σ analyzing...[/bold blue]"):
267
268
  response = asyncio.run(run_query())
268
269
 
269
- console.print(Panel(response, title="[bold cyan]σ Sigma[/bold cyan]"))
270
+ from rich.markdown import Markdown
271
+ console.print(Panel(Markdown(str(response)), title="[bold cyan]σ Sigma[/bold cyan]"))
270
272
  return 0
271
273
 
272
274
  except Exception as e:
sigma/comparison.py CHANGED
@@ -219,7 +219,7 @@ class ComparisonEngine:
219
219
 
220
220
  # Momentum score (recent performance relative to history)
221
221
  if len(returns) > 252:
222
- recent_return = (1 + returns.iloc[-63:]).prod() - 1 # 3 months
222
+ recent_return = float((1 + returns.iloc[-63:]).prod()) - 1 # 3 months # type: ignore
223
223
  historical_vol = returns.iloc[:-63].std()
224
224
  momentum_score = recent_return / (historical_vol * np.sqrt(63)) if historical_vol > 0 else 0
225
225
  else:
@@ -236,7 +236,7 @@ class ComparisonEngine:
236
236
  if len(returns) > 252:
237
237
  monthly_returns = returns.resample('M').apply(lambda x: (1 + x).prod() - 1)
238
238
  if len(monthly_returns) > 2:
239
- trend_persistence = monthly_returns.autocorr(lag=1)
239
+ trend_persistence = monthly_returns.autocorr(lag=1) # type: ignore
240
240
  else:
241
241
  trend_persistence = 0
242
242
  else:
sigma/config.py CHANGED
@@ -1,4 +1,4 @@
1
- """Configuration management for Sigma v3.4.0."""
1
+ """Configuration management for Sigma v3.5.0."""
2
2
 
3
3
  import os
4
4
  import shutil
@@ -11,7 +11,7 @@ from pydantic import Field
11
11
  from pydantic_settings import BaseSettings
12
12
 
13
13
 
14
- __version__ = "3.4.0"
14
+ __version__ = "3.5.0"
15
15
 
16
16
 
17
17
  class ErrorCode(IntEnum):
@@ -65,6 +65,7 @@ class SigmaError(Exception):
65
65
 
66
66
  class LLMProvider(str, Enum):
67
67
  """Supported LLM providers."""
68
+ SIGMA_CLOUD = "sigma_cloud" # Hack Club / Default
68
69
  GOOGLE = "google"
69
70
  OPENAI = "openai"
70
71
  ANTHROPIC = "anthropic"
@@ -73,16 +74,24 @@ class LLMProvider(str, Enum):
73
74
  OLLAMA = "ollama"
74
75
 
75
76
 
76
- # Available models per provider (Feb 2026 - LATEST MODELS ONLY)
77
+ # Available models per provider (Feb 2026 - REAL API NAMES)
77
78
  AVAILABLE_MODELS = {
79
+ "sigma_cloud": [
80
+ "moonshotai/kimi-k2.5", # Default via Hack Club
81
+ "gpt-4o",
82
+ "claude-3-5-sonnet-20240620"
83
+ ],
78
84
  "google": [
79
- "gemini-3-flash", # Latest fast model
80
- "gemini-3-pro", # Latest capable model
85
+ "gemini-3-flash-preview", # Fast multimodal, Pro-level at Flash speed
86
+ "gemini-3-pro-preview", # Multimodal reasoning (1M tokens)
87
+ "gemini-3-pro-image-preview", # Image+text focus (65K tokens)
81
88
  ],
82
89
  "openai": [
83
- "gpt-5", # Latest flagship
84
- "gpt-5-mini", # Latest efficient
85
- "o3", # Latest reasoning
90
+ "gpt-5", # Flagship general/agentic, multimodal (256K)
91
+ "gpt-5-mini", # Cost-efficient variant (256K)
92
+ "gpt-5.2", # Enterprise knowledge work, advanced reasoning
93
+ "gpt-5-nano", # Ultra-cheap/lightweight
94
+ "o3", # Advanced reasoning
86
95
  "o3-mini", # Fast reasoning
87
96
  ],
88
97
  "anthropic": [
@@ -309,18 +318,20 @@ class Settings(BaseSettings):
309
318
  """Application settings."""
310
319
 
311
320
  # Provider settings
312
- default_provider: LLMProvider = LLMProvider.GOOGLE
313
- default_model: str = Field(default="gemini-3-flash", alias="DEFAULT_MODEL")
321
+ default_provider: LLMProvider = LLMProvider.SIGMA_CLOUD
322
+ default_model: str = Field(default="gpt-4o", alias="DEFAULT_MODEL")
314
323
 
315
324
  # LLM API Keys
325
+ sigma_cloud_api_key: Optional[str] = Field(default=None, alias="SIGMA_CLOUD_API_KEY")
316
326
  google_api_key: Optional[str] = Field(default=None, alias="GOOGLE_API_KEY")
317
327
  openai_api_key: Optional[str] = Field(default=None, alias="OPENAI_API_KEY")
318
328
  anthropic_api_key: Optional[str] = Field(default=None, alias="ANTHROPIC_API_KEY")
319
329
  groq_api_key: Optional[str] = Field(default=None, alias="GROQ_API_KEY")
320
330
  xai_api_key: Optional[str] = Field(default=None, alias="XAI_API_KEY")
321
331
 
322
- # Model settings - LATEST MODELS ONLY (Feb 2026)
323
- google_model: str = "gemini-3-flash"
332
+ # Model settings - REAL API NAMES (Feb 2026)
333
+ sigma_cloud_model: str = "moonshotai/kimi-k2.5" # Should map to OpenAI-compatible endpoint
334
+ google_model: str = "gemini-3-flash-preview"
324
335
  openai_model: str = "gpt-5"
325
336
  anthropic_model: str = "claude-sonnet-4-20250514"
326
337
  groq_model: str = "llama-3.3-70b-versatile"
@@ -348,6 +359,7 @@ class Settings(BaseSettings):
348
359
  def get_api_key(self, provider: LLMProvider) -> Optional[str]:
349
360
  """Get API key for a provider."""
350
361
  key_map = {
362
+ LLMProvider.SIGMA_CLOUD: self.sigma_cloud_api_key,
351
363
  LLMProvider.GOOGLE: self.google_api_key,
352
364
  LLMProvider.OPENAI: self.openai_api_key,
353
365
  LLMProvider.ANTHROPIC: self.anthropic_api_key,
@@ -360,6 +372,7 @@ class Settings(BaseSettings):
360
372
  def get_model(self, provider: LLMProvider) -> str:
361
373
  """Get model for a provider."""
362
374
  model_map = {
375
+ LLMProvider.SIGMA_CLOUD: self.sigma_cloud_model,
363
376
  LLMProvider.GOOGLE: self.google_model,
364
377
  LLMProvider.OPENAI: self.openai_model,
365
378
  LLMProvider.ANTHROPIC: self.anthropic_model,
@@ -0,0 +1,93 @@
1
+ from typing import Any, Dict, List, Optional
2
+ from pydantic import BaseModel
3
+
4
+ from .intent import IntentParser
5
+ from ..utils.extraction import extract_tickers, extract_timeframe
6
+
7
+ class Request(BaseModel):
8
+ action: str
9
+ tickers: List[str]
10
+ timeframe: str
11
+ output_mode: str = "report" # report, memo, quant, summary, etc.
12
+ original_query: str
13
+ is_command: bool = False
14
+ details: Dict[str, Any] = {}
15
+
16
+ class CommandRouter:
17
+ def __init__(self):
18
+ self.intent_parser = IntentParser()
19
+
20
+ def parse(self, query: str) -> Request:
21
+ stripped = query.strip()
22
+
23
+ # Explicit commands
24
+ if stripped.startswith("/"):
25
+ parts = stripped.split(maxsplit=1)
26
+ cmd = parts[0][1:].lower() # remove /
27
+ args = parts[1] if len(parts) > 1 else ""
28
+
29
+ # Map commands to standard request structures
30
+ if cmd == "backtest":
31
+ tickers = extract_tickers(args)
32
+ return Request(
33
+ action="backtest",
34
+ tickers=tickers,
35
+ timeframe="default",
36
+ output_mode="quant",
37
+ original_query=query,
38
+ is_command=True
39
+ )
40
+ elif cmd == "chart":
41
+ tickers = extract_tickers(args)
42
+ return Request(
43
+ action="chart",
44
+ tickers=tickers,
45
+ timeframe="default",
46
+ output_mode="chart",
47
+ original_query=query,
48
+ is_command=True
49
+ )
50
+ elif cmd == "model":
51
+ # Handled by UI/Engine specifically to switch model?
52
+ return Request(
53
+ action="config_model",
54
+ tickers=[],
55
+ timeframe="",
56
+ output_mode="system",
57
+ original_query=query,
58
+ is_command=True,
59
+ details={"model": args.strip()}
60
+ )
61
+ elif cmd == "setup":
62
+ return Request(action="setup", tickers=[], timeframe="", output_mode="system", original_query=query, is_command=True)
63
+
64
+ # Natural Language
65
+ tickers = extract_tickers(query)
66
+ tf_desc, start, end = extract_timeframe(query)
67
+
68
+ # Simple heuristic for output mode
69
+ output_mode = "report"
70
+ if "memo" in query.lower(): output_mode = "memo"
71
+ if "summary" in query.lower(): output_mode = "summary"
72
+ if "backtest" in query.lower() or "quant" in query.lower(): output_mode = "quant"
73
+
74
+ # Use existing IntentParser for heavier lifting if needed
75
+ # But Request object normalizes it for Engine
76
+ # Here we just do extraction logic
77
+
78
+ # Intent parser might give us plan, but we want a unified Request struct first?
79
+ # Actually Engine uses IntentParser.
80
+ # Let's wrap IntentParsing into "action" determination.
81
+
82
+ plan = self.intent_parser.parse(query)
83
+ action = plan.deliverable if plan else "analysis"
84
+
85
+ return Request(
86
+ action=action,
87
+ tickers=tickers,
88
+ timeframe=tf_desc,
89
+ output_mode=output_mode,
90
+ original_query=query,
91
+ is_command=False,
92
+ details={"plan": plan}
93
+ )
sigma/llm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .router import LLMRouter, get_router
2
+ from .registry import REGISTRY
3
+ from .providers.base import BaseLLM
@@ -0,0 +1,196 @@
1
+ import json
2
+ from typing import Any, Callable, Dict, List, Optional, Union, AsyncIterator
3
+ import logging
4
+
5
+ from .base import BaseLLM
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class AnthropicProvider(BaseLLM):
10
+ """Anthropic Claude client."""
11
+
12
+ provider_name = "anthropic"
13
+
14
+ def __init__(self, api_key: str, rate_limiter=None, base_url: Optional[str] = None):
15
+ super().__init__(rate_limiter)
16
+ from anthropic import AsyncAnthropic
17
+ self.client = AsyncAnthropic(api_key=api_key, base_url=base_url)
18
+
19
+ async def generate(
20
+ self,
21
+ messages: List[Dict[str, str]],
22
+ model: str,
23
+ tools: Optional[List[Dict[str, Any]]] = None,
24
+ on_tool_call: Optional[Callable] = None,
25
+ stream: bool = True,
26
+ json_mode: bool = False,
27
+ ) -> Union[str, AsyncIterator[str]]:
28
+ await self._wait_for_rate_limit()
29
+
30
+ # Format messages for Anthropic (separate system message)
31
+ system_prompt = ""
32
+ filtered_messages = []
33
+ for msg in messages:
34
+ if msg["role"] == "system":
35
+ system_prompt = msg["content"]
36
+ else:
37
+ filtered_messages.append(msg)
38
+
39
+ kwargs = {
40
+ "model": model,
41
+ "max_tokens": 4096,
42
+ "messages": filtered_messages,
43
+ "system": system_prompt,
44
+ }
45
+
46
+ if tools:
47
+ kwargs["tools"] = tools
48
+
49
+ if stream:
50
+ return self._stream_response(kwargs, on_tool_call, tools, messages) # Pass original messages for recursion
51
+ else:
52
+ return await self._block_response(kwargs, on_tool_call, tools, messages)
53
+
54
+ async def _block_response(self, kwargs, on_tool_call, tools, messages):
55
+ response = await self.client.messages.create(**kwargs)
56
+
57
+ # Check for tool usage
58
+ if response.stop_reason == "tool_use" and on_tool_call:
59
+ tool_results = []
60
+ assistant_content = []
61
+
62
+ # Construct assistant message parts
63
+ for block in response.content:
64
+ if block.type == "text":
65
+ assistant_content.append({"type": "text", "text": block.text})
66
+ elif block.type == "tool_use":
67
+ assistant_content.append({
68
+ "type": "tool_use",
69
+ "id": block.id,
70
+ "name": block.name,
71
+ "input": block.input
72
+ })
73
+
74
+ # Execute tool
75
+ try:
76
+ result = await on_tool_call(block.name, block.input)
77
+ except Exception as e:
78
+ result = {"error": str(e)}
79
+
80
+ tool_results.append({
81
+ "type": "tool_result",
82
+ "tool_use_id": block.id,
83
+ "content": json.dumps(result, default=str)
84
+ })
85
+
86
+ # Append exchange to history
87
+ new_messages = messages + [
88
+ {"role": "assistant", "content": assistant_content},
89
+ {"role": "user", "content": tool_results}
90
+ ]
91
+
92
+ # Recurse
93
+ # Re-format for next call
94
+ next_kwargs = kwargs.copy()
95
+ next_system = ""
96
+ next_filtered = []
97
+ for msg in new_messages:
98
+ if isinstance(msg.get("content"), str): # Basic text message
99
+ if msg["role"] == "system":
100
+ next_system = msg["content"]
101
+ else:
102
+ next_filtered.append(msg)
103
+ else: # Complex content
104
+ next_filtered.append(msg)
105
+
106
+ next_kwargs["messages"] = next_filtered
107
+ next_kwargs["system"] = next_system
108
+
109
+ return await self._block_response(next_kwargs, on_tool_call, tools, new_messages)
110
+
111
+ return response.content[0].text if response.content else ""
112
+
113
+ async def _stream_response(self, kwargs, on_tool_call, tools, messages) -> AsyncIterator[str]:
114
+ async with self.client.messages.stream(**kwargs) as stream:
115
+ current_tool_use: Dict[str, Any] = {}
116
+ current_text = ""
117
+ tool_uses = []
118
+
119
+ async for event in stream:
120
+ if event.type == "content_block_delta":
121
+ if event.delta.type == "text_delta":
122
+ yield event.delta.text
123
+ current_text += event.delta.text
124
+ elif event.delta.type == "input_json_delta":
125
+ # Accumulate JSON
126
+ current_tool_use["input_json"] = current_tool_use.get("input_json", "") + event.delta.partial_json
127
+
128
+ elif event.type == "content_block_start":
129
+ if event.content_block.type == "tool_use":
130
+ current_tool_use = {
131
+ "name": event.content_block.name,
132
+ "id": event.content_block.id,
133
+ "input_json": ""
134
+ }
135
+
136
+ elif event.type == "content_block_stop":
137
+ if current_tool_use:
138
+ try:
139
+ current_tool_use["input"] = json.loads(current_tool_use["input_json"])
140
+ except:
141
+ current_tool_use["input"] = {}
142
+ tool_uses.append(current_tool_use)
143
+ current_tool_use = {}
144
+
145
+ elif event.type == "message_stop":
146
+ pass
147
+
148
+ # If tools were used, execute and recurse
149
+ if tool_uses and on_tool_call:
150
+ # Reconstruct assistant message
151
+ assistant_content = []
152
+ if current_text:
153
+ assistant_content.append({"type": "text", "text": current_text})
154
+
155
+ tool_results = []
156
+ for tu in tool_uses:
157
+ assistant_content.append({
158
+ "type": "tool_use",
159
+ "id": tu["id"],
160
+ "name": tu["name"],
161
+ "input": tu["input"]
162
+ })
163
+ try:
164
+ res = await on_tool_call(tu["name"], tu["input"])
165
+ except Exception as e:
166
+ res = {"error": str(e)}
167
+
168
+ tool_results.append({
169
+ "type": "tool_result",
170
+ "tool_use_id": tu["id"],
171
+ "content": json.dumps(res, default=str)
172
+ })
173
+
174
+ new_messages = messages + [
175
+ {"role": "assistant", "content": assistant_content},
176
+ {"role": "user", "content": tool_results}
177
+ ]
178
+
179
+ # Prepare next call
180
+ next_kwargs = kwargs.copy()
181
+ next_system = ""
182
+ next_filtered = []
183
+ for msg in new_messages:
184
+ if isinstance(msg.get("content"), str):
185
+ if msg["role"] == "system":
186
+ next_system = msg["content"]
187
+ else:
188
+ next_filtered.append(msg)
189
+ else:
190
+ next_filtered.append(msg)
191
+
192
+ next_kwargs["messages"] = next_filtered
193
+ next_kwargs["system"] = next_system
194
+
195
+ async for chunk in self._stream_response(next_kwargs, on_tool_call, tools, new_messages):
196
+ yield chunk
@@ -0,0 +1,29 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Callable, Dict, List, Optional, Union
3
+ from ..rate_limit import RateLimiter
4
+
5
+ class BaseLLM(ABC):
6
+ """Base class for LLM clients."""
7
+
8
+ provider_name: str = "base"
9
+
10
+ def __init__(self, rate_limiter: Optional[RateLimiter] = None):
11
+ self.rate_limiter = rate_limiter
12
+
13
+ @abstractmethod
14
+ async def generate(
15
+ self,
16
+ messages: List[Dict[str, str]],
17
+ model: str,
18
+ tools: Optional[List[Dict[str, Any]]] = None,
19
+ on_tool_call: Optional[Callable] = None,
20
+ stream: bool = True,
21
+ json_mode: bool = False,
22
+ ) -> Union[str, Any]:
23
+ """Generate a response."""
24
+ pass
25
+
26
+ async def _wait_for_rate_limit(self):
27
+ """Apply rate limiting."""
28
+ if self.rate_limiter:
29
+ await self.rate_limiter.wait()