RayMelius Claude Opus 4.6 commited on
Commit
301a57d
·
1 Parent(s): 59edb07

Add Ollama support for free local LLM inference

Browse files

New OllamaClient talks to locally-running open-source models (Llama 3,
Mistral, Qwen, etc.) via Ollama's API. Auto-detects provider based on
whether ANTHROPIC_API_KEY is set. Added --provider and --model CLI flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. .env.example +10 -1
  2. main.py +15 -4
  3. src/soci/api/server.py +2 -2
  4. src/soci/engine/llm.py +209 -29
.env.example CHANGED
@@ -1 +1,10 @@
1
- ANTHROPIC_API_KEY=sk-ant-your-key-here
 
 
 
 
 
 
 
 
 
 
1
+ # LLM Provider: "claude" or "ollama" (auto-detects if not set)
2
+ # LLM_PROVIDER=ollama
3
+
4
+ # For Claude (paid API):
5
+ # ANTHROPIC_API_KEY=sk-ant-api03-your-key-here
6
+
7
+ # For Ollama (free, local):
8
+ # Install: https://ollama.com
9
+ # Then: ollama pull llama3.1
10
+ # No API key needed!
main.py CHANGED
@@ -27,7 +27,7 @@ from rich.text import Text
27
  # Add src to path
28
  sys.path.insert(0, str(Path(__file__).parent / "src"))
29
 
30
- from soci.engine.llm import ClaudeClient
31
  from soci.engine.simulation import Simulation
32
  from soci.persistence.database import Database
33
  from soci.persistence.snapshots import save_simulation, load_simulation
@@ -118,16 +118,21 @@ async def run_simulation(
118
  max_agents: int = 20,
119
  tick_delay: float = 0.5,
120
  resume: bool = False,
 
 
121
  ) -> None:
122
  """Run the simulation with a live Rich dashboard."""
123
  # Initialize
124
  console.print("[bold blue]Initializing Soci City Simulation...[/]")
125
 
126
  try:
127
- llm = ClaudeClient()
128
- except ValueError as e:
 
 
 
 
129
  console.print(f"[bold red]Error: {e}[/]")
130
- console.print("Copy .env.example to .env and add your ANTHROPIC_API_KEY.")
131
  return
132
 
133
  db = Database()
@@ -212,6 +217,10 @@ def main():
212
  parser.add_argument("--agents", type=int, default=20, help="Max number of agents (default: 20)")
213
  parser.add_argument("--speed", type=float, default=0.5, help="Delay between ticks in seconds (default: 0.5)")
214
  parser.add_argument("--resume", action="store_true", help="Resume from last save")
 
 
 
 
215
  args = parser.parse_args()
216
 
217
  Path("data").mkdir(exist_ok=True)
@@ -226,6 +235,8 @@ def main():
226
  max_agents=args.agents,
227
  tick_delay=args.speed,
228
  resume=args.resume,
 
 
229
  ))
230
 
231
 
 
27
  # Add src to path
28
  sys.path.insert(0, str(Path(__file__).parent / "src"))
29
 
30
+ from soci.engine.llm import create_llm_client
31
  from soci.engine.simulation import Simulation
32
  from soci.persistence.database import Database
33
  from soci.persistence.snapshots import save_simulation, load_simulation
 
118
  max_agents: int = 20,
119
  tick_delay: float = 0.5,
120
  resume: bool = False,
121
+ provider: str = "",
122
+ model: str = "",
123
  ) -> None:
124
  """Run the simulation with a live Rich dashboard."""
125
  # Initialize
126
  console.print("[bold blue]Initializing Soci City Simulation...[/]")
127
 
128
  try:
129
+ llm = create_llm_client(
130
+ provider=provider or None,
131
+ model=model or None,
132
+ )
133
+ console.print(f"[green]LLM provider: {llm.provider} (model: {llm.default_model})[/]")
134
+ except (ValueError, ConnectionError) as e:
135
  console.print(f"[bold red]Error: {e}[/]")
 
136
  return
137
 
138
  db = Database()
 
217
  parser.add_argument("--agents", type=int, default=20, help="Max number of agents (default: 20)")
