RayMelius Claude Sonnet 4.6 commited on
Commit
49e29c1
·
1 Parent(s): 64eea99

Click model pill to switch LLM provider at runtime

Browse files

Backend:
- server.py: get_llm_provider() getter + switch_llm_provider() hot-swaps
sim.llm without restarting the simulation
- routes.py: GET /llm/providers returns available providers (those with
env keys set) + current; POST /llm/provider switches immediately

Frontend:
- #llm-model pill is now clickable (pointer cursor, hover highlight)
- Click opens a dark popup listing available providers with icons;
current provider shows a checkmark
- Selecting a different provider POSTs to the API and shows a toast;
clicking outside or re-clicking the pill closes the popup

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

Files changed (3) hide show
  1. src/soci/api/routes.py +35 -0
  2. src/soci/api/server.py +14 -0
  3. web/index.html +78 -0
src/soci/api/routes.py CHANGED
@@ -261,6 +261,41 @@ async def get_conversations(include_history: bool = True, limit: int = 20):
261
  return result
262
 
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  @router.get("/stats")
265
  async def get_stats():
266
  """Get simulation statistics and LLM usage."""
 
261
  return result
262
 
263
 
264
+ class SwitchProviderRequest(BaseModel):
265
+ provider: str
266
+
267
+
268
+ @router.get("/llm/providers")
269
+ async def get_llm_providers():
270
+ """Return available LLM providers (those with API keys set) and the current one."""
271
+ import os
272
+ from soci.api.server import get_llm_provider
273
+ current = get_llm_provider()
274
+ providers = []
275
+ if os.environ.get("ANTHROPIC_API_KEY"):
276
+ providers.append({"id": "claude", "label": "Claude (Anthropic)", "icon": "◆"})
277
+ if os.environ.get("GROQ_API_KEY"):
278
+ providers.append({"id": "groq", "label": "Groq (Llama 8B)", "icon": "⚡"})
279
+ if os.environ.get("GEMINI_API_KEY"):
280
+ providers.append({"id": "gemini", "label": "Gemini 2.0 Flash", "icon": "✦"})
281
+ providers.append( {"id": "ollama", "label": "Ollama (local)", "icon": "🦙"})
282
+ return {"current": current, "providers": providers}
283
+
284
+
285
+ @router.post("/llm/provider")
286
+ async def set_llm_provider(req: SwitchProviderRequest):
287
+ """Hot-swap the active LLM provider."""
288
+ from soci.api.server import switch_llm_provider
289
+ valid = {"claude", "groq", "gemini", "ollama"}
290
+ if req.provider not in valid:
291
+ raise HTTPException(status_code=400, detail=f"Unknown provider '{req.provider}'")
292
+ try:
293
+ await switch_llm_provider(req.provider)
294
+ return {"ok": True, "provider": req.provider}
295
+ except Exception as e:
296
+ raise HTTPException(status_code=500, detail=str(e))
297
+
298
+
299
  @router.get("/stats")
300
  async def get_stats():
301
  """Get simulation statistics and LLM usage."""
src/soci/api/server.py CHANGED
@@ -53,6 +53,20 @@ def get_database() -> Database:
53
  return _database
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  async def simulation_loop(sim: Simulation, db: Database, tick_delay: float = 2.0) -> None:
57
  """Background task that runs the simulation continuously."""
58
  global _sim_paused, _sim_speed
 
53
  return _database
54
 
55
 
56
+ def get_llm_provider() -> str:
57
+ return _llm_provider
58
+
59
+
60
+ async def switch_llm_provider(provider: str) -> None:
61
+ """Hot-swap the LLM client on the running simulation."""
62
+ global _llm_provider, _simulation
63
+ assert _simulation is not None, "Simulation not initialized"
64
+ new_llm = create_llm_client(provider=provider)
65
+ _simulation.llm = new_llm
66
+ _llm_provider = provider
67
+ logger.info(f"LLM provider switched to: {provider} ({new_llm.__class__.__name__})")
68
+
69
+
70
  async def simulation_loop(sim: Simulation, db: Database, tick_delay: float = 2.0) -> None:
71
  """Background task that runs the simulation continuously."""
72
  global _sim_paused, _sim_speed
