Skip to content

Commit a1a638d

Browse files
Zawwarsami16claude
andcommitted
phase 6.0: production-readiness pack
structured access logs: new logger zhub.access at INFO. one line per request: <status> <method> <path> <latency_ms>ms [ai=<name>] middleware in server.py captures latency, identifies the AI from the path prefix when matchable, and records into hub metrics. per-AI latency in /metrics: /metrics now returns by_ai[name].request_count, total_latency_ms, max_latency_ms, avg_latency_ms. integer ms. counters reset on hub restart. percentiles will follow if/when needed. named cloudflared tunnels: python -m zhub up --tunnel-name <name> uses `cloudflared tunnel run <name>` for a stable hostname instead of the random *.trycloudflare.com from quick tunnels. one-time setup via `cloudflared tunnel route dns <name> <hostname>` documented in docs/DEPLOY.md. readme: architecture section gets a mermaid diagram showing the four flow classes (browser BYOK, http client, MCP host, federated peer hub) and the publisher / connection sides of the WS multiplex. docs/DEPLOY.md: end-to-end VPS deployment walkthrough. ~10 minutes from `ssh root@vps` to a stable https://hub.example.com/<ai>/v1 endpoint served by systemd-managed hub + named-tunnel pair, with rotating brain creds documented. 130/130 pytest now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 85ecc85 commit a1a638d

7 files changed

Lines changed: 445 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Tests: `pytest -v`. The e2e tests spin up the hub in-process and run the full pu
9191
- **Phase 5.1** ✅ — `python -m zhub up` one-shot quickstart: spawns hub on a free port, optionally Cloudflare tunnel, auto-detects brain, publishes, prints URL+KEY. `python -m zhub doctor` checks Python version, deps, optional cloudflared, brain credentials in env. New `zhub/__main__.py` dispatcher (subcommands: `up`, `server`, `doctor`). The "anyone can easily do it" gate.
9292
- **Phase 5.2** ✅ — Entity expansion: added `## install`, `## up`, `## paths` sections to `entity.md`. Header notes the file ships with the package so an AI installing zhub can read it locally before any hub is running.
9393
- **Phase 5.3** ✅ — `AnthropicAdapter` brain (5th adapter). Anthropic Messages API has its own SSE shape (`event:`/`data:` pairs, `content_block_delta` with `text_delta`); adapter normalizes to `ChatChunk` like the others. Registered in REGISTRY after Cerebras.
94+
- **Phase 6.0** ✅ — Production-readiness pack: structured access logs at `zhub.access` logger (one line per request: status + method + path + latency_ms + ai_name when applicable); per-AI latency tracking surfaced in `/metrics` (`request_count`, `total_latency_ms`, `max_latency_ms`, `avg_latency_ms`); `python -m zhub up --tunnel-name <name>` for cloudflared *named* tunnels (stable URL across restarts); README gets a mermaid arch diagram; new `docs/DEPLOY.md` walkthrough for a $5 VPS deployment with systemd unit files for hub + named tunnel.
9495

9596
**Next (not started):** true tool_call delta streaming through SSE (4.2b), multi-tier API keys, capability-only WS connections (a "tool provider" usable by any AI on the hub, not tied to one publisher), MCP resources/prompts surface.
9697

README.md

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -168,25 +168,39 @@ print(resp["text"])
168168

169169
## Architecture
170170

