232 lines
8.1 KiB
Python
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)
|