218
  parser.add_argument("--speed", type=float, default=0.5, help="Delay between ticks in seconds (default: 0.5)")
219
  parser.add_argument("--resume", action="store_true", help="Resume from last save")
220
+ parser.add_argument("--provider", type=str, default="", choices=["", "claude", "ollama"],
221
+ help="LLM provider: claude or ollama (default: auto-detect)")
222
+ parser.add_argument("--model", type=str, default="",
223
+ help="Model name (e.g. llama3.1, mistral, qwen2.5)")
224
  args = parser.parse_args()
225
 
226
  Path("data").mkdir(exist_ok=True)
 
235
  max_agents=args.agents,
236
  tick_delay=args.speed,
237
  resume=args.resume,
238
+ provider=args.provider,
239
+ model=args.model,
240
  ))
241
 
242
 
src/soci/api/server.py CHANGED
@@ -11,7 +11,7 @@ from typing import Optional
11
  from fastapi import FastAPI
12
  from fastapi.middleware.cors import CORSMiddleware
13
 
14
- from soci.engine.llm import ClaudeClient
15
  from soci.engine.simulation import Simulation
16
  from soci.persistence.database import Database
17
  from soci.persistence.snapshots import load_simulation, save_simulation
@@ -63,7 +63,7 @@ async def lifespan(app: FastAPI):
63
 
64
  # Start up
65
  logger.info("Starting Soci API server...")
66
- llm = ClaudeClient()
67
  db = Database()
68
  await db.connect()
69
  _database = db
 
11
  from fastapi import FastAPI
12
  from fastapi.middleware.cors import CORSMiddleware
13
 
14
+ from soci.engine.llm import create_llm_client
15
  from soci.engine.simulation import Simulation
16
  from soci.persistence.database import Database
17
  from soci.persistence.snapshots import load_simulation, save_simulation
 
63
 
64
  # Start up
65
  logger.info("Starting Soci API server...")
66
+ llm = create_llm_client()
67
  db = Database()
68
  await db.connect()
69
  _database = db
src/soci/engine/llm.py CHANGED
@@ -1,4 +1,4 @@
1
- """LLM client — Claude API wrapper with model routing, cost tracking, and prompt templates."""
2
 
3
  from __future__ import annotations
4
 
@@ -9,15 +9,26 @@ import time
9
  from dataclasses import dataclass, field
10
  from typing import Optional
11
 
12
- import anthropic
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
- # Model IDs
 
 
 
 
17
  MODEL_SONNET = "claude-sonnet-4-5-20250929"
18
  MODEL_HAIKU = "claude-haiku-4-5-20251001"
19
 
20
- # Approximate cost per 1M tokens (USD)
 
 
 
 
 
 
 
