theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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")