mock_hermes.py (9099B)
1 """Mock Hermes API server for local development and browser testing. 2 3 Provides all endpoints the dashboard proxies to, with in-memory storage 4 and a simulated SSE chat stream that echoes messages back. 5 6 Usage: 7 python mock_hermes.py # runs on port 8642 8 python mock_hermes.py 9999 # custom port 9 """ 10 11 from __future__ import annotations 12 13 import json 14 import sys 15 import time 16 import uuid 17 from collections import defaultdict 18 from contextlib import asynccontextmanager 19 20 from fastapi import FastAPI, Request 21 from fastapi.responses import JSONResponse, StreamingResponse 22 from pydantic import BaseModel 23 24 # ── In-memory storage ────────────────────────────────────────────── 25 26 sessions: dict[str, dict] = {} 27 messages: dict[str, list[dict]] = defaultdict(list) 28 29 30 def _new_id() -> str: 31 return uuid.uuid4().hex[:12] 32 33 34 def _now() -> float: 35 return time.time() 36 37 38 # ── Lifespan (seed a demo session) ───────────────────────────────── 39 40 @asynccontextmanager 41 async def lifespan(app: FastAPI): 42 sid = _new_id() 43 sessions[sid] = { 44 "id": sid, 45 "name": "Demo Session", 46 "created_at": _now(), 47 "updated_at": _now(), 48 "model": None, 49 } 50 messages[sid].append({ 51 "id": _new_id(), 52 "role": "assistant", 53 "content": "Hello! I'm a mock Hermes. Send me a message and I'll echo it back.", 54 "created_at": _now(), 55 "model": "mock-v1", 56 "tool_calls": [], 57 "tool_results": [], 58 }) 59 yield 60 61 62 app = FastAPI(title="Mock Hermes API", lifespan=lifespan) 63 64 65 # ── Health ────────────────────────────────────────────────────────── 66 67 @app.get("/health") 68 async def health(): 69 return {"status": "ok", "platform": "mock-hermes", "version": "0.0.1"} 70 71 72 # ── Models ────────────────────────────────────────────────────────── 73 74 @app.get("/v1/models") 75 async def list_models(): 76 return [ 77 {"id": "mock-v1", "name": "Mock Model v1"}, 78 {"id": "mock-fast", "name": "Mock Fast"}, 79 {"id": "mock-smart", "name": "Mock Smart"}, 80 ] 81 82 83 # ── Sessions CRUD ────────────────────────────────────────────────── 84 85 class CreateSession(BaseModel): 86 name: str | None = None 87 88 89 class UpdateSession(BaseModel): 90 name: str | None = None 91 model: str | None = None 92 93 94 @app.get("/api/sessions") 95 async def list_sessions(): 96 return list(sessions.values()) 97 98 99 @app.post("/api/sessions", status_code=201) 100 async def create_session(body: CreateSession): 101 sid = _new_id() 102 session = { 103 "id": sid, 104 "name": body.name or f"Session {len(sessions) + 1}", 105 "created_at": _now(), 106 "updated_at": _now(), 107 "model": None, 108 } 109 sessions[sid] = session 110 messages[sid] = [] 111 return session 112 113 114 @app.get("/api/sessions/{session_id}") 115 async def get_session(session_id: str): 116 if session_id not in sessions: 117 return JSONResponse({"error": "Not found"}, status_code=404) 118 return sessions[session_id] 119 120 121 @app.patch("/api/sessions/{session_id}") 122 async def update_session(session_id: str, body: UpdateSession): 123 if session_id not in sessions: 124 return JSONResponse({"error": "Not found"}, status_code=404) 125 s = sessions[session_id] 126 if body.name is not None: 127 s["name"] = body.name 128 if body.model is not None: 129 s["model"] = body.model 130 s["updated_at"] = _now() 131 return s 132 133 134 @app.delete("/api/sessions/{session_id}", status_code=204) 135 async def delete_session(session_id: str): 136 sessions.pop(session_id, None) 137 messages.pop(session_id, None) 138 return None 139 140 141 # ── Messages ──────────────────────────────────────────────────────── 142 143 class SendMessage(BaseModel): 144 content: str 145 146 147 @app.get("/api/sessions/{session_id}/messages") 148 async def get_messages(session_id: str): 149 if session_id not in sessions: 150 return JSONResponse({"error": "Not found"}, status_code=404) 151 return messages[session_id] 152 153 154 @app.post("/api/sessions/{session_id}/messages") 155 async def post_message(session_id: str, body: SendMessage): 156 if session_id not in sessions: 157 return JSONResponse({"error": "Not found"}, status_code=404) 158 msg = { 159 "id": _new_id(), 160 "role": "user", 161 "content": body.content, 162 "created_at": _now(), 163 "model": None, 164 "tool_calls": [], 165 "tool_results": [], 166 } 167 messages[session_id].append(msg) 168 return msg 169 170 171 # ── Chat stream (SSE) ────────────────────────────────────────────── 172 173 @app.get("/api/sessions/{session_id}/chat/stream") 174 async def chat_stream(session_id: str): 175 """Simulate an SSE chat response. Echoes the last user message back 176 with tool-call events for realism.""" 177 178 if session_id not in sessions: 179 return JSONResponse({"error": "Not found"}, status_code=404) 180 181 user_msgs = [m for m in messages[session_id] if m["role"] == "user"] 182 last_user = user_msgs[-1]["content"] if user_msgs else "No message" 183 184 run_id = _new_id() 185 msg_id = _new_id() 186 187 def generate(): 188 # run.started 189 yield _sse("run.started", {"run_id": run_id}) 190 191 # Simulate a tool call (search) 192 tool_id = _new_id() 193 yield _sse("tool.started", { 194 "tool_id": tool_id, 195 "tool_name": "search", 196 "args": {"query": last_user[:50]}, 197 }) 198 time.sleep(0.2) 199 yield _sse("tool.completed", { 200 "tool_id": tool_id, 201 "result": f"Found 0 results for '{last_user[:50]}'", 202 }) 203 204 # Assistant message 205 yield _sse("message.started", { 206 "id": msg_id, 207 "role": "assistant", 208 }) 209 210 # Stream the response in chunks 211 reply = f"Echo: {last_user}" 212 for i in range(0, len(reply), 10): 213 chunk = reply[i:i+10] 214 yield _sse("assistant.delta", {"delta": chunk}) 215 time.sleep(0.05) 216 217 yield _sse("assistant.completed", { 218 "id": msg_id, 219 "content": reply, 220 "model": "mock-v1", 221 }) 222 223 # Save assistant message 224 messages[session_id].append({ 225 "id": msg_id, 226 "role": "assistant", 227 "content": reply, 228 "created_at": _now(), 229 "model": "mock-v1", 230 "tool_calls": [{"id": tool_id, "name": "search", "args": {"query": last_user[:50]}}], 231 "tool_results": [{"tool_id": tool_id, "result": f"Found 0 results for '{last_user[:50]}'"}], 232 }) 233 234 yield _sse("run.completed", {"run_id": run_id}) 235 236 return StreamingResponse(generate(), media_type="text/event-stream") 237 238 239 def _sse(event: str, data: dict) -> str: 240 return f"event: {event}\ndata: {json.dumps(data)}\n\n" 241 242 243 # ── Fork ──────────────────────────────────────────────────────────── 244 245 @app.post("/api/sessions/{session_id}/fork", status_code=201) 246 async def fork_session(session_id: str): 247 if session_id not in sessions: 248 return JSONResponse({"error": "Not found"}, status_code=404) 249 orig = sessions[session_id] 250 new_id = _new_id() 251 forked = { 252 "id": new_id, 253 "name": f"{orig['name']} (fork)", 254 "created_at": _now(), 255 "updated_at": _now(), 256 "model": orig.get("model"), 257 } 258 sessions[new_id] = forked 259 messages[new_id] = [m.copy() for m in messages.get(session_id, [])] 260 return forked 261 262 263 # ── Search ────────────────────────────────────────────────────────── 264 265 @app.get("/api/search") 266 async def search_messages(q: str = ""): 267 results = [] 268 for sid, msgs in messages.items(): 269 for m in msgs: 270 if q.lower() in m.get("content", "").lower(): 271 results.append({ 272 "session_id": sid, 273 "message": m, 274 }) 275 return {"query": q, "results": results[:20]} 276 277 278 # ── Main ──────────────────────────────────────────────────────────── 279 280 if __name__ == "__main__": 281 import uvicorn 282 port = int(sys.argv[1]) if len(sys.argv) > 1 else 8642 283 print(f"Mock Hermes starting on http://localhost:{port}") 284 uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")