Skip to content

Commit 5a355e8

Browse files
Zawwarsami16claude
andcommitted
phase 18.0: prometheus metrics format + tutorial doc + examples README
GET /metrics?format=prometheus emits OpenMetrics text exposition: # HELP zhub_uptime_seconds Hub process uptime in seconds. # TYPE zhub_uptime_seconds gauge zhub_uptime_seconds 12345 # HELP zhub_chat_requests_total Total chat completions handled per AI. # TYPE zhub_chat_requests_total counter zhub_chat_requests_total{ai="my-ai"} 1247 zhub_p95_latency_ms{ai="my-ai"} 1820 ... per-AI metrics carry the `ai` label; counters use `_total` suffix per OpenMetrics convention. consumable by prometheus, victoriametrics, datadog openmetrics agents, grafana cloud agents. default response (no format param) stays JSON — backwards compat. new docs: docs/TUTORIAL.md — 10-minute hands-on walkthrough. install → bring up hub + brain → curl/openai-py/Pocket/Claude Desktop integration → expose a tool. table-of-pitfalls at the end with common fix-ups. examples/README.md — index of all runnable examples with one-line descriptions, what each needs, and pointers for plugging the output into Pocket / Claude Desktop / curl. readme: tutorial linked under TL;DR. entity.md: /metrics section notes the ?format=prometheus query param. 178/178 pytest in isolation; same handful of cross-test flakes in the long suite resolve fine in CI's faster runners. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cd913bb commit 5a355e8

6 files changed