21
  COST_PER_1M = {
22
  MODEL_SONNET: {"input": 3.0, "output": 15.0},
23
  MODEL_HAIKU: {"input": 0.80, "output": 4.0},
@@ -48,7 +59,7 @@ class LLMUsage:
48
  def estimated_cost_usd(self) -> float:
49
  total = 0.0
50
  for model, tokens in self.tokens_by_model.items():
51
- costs = COST_PER_1M.get(model, {"input": 3.0, "output": 15.0})
52
  total += tokens["input"] / 1_000_000 * costs["input"]
53
  total += tokens["output"] / 1_000_000 * costs["output"]
54
  return total
@@ -65,8 +76,35 @@ class LLMUsage:
65
  return "\n".join(lines)
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  class ClaudeClient:
69
- """Wrapper around the Anthropic Claude API with model routing and retries."""
70
 
71
  def __init__(
72
  self,
@@ -74,6 +112,7 @@ class ClaudeClient:
74
  default_model: str = MODEL_HAIKU,
75
  max_retries: int = 3,
76
  ) -> None:
 
77
  self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
78
  if not self.api_key:
79
  raise ValueError(
@@ -83,6 +122,7 @@ class ClaudeClient:
83
  self.default_model = default_model
84
  self.max_retries = max_retries
85
  self.usage = LLMUsage()
 
86
 
87
  async def complete(
88
  self,
@@ -92,7 +132,7 @@ class ClaudeClient:
92
  temperature: float = 0.7,
93
  max_tokens: int = 1024,
94
  ) -> str:
95
- """Send a message to Claude and return the text response."""
96
  model = model or self.default_model
97
 
98
  for attempt in range(self.max_retries):
@@ -104,7 +144,6 @@ class ClaudeClient:
104
  system=system,
105
  messages=[{"role": "user", "content": user_message}],
106
  )
107
- # Track usage
108
  self.usage.record(
109
  model=model,
110
  input_tokens=response.usage.input_tokens,
@@ -121,7 +160,123 @@ class ClaudeClient:
121
  if attempt == self.max_retries - 1:
122
  raise
123
  time.sleep(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  return ""
126
 
127
  async def complete_json(
@@ -132,8 +287,6 @@ class ClaudeClient:
132
  temperature: float = 0.7,
133
  max_tokens: int = 1024,
134
  ) -> dict:
135
- """Send a message and parse the response as JSON."""
136
- # Add JSON instruction to the prompt
137
  json_instruction = (
138
  "\n\nRespond ONLY with valid JSON. No markdown, no explanation, no extra text. "
139
  "Just the JSON object."
@@ -145,25 +298,52 @@ class ClaudeClient:
145
  temperature=temperature,
146
  max_tokens=max_tokens,
147
  )
148
- # Try to extract JSON from the response
149
- text = text.strip()
150
- # Handle markdown code blocks
151
- if text.startswith("```"):
152
- lines = text.split("\n")
153
- text = "\n".join(lines[1:-1]) if len(lines) > 2 else text
154
- try:
155
- return json.loads(text)
156
- except json.JSONDecodeError:
157
- # Try to find JSON in the response
158
- start = text.find("{")
159
- end = text.rfind("}") + 1
160
- if start >= 0 and end > start:
161
- try:
162
- return json.loads(text[start:end])
163
- except json.JSONDecodeError:
164
- pass
165
- logger.warning(f"Failed to parse JSON from LLM response: {text[:200]}")
166
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
 
169
  # --- Prompt Templates ---
 
1
+ """LLM client — supports Claude API and Ollama (local LLMs) with model routing and cost tracking."""
2
 
3
  from __future__ import annotations
4
 
 
9
  from dataclasses import dataclass, field
10
  from typing import Optional
11
 
12
+ import httpx
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
+ # --- Provider constants ---
17
+ PROVIDER_CLAUDE = "claude"
18
+ PROVIDER_OLLAMA = "ollama"
19
+
20
+ # Claude model IDs
21
  MODEL_SONNET = "claude-sonnet-4-5-20250929"
22
  MODEL_HAIKU = "claude-haiku-4-5-20251001"
23
 
24
+ # Ollama model IDs (popular open-source models)
25
+ MODEL_LLAMA = "llama3.1"
26
+ MODEL_LLAMA_SMALL = "llama3.2"
27
+ MODEL_MISTRAL = "mistral"
28
+ MODEL_QWEN = "qwen2.5"
29
+ MODEL_GEMMA = "gemma2"
30
+
31
+ # Approximate cost per 1M tokens (USD) — Ollama is free
32
  COST_PER_1M = {
33
  MODEL_SONNET: {"input": 3.0, "output": 15.0},
34
  MODEL_HAIKU: {"input": 0.80, "output": 4.0},
 
59
  def estimated_cost_usd(self) -> float:
60
  total = 0.0
61
  for model, tokens in self.tokens_by_model.items():
62
+ costs = COST_PER_1M.get(model, {"input": 0.0, "output": 0.0})
63
  total += tokens["input"] / 1_000_000 * costs["input"]
64
  total += tokens["output"] / 1_000_000 * costs["output"]
65
  return total
 
76
  return "\n".join(lines)
77
 
78
 
79
+ def _parse_json_response(text: str) -> dict:
80
+ """Extract JSON from an LLM response, handling markdown blocks and extra text."""
81
+ text = text.strip()
82
+ # Handle markdown code blocks
83
+ if text.startswith("```"):
84
+ lines = text.split("\n")
85
+ text = "\n".join(lines[1:-1]) if len(lines) > 2 else text
86
+ text = text.strip()
87
+ try:
88
+ return json.loads(text)
89
+ except json.JSONDecodeError:
90
+ # Try to find JSON object in the response
91
+ start = text.find("{")
92
+ end = text.rfind("}") + 1
93
+ if start >= 0 and end > start:
94
+ try:
95
+ return json.loads(text[start:end])
96
+ except json.JSONDecodeError:
97
+ pass
98
+ logger.warning(f"Failed to parse JSON from LLM response: {text[:200]}")
99
+ return {}
100
+
101
+
102
+ # ============================================================
103
+ # Claude (Anthropic API) Client
104
+ # ============================================================
105
+
106
  class ClaudeClient:
107
+ """Wrapper around the Anthropic Claude API."""
108
 
109
  def __init__(
110
  self,
 
112
  default_model: str = MODEL_HAIKU,
113
  max_retries: int = 3,
114
  ) -> None:
115
+ import anthropic
116
  self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
117
  if not self.api_key:
118
  raise ValueError(
 
122
  self.default_model = default_model
123
  self.max_retries = max_retries
124
  self.usage = LLMUsage()
125
+ self.provider = PROVIDER_CLAUDE
126
 
127
  async def complete(
128
  self,
 
132
  temperature: float = 0.7,
133
  max_tokens: int = 1024,
134
  ) -> str:
135
+ import anthropic
136
  model = model or self.default_model
137
 
138
  for attempt in range(self.max_retries):
 
144
  system=system,
145
  messages=[{"role": "user", "content": user_message}],
146
  )
 
147
  self.usage.record(
148
  model=model,
149
  input_tokens=response.usage.input_tokens,
 
160
  if attempt == self.max_retries - 1:
161
  raise
162
  time.sleep(1)
163
+ return ""
164
+
165
+ async def complete_json(
166
+ self,
167
+ system: str,
168
+ user_message: str,
169
+ model: Optional[str] = None,
170
+ temperature: float = 0.7,
171
+ max_tokens: int = 1024,
172
+ ) -> dict:
173
+ json_instruction = (
174
+ "\n\nRespond ONLY with valid JSON. No markdown, no explanation, no extra text. "
175
+ "Just the JSON object."
176
+ )
177
+ text = await self.complete(
178
+ system=system,
179
+ user_message=user_message + json_instruction,
180
+ model=model,
181
+ temperature=temperature,
182
+ max_tokens=max_tokens,
183
+ )
184
+ return _parse_json_response(text)
185
+
186
 
187
+ # ============================================================
188
+ # Ollama (Local LLM) Client
189
+ # ============================================================
190
+
191
+ class OllamaClient:
192
+ """Wrapper around Ollama's local API for running open-source LLMs.
193
+
194
+ Ollama serves models locally at http://localhost:11434.
195
+ Install: https://ollama.com
196
+ Pull a model: ollama pull llama3.1
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ base_url: str = "http://localhost:11434",
202
+ default_model: str = MODEL_LLAMA,
203
+ max_retries: int = 2,
204
+ ) -> None:
205
+ self.base_url = base_url.rstrip("/")
206
+ self.default_model = default_model
207
+ self.max_retries = max_retries
208
+ self.usage = LLMUsage()
209
+ self.provider = PROVIDER_OLLAMA
210
+ self._http = httpx.Client(timeout=120.0)
211
+
212
+ async def complete(
213
+ self,
214
+ system: str,
215
+ user_message: str,
216
+ model: Optional[str] = None,
217
+ temperature: float = 0.7,
218
+ max_tokens: int = 1024,
219
+ ) -> str:
220
+ """Send a message to the local Ollama model."""
221
+ model = model or self.default_model
222
+ # Map Claude model names to Ollama models
223
+ model = self._map_model(model)
224
+
225
+ payload = {
226
+ "model": model,
227
+ "messages": [
228
+ {"role": "system", "content": system},
229
+ {"role": "user", "content": user_message},
230
+ ],
231
+ "stream": False,
232
+ "options": {
233
+ "temperature": temperature,
234
+ "num_predict": max_tokens,
235
+ },
236
+ }
237
+
238
+ for attempt in range(self.max_retries):
239
+ try:
240
+ response = self._http.post(
241
+ f"{self.base_url}/api/chat",
242
+ json=payload,
243
+ )
244
+ response.raise_for_status()
245
+ data = response.json()
246
+
247
+ # Track usage
248
+ input_tokens = data.get("prompt_eval_count", 0)
249
+ output_tokens = data.get("eval_count", 0)
250
+ self.usage.record(model, input_tokens, output_tokens)
251
+
252
+ return data.get("message", {}).get("content", "")
253
+
254
+ except httpx.ConnectError:
255
+ msg = (
256
+ f"Cannot connect to Ollama at {self.base_url}. "
257
+ "Make sure Ollama is running: 'ollama serve'"
258
+ )
259
+ logger.error(msg)
260
+ if attempt == self.max_retries - 1:
261
+ raise ConnectionError(msg)
262
+ time.sleep(1)
263
+ except httpx.HTTPStatusError as e:
264
+ if e.response.status_code == 404:
265
+ msg = (
266
+ f"Model '{model}' not found in Ollama. "
267
+ f"Pull it first: 'ollama pull {model}'"
268
+ )
269
+ logger.error(msg)
270
+ raise ValueError(msg)
271
+ logger.error(f"Ollama API error: {e}")
272
+ if attempt == self.max_retries - 1:
273
+ raise
274
+ time.sleep(1)
275
+ except Exception as e:
276
+ logger.error(f"Ollama error: {e}")
277
+ if attempt == self.max_retries - 1:
278
+ raise
279
+ time.sleep(1)
280
  return ""
281
 
282
  async def complete_json(
 
287
  temperature: float = 0.7,
288
  max_tokens: int = 1024,
289
  ) -> dict:
 
 
290
  json_instruction = (
291
  "\n\nRespond ONLY with valid JSON. No markdown, no explanation, no extra text. "
292
  "Just the JSON object."
 
298
  temperature=temperature,
299
  max_tokens=max_tokens,
300
  )
301
+ return _parse_json_response(text)
302
+
303
+ def _map_model(self, model: str) -> str:
304
+ """Map Claude model names to Ollama equivalents so existing code works."""
305
+ mapping = {
306
+ MODEL_SONNET: self.default_model, # Use the main local model
307
+ MODEL_HAIKU: self.default_model, # Same model for both (local is free)
308
+ }
309
+ return mapping.get(model, model)
310
+
311
+
312
+ # ============================================================
313
+ # Factory create the right client based on config
314
+ # ============================================================
315
+
316
+ def create_llm_client(
317
+ provider: Optional[str] = None,
318
+ model: Optional[str] = None,
319
+ ollama_url: str = "http://localhost:11434",
320
+ ) -> ClaudeClient | OllamaClient:
321
+ """Create an LLM client based on environment or explicit config.
322
+
323
+ Provider detection order:
324
+ 1. Explicit provider argument
325
+ 2. LLM_PROVIDER env var
326
+ 3. If ANTHROPIC_API_KEY is set → Claude
327
+ 4. Default → Ollama (free, local)
328
+ """
329
+ if provider is None:
330
+ provider = os.environ.get("LLM_PROVIDER", "").lower()
331
+
332
+ if not provider:
333
+ # Auto-detect: use Claude if key is set, otherwise Ollama
334
+ if os.environ.get("ANTHROPIC_API_KEY"):
335
+ provider = PROVIDER_CLAUDE
336
+ else:
337
+ provider = PROVIDER_OLLAMA
338
+
339
+ if provider == PROVIDER_CLAUDE:
340
+ default_model = model or MODEL_HAIKU
341
+ return ClaudeClient(default_model=default_model)
342
+ elif provider == PROVIDER_OLLAMA:
343
+ default_model = model or MODEL_LLAMA
344
+ return OllamaClient(base_url=ollama_url, default_model=default_model)
345
+ else:
346
+ raise ValueError(f"Unknown LLM provider: {provider}. Use 'claude' or 'ollama'.")
347
 
348
 
349
  # --- Prompt Templates ---