Click model pill to switch LLM provider at runtime
Browse filesBackend:
- 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>
- src/soci/api/routes.py +35 -0
- src/soci/api/server.py +14 -0
- 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 |
// ============================================================
|