171+
```mermaid
172+
flowchart TB
173+
classDef ext fill:#1f2937,color:#e5e7eb,stroke:#4b5563
174+
classDef hub fill:#0f3460,color:#e5e7eb,stroke:#3b82f6
175+
classDef pub fill:#064e3b,color:#e5e7eb,stroke:#10b981
176+
classDef cli fill:#581c87,color:#e5e7eb,stroke:#a855f7
177+
178+
Pocket[browser BYOK<br/>e.g. Pocket]:::ext
179+
OpenAIPy[openai-py / curl<br/>any HTTP client]:::ext
180+
Claude[Claude Desktop /<br/>Cursor / Cline<br/>via MCP]:::ext
181+
182+
Hub((zhub hub<br/>FastAPI + WS<br/>SQLite persistence)):::hub
183+
184+
Pub1[publisher<br/>publish&#40;name, brain, ...&#41;]:::pub
185+
Pub2[publisher<br/>another AI]:::pub
186+
187+
Conn1[connection<br/>connect&#40;ai, key, capabilities&#41;]:::cli
188+
Conn2[connection<br/>another device]:::cli
189+
190+
Pocket -- HTTPS Bearer<br/>POST /v1/chat/completions --> Hub
191+
OpenAIPy -- HTTPS Bearer --> Hub
192+
Claude -- stdio JSON-RPC<br/>via zhub.mcp_server --> Hub
193+
194+
Hub <-- WS<br/>chat-request / chat-chunk<br/>invoke-result --> Pub1
195+
Hub <-- WS --> Pub2
196+
197+
Hub <-- WS<br/>chat-request<br/>invoke-request --> Conn1
198+
Hub <-- WS --> Conn2
199+
200+
Hub -. peer routing .- Hub2((peer hub)):::hub
171201
```
172-
[curl / openai-py / friend's app]
173-
│ HTTPS, Bearer key
174-
175-
┌──────────────────────────┐
176-
│ zhub hub server │
177-
│ • routes chat requests │
178-
│ • routes invokes │
179-
│ • holds the registry │
180-
└────────┬─────────────────┘
181-
│ WebSocket multiplex
182-
183-
┌────────┴──────────┐
184-
│ │
185-
[ AI publish() ] [ Client connect() ]
186-
│ chat_handler │ capability handlers
187-
│ │
188-
└─── bidirectional ─┘
189-
```
202+
203+
The hub is a router. State (publisher registry, in-flight requests, rate-limit windows, metrics, entity extensions) lives in the hub process; persistence is SQLite. Publishers and connections each hold one long-lived WebSocket. Federation: hubs peer each other and proxy chat completions / WS register-connection for AIs hosted elsewhere.
190204

191205
- The hub holds a registry of every published AI and every connection to it.
192206
- Publishers receive `connection-event` messages whenever a client connects, disconnects, or updates its capabilities.

docs/DEPLOY.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Deploy zhub on a $5 VPS in 10 minutes
2+
3+
Goal: a stable URL that survives reboots and gives you `https://your-domain/...` on a quiet server you forget about.
4+
5+
This guide assumes Ubuntu 22.04+ on a tiny box (1 vCPU, 512 MB RAM is fine for personal-tier loads). Adapt freely for Debian / Arch / Fedora; the steps are the same.
6+
7+
---
8+
9+
## 1. Server prep (90 seconds)
10+
11+
```bash
12+
ssh root@your-vps
13+
apt update && apt install -y python3-venv python3-pip git curl
14+
adduser zhub --disabled-password --gecos ""
15+
su - zhub
16+
```
17+
18+
## 2. Install zhub (60 seconds)
19+
20+
```bash
21+
git clone https://github.com/Zawwarsami16/zhub
22+
cd zhub
23+
python3 -m venv .venv && source .venv/bin/activate
24+
pip install -e '.[server,brains]'
25+
python -m zhub doctor # sanity check
26+
```
27+
28+
## 3. Cloudflare named tunnel — stable URL forever (4 minutes)
29+
30+
A *quick tunnel* (`--public-tunnel`) gives you a random `*.trycloudflare.com` URL that changes every restart. A *named tunnel* keeps the same hostname forever. One-time setup:
31+
32+
```bash
33+
# install cloudflared (linux/amd64)
34+
curl -L --output cloudflared.deb \
35+
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
36+
sudo dpkg -i cloudflared.deb
37+
38+
# log in once — opens a browser link, you authorize a CF zone you control
39+
cloudflared tunnel login
40+
41+
# create a named tunnel (does NOT expose anything yet)
42+
cloudflared tunnel create zhub-prod
43+
# → Created tunnel zhub-prod with id XYZ
44+
# → Tunnel credentials written to ~/.cloudflared/XYZ.json
45+
46+
# point a hostname (must be in a CF zone you own) at the tunnel
47+
cloudflared tunnel route dns zhub-prod hub.example.com
48+
```
49+
50+
Now `hub.example.com` is wired to your tunnel. The tunnel itself isn't running yet — that's step 4.
51+
52+
## 4. Run zhub as a systemd service (3 minutes)
53+
54+
Two services: one for the hub + brain publisher, one for the cloudflared tunnel. Both auto-restart on failure.
55+
56+
`/etc/systemd/system/zhub.service`:
57+
58+
```ini
59+
[Unit]
60+
Description=zhub hub + brain publisher
61+
After=network-online.target
62+
Wants=network-online.target
63+
64+
[Service]
65+
Type=simple
66+
User=zhub
67+
WorkingDirectory=/home/zhub/zhub
68+
Environment="PATH=/home/zhub/zhub/.venv/bin:/usr/bin"
69+
# Set whichever brain creds you use:
70+
Environment="GROQ_API_KEY=gsk_REDACTED"
71+
ExecStart=/home/zhub/zhub/.venv/bin/python -m zhub up \
72+
--no-tunnel --port 8080 --name me \
73+
--db /home/zhub/zhub/zhub.db
74+
Restart=on-failure
75+
RestartSec=5
76+
77+
[Install]
78+
WantedBy=multi-user.target
79+
```
80+
81+
`/etc/systemd/system/zhub-tunnel.service`:
82+
83+
```ini
84+
[Unit]
85+
Description=cloudflared named tunnel for zhub
86+
After=zhub.service network-online.target
87+
Requires=zhub.service
88+
89+
[Service]
90+
Type=simple
91+
User=zhub
92+
ExecStart=/usr/bin/cloudflared tunnel --no-autoupdate run zhub-prod
93+
Restart=on-failure
94+
RestartSec=5
95+
96+
[Install]
97+
WantedBy=multi-user.target
98+
```
99+
100+
Enable + start:
101+
102+
```bash
103+
sudo systemctl daemon-reload
104+
sudo systemctl enable --now zhub.service zhub-tunnel.service
105+
sudo systemctl status zhub.service zhub-tunnel.service
106+
```
107+
108+
## 5. Verify (60 seconds)
109+
110+
```bash
111+
# health
112+
curl https://hub.example.com/healthz
113+
# → {"status":"ok","publishers":"1"}
114+
115+
# entity (zhub's self-knowledge)
116+
curl https://hub.example.com/entity | head -20
117+
118+
# the AI's manifest
119+
curl https://hub.example.com/me/manifest.json | jq .
120+
121+
# grab the api key the publisher generated (one-time on first start;
122+
# stable forever after because of --db persistence)
123+
sudo journalctl -u zhub.service --no-pager | grep -E "KEY:" | tail -1
124+
```
125+
126+
Paste `https://hub.example.com/me/v1` + the `zk_...` key into Pocket / openai-py / curl / Claude Desktop. Done.
127+
128+
---
129+
130+
## Operational notes
131+
132+
**Logs.** `journalctl -u zhub.service -f` for live tail. Each request shows up at INFO via the `zhub.access` logger:
133+
134+
```
135+
123 GET /healthz 0ms
136+
200 POST /me/v1/chat/completions 412ms ai=me
137+
```
138+
139+
**Metrics.** `curl https://hub.example.com/metrics` returns a JSON snapshot with per-AI request_count, total_latency_ms, max_latency_ms, avg_latency_ms, plus rate-limit / peer-proxy / tool-call counters. Pipe to a collector if you want history.
140+
141+
**Persistence.** `zhub.db` (SQLite) holds publishers + entity extensions. Survives reboots. Back it up the same way you'd back up any small file.
142+
143+
**Updating.** `cd ~/zhub && git pull && pip install -e '.[server,brains]' && sudo systemctl restart zhub.service`. The named tunnel keeps running.
144+
145+
**Resources.** zhub itself is ~30 MB RSS idle. The brain dominates: brain=ollama means a local model burning whatever Ollama burns; brain=groq/openai/cerebras/anthropic means just outbound HTTPS. For a brain-bills-elsewhere setup, a 512 MB / 1 vCPU box runs zhub + cloudflared + a publisher comfortably.
146+
147+
**Multi-AI.** Run more publishers with different `--name`s against the same hub. Each gets its own `zk_` key + URL. The hub doesn't need restart — just spawn another publisher process.
148+
149+
**Rotating the brain.** Stop the publisher service, change `Environment="..."` in the unit file with a new brain's creds, restart. The `zk_` key is preserved (persistence). External clients see no change; brain underneath silently swapped.

tests/test_metrics_latency.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Phase 6.0 — per-AI request latency surfaced in /metrics.
2+
3+
Each /<ai>/v1/* request bumps a per-AI rolling latency counter so
4+
operators can see how long the publisher is taking to respond.
5+
/metrics returns avg_latency_ms and max_latency_ms per AI.
6+
"""
7+
8+
import asyncio
9+
import socket
10+
import threading
11+
import time
12+
13+
import pytest
14+
15+
try:
16+
import fastapi # noqa
17+
import uvicorn # noqa
18+
import httpx # noqa
19+
DEPS_AVAILABLE = True
20+
except ImportError:
21+
DEPS_AVAILABLE = False
22+
23+
if DEPS_AVAILABLE:
24+
from zhub.server import create_app
25+
from zhub import publish
26+
27+
28+
def _free_port() -> int:
29+
with socket.socket() as s:
30+
s.bind(("", 0))
31+
return s.getsockname()[1]
32+
33+
34+
@pytest.fixture(scope="module")
35+
def lat_hub():
36+
if not DEPS_AVAILABLE:
37+
pytest.skip("fastapi/uvicorn/httpx not installed")
38+
port = _free_port()
39+
app = create_app()
40+
41+
def run():
42+
config = uvicorn.Config(app, host="127.0.0.1", port=port,
43+
log_level="warning")
44+
asyncio.run(uvicorn.Server(config).serve())
45+
46+
threading.Thread(target=run, daemon=True).start()
47+
for _ in range(30):
48+
try:
49+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
50+
break
51+
except OSError:
52+
time.sleep(0.1)
53+
yield port
54+
55+
56+
@pytest.mark.asyncio
57+
async def test_per_ai_latency_in_metrics(lat_hub):
58+
pub = publish(
59+
name="lat-bot",
60+
description="latency test",
61+
chat_handler=lambda m, o: "ok",
62+
hub_url=f"ws://127.0.0.1:{lat_hub}",
63+
)
64+
for _ in range(50):
65+
if pub.api_key:
66+
break
67+
await asyncio.sleep(0.1)
68+
69+
async with httpx.AsyncClient(timeout=5.0) as c:
70+
for _ in range(3):
71+
await c.post(
72+
f"http://127.0.0.1:{lat_hub}/{pub.name}/v1/chat/completions",
73+
json={"messages": [{"role": "user", "content": "x"}]},
74+
headers={"Authorization": f"Bearer {pub.api_key}"},
75+
)
76+
m = (await c.get(f"http://127.0.0.1:{lat_hub}/metrics")).json()
77+
78+
by_ai = m["by_ai"]
79+
assert pub.name in by_ai
80+
e = by_ai[pub.name]
81+
assert "avg_latency_ms" in e and e["avg_latency_ms"] >= 0
82+
assert "max_latency_ms" in e and e["max_latency_ms"] >= e["avg_latency_ms"]
83+
assert "request_count" in e and e["request_count"] >= 3

tests/test_request_logging.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Phase 6.0 — structured request logging middleware.
2+
3+
Every HTTP request to the hub is logged at INFO with a single line
4+
containing: method, path, status, latency_ms, ai_name (when path
5+
matches /<ai>/...). Logs go to stderr via the zhub.access logger so
6+
operators can tail them or pipe to a structured collector.
7+
"""
8+
9+
import asyncio
10+
import logging
11+
import socket
12+
import threading
13+
import time
14+
15+
import pytest
16+
17+
try:
18+
import fastapi # noqa
19+
import uvicorn # noqa
20+
import httpx # noqa
21+
DEPS_AVAILABLE = True
22+
except ImportError:
23+
DEPS_AVAILABLE = False
24+
25+
if DEPS_AVAILABLE:
26+
from zhub.server import create_app
27+
from zhub import publish
28+
29+
30+
def _free_port() -> int:
31+
with socket.socket() as s:
32+
s.bind(("", 0))
33+
return s.getsockname()[1]
34+
35+
36+
@pytest.fixture
37+
def hub_with_log_capture(caplog):
38+
if not DEPS_AVAILABLE:
39+
pytest.skip("fastapi/uvicorn/httpx not installed")
40+
port = _free_port()
41+
app = create_app()
42+
43+
def run():
44+
config = uvicorn.Config(app, host="127.0.0.1", port=port,
45+
log_level="warning")
46+
asyncio.run(uvicorn.Server(config).serve())
47+
48+
threading.Thread(target=run, daemon=True).start()
49+
for _ in range(30):
50+
try:
51+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
52+
break
53+
except OSError:
54+
time.sleep(0.1)
55+
caplog.set_level(logging.INFO, logger="zhub.access")
56+
yield port
57+
58+
59+
@pytest.mark.asyncio
60+
async def test_each_request_logged_with_status_and_latency(hub_with_log_capture, caplog):
61+
port = hub_with_log_capture
62+
async with httpx.AsyncClient(timeout=5.0) as c:
63+
await c.get(f"http://127.0.0.1:{port}/healthz")
64+
# Log message: "200 GET /healthz <ms>ms"
65+
msgs = [r.message for r in caplog.records if r.name == "zhub.access"]
66+
assert any("/healthz" in m and "200" in m and "ms" in m for m in msgs), \
67+
f"no access log line found: {msgs!r}"
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_ai_path_logs_include_ai_name(hub_with_log_capture, caplog):
72+
port = hub_with_log_capture
73+
pub = publish(
74+
name="logbot",
75+
description="x",
76+
chat_handler=lambda m, o: "ok",
77+
hub_url=f"ws://127.0.0.1:{port}",
78+
)
79+
for _ in range(50):
80+
if pub.api_key:
81+
break
82+
await asyncio.sleep(0.1)
83+
84+
caplog.clear()
85+
async with httpx.AsyncClient(timeout=5.0) as c:
86+
await c.post(
87+
f"http://127.0.0.1:{port}/{pub.name}/v1/chat/completions",
88+
json={"messages": [{"role": "user", "content": "hi"}]},
89+
headers={"Authorization": f"Bearer {pub.api_key}"},
90+
)
91+
msgs = [r.message for r in caplog.records if r.name == "zhub.access"]
92+
assert any("logbot" in m and "200" in m for m in msgs), \
93+
f"no AI-tagged access log: {msgs!r}"

0 commit comments

Comments
 (0)