#!/usr/bin/env python3 """LobeHub Cloud Proxy v2 — Real LobeHub API via `lh generate text --json --stream` Pure OpenAI-compatible. No closerouter bullshit. Multi-account support. Usage: python lobehub_cli_proxy.py Config in Hermes config.yaml: - name: lobehub-cli api_key: [REDACTED] provider: openai base_url: http://127.0.0.1:20140/v1 discover_models: true Test: curl -s http://localhost:20140/health curl -s http://localhost:20140/v1/models curl -s -X POST http://localhost:20140/v1/chat/completions \\ -H "Content-Type: application/json" \\ -d '{"model":"openai/gpt-5.4","messages":[{"role":"user","content":"hi"}],"max_tokens":20}' """ import json, asyncio from aiohttp import web PORT = 20140 LH_CMD = r"C:\Users\User\AppData\Roaming\npm\lh.cmd" # Models from LobeHub Cloud (verified via `lh model list`) ALL_MODELS = [ {"id": "openai/gpt-5.5", "ctx": 131072}, {"id": "openai/gpt-5.4", "ctx": 131072}, {"id": "openai/gpt-5.4-mini", "ctx": 131072}, {"id": "anthropic/claude-opus-4-8", "ctx": 200000}, {"id": "anthropic/claude-sonnet-4-6", "ctx": 200000}, {"id": "anthropic/claude-haiku-4-5", "ctx": 200000}, {"id": "anthropic/claude-fable-5", "ctx": 200000}, {"id": "anthropic/claude-opus-4-7", "ctx": 200000}, {"id": "deepseek/deepseek-v4-pro", "ctx": 131072}, {"id": "deepseek/deepseek-v4-flash", "ctx": 131072}, {"id": "deepseek/deepseek-r1", "ctx": 131072}, ] # Status markers to filter out (NOT text content) SKIP_TOKENS = {"in_progress", "message_stop", "end_turn", "completed", "done", "max_tokens"} ACCOUNTS_FILE = r"C:\Users\User\lobehub_cloud_accounts.json" def load_accounts(): """Load accounts from JSON file (optional — CLI uses .lobehub/credentials.json)""" try: with open(ACCOUNTS_FILE, "r") as f: data = json.load(f) if isinstance(data, list): return data elif isinstance(data, dict) and "accounts" in data: return data["accounts"] except Exception: pass return [] async def run_lh(prompt, model, system="", max_tokens=4096, temperature=0.7): """Run lh generate text --json --stream and parse token stream. Yields events: {"type": "token"|"usage"|"delta"|"timing"|"done", ...} CRITICAL: use --stream --json together, not --json alone. CRITICAL: no `return` in async generator — use `yield` for final event.""" cmd = [ LH_CMD, "generate", "text", prompt, "--model", model, "--max-tokens", str(max_tokens), "--temperature", str(temperature), "--stream", "--json", ] if system: cmd.extend(["--system", system]) proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) text_buf = [] usage = None async for line in proc.stdout: line_str = line.decode("utf-8", errors="replace").strip() if not line_str: continue if line_str.startswith('"') and line_str.endswith('"'): # String token — text chunk try: token = [REDACTED]) if token and token not in SKIP_TOKENS: text_buf.append(token) yield {"type": "token", "text": token} except json.JSONDecodeError: pass elif line_str.startswith("{"): # JSON object — metadata try: obj = json.loads(line_str) if "totalTokens" in obj: usage = obj yield {"type": "usage", "data": obj} elif "delta" in obj: yield {"type": "delta", "data": obj} elif "duration" in obj: yield {"type": "timing", "data": obj} except json.JSONDecodeError: pass yield {"type": "done", "text": "".join(text_buf), "usage": usage} async def handle_models(request): return web.json_response({ "object": "list", "data": [{"id": m["id"], "object": "model", "owned_by": "lobehub", "context_length": m["ctx"]} for m in ALL_MODELS] }) async def handle_chat(request): try: body = await request.json() except Exception: return web.json_response({"error": "invalid json"}, status=400) model = body.get("model", "openai/gpt-5.4") messages = body.get("messages", []) if not messages: return web.json_response({"error": {"message": "messages required"}}, status=400) # Extract system prompt and user messages system = "" user_parts = [] for msg in messages: role = msg.get("role", "user") content = msg.get("content", "") if role == "system": system += content + "\n" elif role in ("user", "assistant"): user_parts.append(f"[{role}]: {content}") prompt = user_parts[-1] if user_parts else "hello" max_tokens = body.get("max_tokens", 4096) temperature = body.get("temperature", 0.7) stream = body.get("stream", False) if stream: response = web.StreamResponse() response.headers["Content-Type"] = "text/event-stream" response.headers["Cache-Control"] = "no-cache" await response.prepare(request) try: async for event in run_lh(prompt, model, system, max_tokens, temperature): if event["type"] == "token": chunk = { "id": "chatcmpl-lobehub", "object": "chat.completion.chunk", "model": model, "choices": [{"delta": {"content": event["text"]}, "index": 0}], } await response.write(f"data: {json.dumps(chunk)}\n\n".encode()) elif event["type"] == "error": err_chunk = {"error": {"message": event["text"][:200]}} await response.write(f"data: {json.dumps(err_chunk)}\n\n".encode()) except Exception as e: err_chunk = {"error": {"message": str(e)}} await response.write(f"data: {json.dumps(err_chunk)}\n\n".encode()) final = { "id": "chatcmpl-lobehub", "object": "chat.completion.chunk", "model": model, "choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}], } await response.write(f"data: {json.dumps(final)}\n\n".encode()) await response.write(b"data: [DONE]\n\n") await response.write_eof() return response else: all_text = [] try: async for event in run_lh(prompt, model, system, max_tokens, temperature): if event["type"] == "token": all_text.append(event["text"]) elif event["type"] == "error": return web.json_response({"error": {"message": event["text"][:200]}}, status=500) except Exception as e: return web.json_response({"error": {"message": str(e)}}, status=500) return web.json_response({ "id": "chatcmpl-lobehub", "object": "chat.completion", "model": model, "choices": [{ "index": 0, "message": {"role": "assistant", "content": "".join(all_text)}, "finish_reason": "stop", }], "usage": {"prompt_tokens": 0, "completion_tokens": len(all_text), "total_tokens": 0}, }) async def handle_health(request): accounts = load_accounts() return web.json_response({ "status": "ok", "backend": "lobehub-cli", "models": len(ALL_MODELS), "accounts": len(accounts), }) app = web.Application() app.router.add_get("/health", handle_health) app.router.add_get("/v1/models", handle_models) app.router.add_post("/v1/chat/completions", handle_chat) if __name__ == "__main__": accounts = load_accounts() print(f"[LobeHub CLI Proxy v2] :{PORT}") print(f" Backend: {LH_CMD}") print(f" Models: {len(ALL_MODELS)}") print(f" Accounts: {len(accounts)}") web.run_app(app, host="127.0.0.1", port=PORT, print=None)