web/index.html CHANGED
@@ -132,6 +132,28 @@
132
  .toast.gossip { border-left-color: #9b59b6; }
133
  @keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
134
  @keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  .section-header {
136
  font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600;
137
  display: flex; align-items: center; gap: 6px;
@@ -266,6 +288,7 @@
266
  <canvas id="cityCanvas"></canvas>
267
  <div id="tooltip"></div>
268
  <div id="toast-container"></div>
 
269
  <input type="range" id="pan-x" min="0" max="100" value="0"
270
  style="position:absolute;bottom:4px;left:10px;right:10px;width:calc(100% - 20px);height:14px;opacity:0.5;z-index:50;"
271
  oninput="onPanSlider()">
@@ -3322,6 +3345,61 @@ async function checkSession() {
3322
  } catch(e) { renderPlayerPanel(); }
3323
  }
3324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3325
  // ============================================================
3326
  // INIT
3327
  // ============================================================
 
132
  .toast.gossip { border-left-color: #9b59b6; }
133
  @keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
134
  @keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
135
+ #llm-model { cursor: pointer; user-select: none; }
136
+ #llm-model:hover { color: #fff; }
137
+ #llm-popup {
138
+ display: none; position: fixed; z-index: 9999;
139
+ background: #0d1b2e; border: 1px solid #1a3a6e;
140
+ border-radius: 8px; padding: 6px 0; min-width: 200px;
141
+ box-shadow: 0 6px 24px rgba(0,0,0,0.6);
142
+ font-size: 13px;
143
+ }
144
+ #llm-popup .llm-pop-title {
145
+ padding: 6px 14px 8px; color: #4ecca3; font-size: 11px;
146
+ font-weight: 700; letter-spacing: 1px; border-bottom: 1px solid #0f3460;
147
+ margin-bottom: 4px;
148
+ }
149
+ #llm-popup .llm-opt {
150
+ display: flex; align-items: center; gap: 10px;
151
+ padding: 7px 14px; cursor: pointer; color: #c8c8d8;
152
+ transition: background 0.15s;
153
+ }
154
+ #llm-popup .llm-opt:hover { background: #0f3460; color: #fff; }
155
+ #llm-popup .llm-opt.active { color: #4ecca3; }
156
+ #llm-popup .llm-opt .llm-check { width: 12px; text-align: center; font-size: 10px; }
157
  .section-header {
158
  font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600;
159
  display: flex; align-items: center; gap: 6px;
 
288
  <canvas id="cityCanvas"></canvas>
289
  <div id="tooltip"></div>
290
  <div id="toast-container"></div>
291
+ <div id="llm-popup"><div class="llm-pop-title">SWITCH LLM</div></div>
292
  <input type="range" id="pan-x" min="0" max="100" value="0"
293
  style="position:absolute;bottom:4px;left:10px;right:10px;width:calc(100% - 20px);height:14px;opacity:0.5;z-index:50;"
294
  oninput="onPanSlider()">
 
3345
  } catch(e) { renderPlayerPanel(); }
3346
  }
3347
 
3348
+ // ============================================================
3349
+ // LLM PROVIDER SWITCHER
3350
+ // ============================================================
3351
+ let _llmPopupOpen = false;
3352
+
3353
+ document.getElementById('llm-model').addEventListener('click', async (e) => {
3354
+ e.stopPropagation();
3355
+ const popup = document.getElementById('llm-popup');
3356
+ if (_llmPopupOpen) { popup.style.display = 'none'; _llmPopupOpen = false; return; }
3357
+
3358
+ // Position below the clicked element
3359
+ const rect = e.currentTarget.getBoundingClientRect();
3360
+ popup.style.left = rect.left + 'px';
3361
+ popup.style.top = (rect.bottom + 6) + 'px';
3362
+
3363
+ // Fetch available providers
3364
+ try {
3365
+ const res = await fetch(`${API_BASE}/llm/providers`);
3366
+ const data = await res.json();
3367
+ const existing = popup.querySelectorAll('.llm-opt');
3368
+ existing.forEach(el => el.remove());
3369
+
3370
+ for (const p of data.providers) {
3371
+ const row = document.createElement('div');
3372
+ row.className = 'llm-opt' + (p.id === data.current ? ' active' : '');
3373
+ row.innerHTML = `<span class="llm-check">${p.id === data.current ? '✔' : ''}</span>
3374
+ <span style="font-size:15px">${p.icon}</span>
3375
+ <span>${p.label}</span>`;
3376
+ row.addEventListener('click', async () => {
3377
+ popup.style.display = 'none'; _llmPopupOpen = false;
3378
+ if (p.id === data.current) return;
3379
+ try {
3380
+ const r = await fetch(`${API_BASE}/llm/provider`, {
3381
+ method: 'POST',
3382
+ headers: {'Content-Type': 'application/json'},
3383
+ body: JSON.stringify({provider: p.id}),
3384
+ });
3385
+ if (!r.ok) { const err = await r.json(); showToast(`LLM switch failed: ${err.detail}`, 'event'); return; }
3386
+ showToast(`Switched to ${p.label}`, 'conv');
3387
+ } catch (err) { showToast('LLM switch error', 'event'); }
3388
+ });
3389
+ popup.appendChild(row);
3390
+ }
3391
+ popup.style.display = 'block';
3392
+ _llmPopupOpen = true;
3393
+ } catch { showToast('Could not fetch providers', 'event'); }
3394
+ });
3395
+
3396
+ document.addEventListener('click', () => {
3397
+ if (_llmPopupOpen) {
3398
+ document.getElementById('llm-popup').style.display = 'none';
3399
+ _llmPopupOpen = false;
3400
+ }
3401
+ });
3402
+
3403
  // ============================================================
3404
  // INIT
3405
  // ============================================================