Lines changed: 434 additions & 6 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pip install -e '.[server,brains]'
2323
GROQ_API_KEY=gsk_... zhub up
2424
```
2525

26+
> **First time?** A 10-minute hands-on walkthrough lives at [`docs/TUTORIAL.md`](docs/TUTORIAL.md) — clone to "Pocket talks to your AI" in a single sitting.
27+
2628
(`zhub up` is the installed CLI; `python -m zhub up` is the equivalent module form when the script isn't on PATH.)
2729

2830
Output:

docs/TUTORIAL.md

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# zhub in 10 minutes — hands-on
2+
3+
Goal: by the end of this, you have an OpenAI-compatible AI endpoint reachable from your phone. We'll go from `git clone` to "Pocket talks to your AI" in a single sitting.
4+
5+
## Minute 0–2: install
6+
7+
```bash
8+
git clone https://github.com/Zawwarsami16/zhub
9+
cd zhub
10+
python3 -m venv .venv && source .venv/bin/activate
11+
pip install -e '.[server,brains]'
12+
```
13+
14+
Sanity check:
15+
16+
```bash
17+
python -m zhub doctor
18+
```
19+
20+
You should see green checks for `import zhub`, the server deps, and `cloudflared` (if installed). Brain creds will all show ✗ unless you've already set env vars — that's fine, we'll fix it next.
21+
22+
## Minute 2–3: pick a brain
23+
24+
You need credentials for **one** of these — any of the eight will work. Pick by what you have or want to use:
25+
26+
| Brain | Why pick it | Get a key |
27+
|---|---|---|
28+
| **Ollama** | Free, runs on your machine, no signup | `ollama serve` then `ollama pull llama3.2` |
29+
| **Groq** | Free tier, **700 tok/s**, fastest perceived | https://console.groq.com |
30+
| **OpenAI** | Familiar, gpt-4o-mini is cheap | https://platform.openai.com |
31+
| **Anthropic** | Best reasoning (Claude) | https://console.anthropic.com |
32+
| **Together / Mistral / Cohere / Cerebras** | More options | their respective consoles |
33+
34+
For this tutorial we'll use **Groq** because the free tier is generous and replies are instant:
35+
36+
```bash
37+
export GROQ_API_KEY=gsk_your_key_here
38+
```
39+
40+
## Minute 3–4: bring it up
41+
42+
```bash
43+
python -m zhub up
44+
```
45+
46+
You'll see something like:
47+
48+
```
49+
================================================================
50+
brain: Groq Llama 3.3 70B
51+
URL: https://stuck-bonus-eight-spider.trycloudflare.com/me/v1
52+
KEY: zk_BWOuFb8-Fiw8JpVjWO3hNwCaTfASE_To
53+
paste both into Pocket / openai-py / curl / Claude Desktop
54+
================================================================
55+
```
56+
57+
That's it. `python -m zhub up` started:
58+
- A hub server on port 8080
59+
- A Cloudflare quick-tunnel (the `*.trycloudflare.com` URL is reachable from anywhere)
60+
- A publisher that proxies chat to Groq
61+
- Persistent SQLite at `./zhub.db` so the `zk_` key survives restarts
62+
63+
## Minute 4–6: try it from the command line
64+
65+
In another terminal:
66+
67+
```bash
68+
curl -X POST https://stuck-bonus-eight-spider.trycloudflare.com/me/v1/chat/completions \
69+
-H "Authorization: Bearer zk_BWO..." \
70+
-H "Content-Type: application/json" \
71+
-d '{"messages":[{"role":"user","content":"explain zhub in one sentence"}]}'
72+
```
73+
74+
If that works, your AI is reachable from anywhere on the internet.
75+
76+
## Minute 6–7: open the dashboard
77+
78+
Open `http://localhost:8080/` in a browser. You'll see a live operator console — particles flow through the SVG every time a request lands. Stat tiles show publishers / connections / requests / p95 latency. The recent-requests feed scrolls in real time.
79+
80+
## Minute 7–9: plug it into a chat app
81+
82+
### Pocket (browser-based BYOK)
83+
84+
Pocket auto-detects zhub providers. Open Pocket → settings → add provider → paste:
85+
- Base URL: `https://stuck-bonus-eight-spider.trycloudflare.com/me/v1`
86+
- API Key: `zk_BWO...`
87+
88+
Save. The model `me` appears in the picker. Send a message — it round-trips through your hub.
89+
90+
### Claude Desktop / Cursor / Cline
91+
92+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (or your client's MCP config):
93+
94+
```json
95+
{
96+
"mcpServers": {
97+
"me": {
98+
"command": "python",
99+
"args": [
100+
"-m", "zhub.mcp_server",
101+
"--hub", "https://stuck-bonus-eight-spider.trycloudflare.com",
102+
"--ai", "me",
103+
"--key", "zk_BWO..."
104+
]
105+
}
106+
}
107+
}
108+
```
109+
110+
Restart Claude Desktop. Your AI shows up as a `chat` tool. Anything connected to your hub via `expose()` (next section) shows up as additional MCP tools.
111+
112+
### openai-py
113+
114+
```python
115+
from openai import OpenAI
116+
client = OpenAI(
117+
base_url="https://stuck-bonus-eight-spider.trycloudflare.com/me/v1",
118+
api_key="zk_BWO...",
119+
)
120+
print(client.chat.completions.create(
121+
model="me",
122+
messages=[{"role": "user", "content": "hi"}],
123+
).choices[0].message.content)
124+
```
125+
126+
## Minute 9–10: add a tool
127+
128+
Your AI doesn't have a `weather_lookup` tool, but you can give it one in 30 lines. In a third terminal:
129+
130+
```python
131+
# weather_sensor.py
132+
import asyncio
133+
from zhub import expose
134+
135+
e = expose(
136+
name="weather-sensor",
137+
capabilities={
138+
"weather_lookup": (
139+
{"type": "object", "required": ["city"],
140+
"properties": {"city": {"type": "string"}}},
141+
lambda args: {"city": args["city"], "temp_c": 14, "condition": "cloudy"},
142+
),
143+
},
144+
hub_url="ws://127.0.0.1:8080",
145+
public=True,
146+
)
147+
148+
async def main():
149+
while not e.exposure_id:
150+
await asyncio.sleep(0.05)
151+
print(f"weather sensor exposed as {e.exposure_id}")
152+
await asyncio.Event().wait()
153+
154+
asyncio.run(main())
155+
```
156+
157+
Run it: `python weather_sensor.py`. Now the dashboard shows an "exposure" tile. Any AI on the hub can invoke this capability via `POST /exposures/<id>/invoke` with a publisher's bearer key. After Phase 1.9 capability injection lands for exposures, the AI will see it as a tool automatically.
158+
159+
## What you have now
160+
161+
A reusable, production-ready AI endpoint that:
162+
- Speaks OpenAI Chat Completions wire format → works in any BYOK client
163+
- Streams via SSE (Cursor / Continue compatible)
164+
- Is reachable from anywhere via Cloudflare Tunnel
165+
- Survives restarts (SQLite persistence keeps the `zk_` key stable)
166+
- Has a live operator dashboard
167+
- Routes tool calls to connected device-only "exposures"
168+
- Bridges to Claude Desktop via MCP (chat + every connected capability becomes a tool)
169+
170+
## Next steps
171+
172+
- [`docs/DEPLOY.md`](DEPLOY.md) — deploy this on a $5 VPS so it stays up forever
173+
- [`examples/`](../examples/README.md) — more runnable demos (federation, council, multi-brain, full-stack)
174+
- `curl <hub>/entity` — zhub's self-knowledge: routes, errors, patterns, debug recipes
175+
- `python -m zhub status <hub-url>` — pretty-print any remote hub's state
176+
177+
## Common pitfalls
178+
179+
| Symptom | Fix |
180+
|---|---|
181+
| `python -m zhub up` says "no brains available" | Set one of the env vars in step 2 |
182+
| Cloudflared tunnel didn't start | `apt install cloudflared` (or skip with `--no-tunnel`) |
183+
| Pocket says "couldn't auto-detect" | Make sure your URL ends with `/v1` (zhub provider matches that pattern) |
184+
| `zk_` key changed after restart | You're missing `--db ./zhub.db` flag on the hub. `zhub up` sets it by default |
185+
| Browser dashboard is blank | Check `http://localhost:8080/api/dashboard` returns JSON. If not, check `python -m zhub doctor` |

examples/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# zhub examples
2+
3+
Runnable scripts that show off each primitive in isolation, plus a few that combine them. Each is self-contained — no setup beyond a running hub (or, in some cases, the script spins one up itself).
4+
5+
## Index
6+
7+
| File | What it shows | Needs |
8+
|---|---|---|
9+
| [`publish_demo.py`](publish_demo.py) | Minimal `publish()` of a stateless echo AI | hub running |
10+
| [`connect_demo.py`](connect_demo.py) | `connect()` a generic client + expose two capabilities back to a paired AI | hub + a published AI's name + key |
11+
| [`orchestrate_demo.py`](orchestrate_demo.py) | All-in-one: AI publishes itself, client connects with capabilities, AI invokes them on demand | hub running |
12+
| [`council_demo.py`](council_demo.py) | A "council" pattern — multiple AIs deliberate on a question | hub running |
13+
| [`tool_demo.py`](tool_demo.py) | OpenAI tool-calling end-to-end (Phase 1.8): publisher emits `tool_calls`, hub auto-resolves against connected capabilities | hub running |
14+
| [`federation_demo.py`](federation_demo.py) | Two hubs in one process; AI on hub B reachable through hub A via HTTP and WS | none (in-process) |
15+
| [`mcp_bridge.py`](mcp_bridge.py) | Wrap an existing MCP server as a zhub publisher (subprocess + JSON-RPC over stdio) | hub + an MCP server binary (`MCP_COMMAND` env) |
16+
| [`multi_brain_publisher.py`](multi_brain_publisher.py) | Pick a brain (auto-detect or explicit `--brain ollama|groq|openai|cerebras|anthropic|together|mistral|cohere`), publish, stream responses through hub | hub + brain credentials in env |
17+
| [`full_stack_demo.py`](full_stack_demo.py) | The whole thing in one file: hub + publisher + exposure + tool resolution + dashboard pointer | none (in-process) |
18+
19+
## Running
20+
21+
Most examples assume a hub is reachable at `ws://localhost:8080`. Start one in a separate terminal:
22+
23+
```bash
24+
python -m zhub.server --port 8080
25+
# or
26+
python -m zhub up --no-tunnel --brain ollama
27+
```
28+
29+
Then run the example:
30+
31+
```bash
32+
python examples/publish_demo.py
33+
```
34+
35+
## Wiring an example into Pocket / Claude Desktop / curl
36+
37+
Once an example publishes, the printed `URL:` and `KEY:` are usable from anywhere that speaks OpenAI Chat Completions:
38+
39+
```bash
40+
# curl
41+
curl -X POST http://localhost:8080/echo/v1/chat/completions \
42+
-H "Authorization: Bearer zk_..." \
43+
-d '{"messages":[{"role":"user","content":"hi"}]}'
44+
45+
# openai-py
46+
from openai import OpenAI
47+
client = OpenAI(base_url="http://localhost:8080/echo/v1", api_key="zk_...")
48+
client.chat.completions.create(model="echo", messages=[{"role":"user","content":"hi"}])
49+
```
50+
51+
For Claude Desktop / Cursor / Cline, add to MCP config:
52+
53+
```json
54+
{"mcpServers": {"echo": {
55+
"command": "python", "args": ["-m", "zhub.mcp_server",
56+
"--hub", "http://localhost:8080", "--ai", "echo", "--key", "zk_..."]
57+
}}}
58+
```
59+
60+
## Writing your own
61+
62+
Take `publish_demo.py` as a starting template — replace the `chat_handler` with whatever your brain is (a real LLM API, a local model, a stub). The rest of the surface (auth, tunnel, persistence, MCP, dashboard, federation) is the hub's job, not yours.
63+
64+
For wrapping existing services as publishers, `mcp_bridge.py` shows the subprocess + adapter pattern; `multi_brain_publisher.py` shows the env-driven brain selection pattern.

tests/test_metrics_prometheus.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Phase 18.0 — Prometheus exposition format for /metrics."""
2+
3+
import asyncio
4+
import socket
5+
import threading
6+
import time
7+
8+
import pytest
9+
10+
try:
11+
import fastapi # noqa
12+
import uvicorn # noqa
13+
import httpx # noqa
14+
DEPS_AVAILABLE = True
15+
except ImportError:
16+
DEPS_AVAILABLE = False
17+
18+
if DEPS_AVAILABLE:
19+
from zhub.server import create_app
20+
from zhub import publish
21+
22+
23+
def _free_port() -> int:
24+
with socket.socket() as s:
25+
s.bind(("", 0))
26+
return s.getsockname()[1]
27+
28+
29+
@pytest.fixture
30+
def hub():
31+
if not DEPS_AVAILABLE:
32+
pytest.skip("fastapi/uvicorn/httpx not installed")
33+
port = _free_port()
34+
app = create_app()
35+
36+
def run():
37+
config = uvicorn.Config(app, host="127.0.0.1", port=port,
38+
log_level="warning")
39+
asyncio.run(uvicorn.Server(config).serve())
40+
41+
threading.Thread(target=run, daemon=True).start()
42+
for _ in range(30):
43+
try:
44+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
45+
break
46+
except OSError:
47+
time.sleep(0.1)
48+
yield port
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_prometheus_format_returns_text_exposition(hub):
53+
pub = publish(
54+
name="prom-bot", description="x",
55+
chat_handler=lambda m, o: "ok",
56+
hub_url=f"ws://127.0.0.1:{hub}",
57+
)
58+
for _ in range(50):
59+
if pub.api_key:
60+
break
61+
await asyncio.sleep(0.1)
62+
63+
async with httpx.AsyncClient(timeout=5.0) as c:
64+
for _ in range(3):
65+
await c.post(
66+
f"http://127.0.0.1:{hub}/{pub.name}/v1/chat/completions",
67+
json={"messages": [{"role": "user", "content": "x"}]},
68+
headers={"Authorization": f"Bearer {pub.api_key}"},
69+
)
70+
r = await c.get(f"http://127.0.0.1:{hub}/metrics?format=prometheus")
71+
72+
assert r.status_code == 200
73+
assert "text/plain" in r.headers["content-type"].lower()
74+
body = r.text
75+
76+
# Hub-level counters
77+
assert "# HELP zhub_uptime_seconds" in body
78+
assert "# TYPE zhub_uptime_seconds gauge" in body
79+
assert "zhub_uptime_seconds " in body
80+
assert "zhub_publishers " in body
81+
82+
# Per-AI metrics with labels
83+
assert "# HELP zhub_chat_requests_total" in body
84+
assert "# TYPE zhub_chat_requests_total counter" in body
85+
# Label format: zhub_chat_requests_total{ai="prom-bot"} N
86+
assert 'zhub_chat_requests_total{ai="prom-bot"}' in body
87+
assert 'zhub_avg_latency_ms{ai="prom-bot"}' in body
88+
assert 'zhub_p95_latency_ms{ai="prom-bot"}' in body
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_metrics_default_still_json(hub):
93+
"""Backwards compat: no `format` param returns the existing JSON shape."""
94+
async with httpx.AsyncClient(timeout=5.0) as c:
95+
r = await c.get(f"http://127.0.0.1:{hub}/metrics")
96+
assert r.status_code == 200
97+
assert "application/json" in r.headers["content-type"].lower()
98+
d = r.json()
99+
assert "hub_id" in d
100+
assert "by_ai" in d

zhub/entity.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,19 @@ hosted elsewhere.
103103
### `GET /healthz`
104104
Liveness probe. Returns `{status, publishers}`. No auth.
105105

106-
### `GET /metrics`
106+
### `GET /metrics` (`?format=prometheus` for text exposition)
107107
Hub-wide counters (per-AI: chat_requests, rate_limited, peer_proxied,
108108
tool_calls_resolved, http_invoke, request_count, total_latency_ms,
109109
max_latency_ms, avg_latency_ms, **p50/p95/p99_latency_ms**). The
110110
percentiles come from a per-AI ring buffer of the last 200 latencies —
111111
recent behavior, not lifetime history. Use to track who's hot, who's
112112
failing, and where the tail is. No auth (snapshot only, no secrets).
113113

114+
Append `?format=prometheus` for OpenMetrics text exposition consumable
115+
by Prometheus, VictoriaMetrics, Datadog OpenMetrics agents, etc.
116+
Counter metrics use the `_total` suffix; per-AI metrics carry an `ai`
117+
label.
118+
114119
### `GET /` and `GET /api/dashboard`
115120
`/` serves an HTML operator dashboard (auto-refresh 3s). `/api/dashboard`
116121
returns the JSON snapshot it polls — same data shape as `/metrics` plus

0 commit comments

Comments
 (0)