Files
autoreg-skills-max/skills/lobehub-pool/scripts/lobehub_cli_proxy.py

232 lines
8.1 KiB
Python

#!/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)