Skip to content

Commit 85ecc85

Browse files
Zawwarsami16claude
andcommitted
phase 5.1 + 5.2 + 5.3: quickstart CLI + entity install/up/paths + Anthropic adapter
5.1 — `python -m zhub up`: one-shot bring-up. picks a free port, optionally starts a Cloudflare tunnel, auto-detects an available brain, publishes, prints URL + KEY in copy-pasteable format. Ctrl-C tears the lot down cleanly. also `python -m zhub doctor` — environment + creds + cloudflared check using the shipped entity recipes. new dispatcher at zhub/__main__.py with subcommands: up, server, doctor. 5.2 — entity expansion: added ## install, ## up, ## paths sections to zhub/entity.md so an AI installing zhub fetches it once and knows the whole flow, default disk locations, env-var conventions. note that the file ships in the package — `zhub/entity.md` — so it can be read before any hub is running. 5.3 — AnthropicAdapter: 5th brain in zhub/brains/. Anthropic Messages API streaming has its own SSE shape (event/data pairs, content_block_delta with text_delta); adapter normalizes to the same ChatChunk surface as the other four. registered in REGISTRY after Cerebras. detection via ANTHROPIC_API_KEY env. tests mock httpx; no real network in CI. 127/127 pytest now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a2f1701 commit 85ecc85

10 files changed

Lines changed: 791 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,12 @@ Tests: `pytest -v`. The e2e tests spin up the hub in-process and run the full pu
8787
- **Phase 4.0** ✅ — `zhub/brains/` package: `BrainAdapter` ABC + four streaming adapters (Ollama, Groq, OpenAI, Cerebras). `detect()` walks them in priority order. `examples/multi_brain_publisher.py` exposes `--brain auto|ollama|groq|openai|cerebras` so the brain underneath any zhub publisher is one CLI flag away from a swap. External clients (Pocket/Loki/curl/MCP) see no change; key stays stable across brain swaps via persistence.
8888
- **Phase 4.1** ✅ — Entity v2: operator-extensible. `POST/GET /entity/extend` and `DELETE /entity/extend/{id}` (auth: any registered publisher's bearer key). Extensions persist in SQLite (`entity_extensions` table), surface inline in `/entity/<section>` and at the title-matched code under `/entity/errors/<code>`, and live alongside shipped recipes (shipped wins on canonical conflicts). Caps: 8KB per body, 200 per hub. Each hub now grows its own institutional memory.
8989
- **Phase 4.2** ✅ — Pre-resolve streaming mode for tool calls. Header `X-Zhub-Stream-Tools: pre-resolve` + `stream:true` runs the full non-streaming auto-resolve loop internally, then emits the resolved final text as one SSE chunk + done. Trades stream-latency for tool-call correctness in streaming mode. The non-streaming auto-resolve loop is now a shared helper (`_run_autoresolve_loop`) used by both code paths. True per-token tool_call delta passthrough = future Phase 4.2b (needs brain-adapter + publisher-SDK changes to surface tool_call deltas).
90+
- **Phase 5.0** ✅ — Cleanup: removed ZAI/Loki coupling. zhub stays neutral substrate. Specifically: deleted `examples/zai_publish.py` + `examples/ZAI_PUBLISH.md`, deleted `kotlin/src/main/kotlin/com/zawwar/zhub/loki/` and `kotlin/LOKI_INTEGRATION.md`, scrubbed named-user references in README + mcp_server docstring + `__init__.py` + `connect_demo.py` + `orchestrate_demo.py`. Anyone embedding zhub uses the generic primitives; bridges to specific products live in those products' own repos.
91+
- **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.
92+
- **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.
93+
- **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.
9094

91-
**Next (not started):** real ZAI integration via `zai_publish.py`, multi-tier API keys, full tool_call streaming (4.2b), MCP resources/prompts surface.
95+
**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.
9296

9397
## 6. File layout (what's where)
9498

tests/test_brains_anthropic.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Tests for the Anthropic brain adapter."""
2+
3+
from typing import Iterable
4+
5+
import httpx
6+
import pytest
7+
8+
from zhub.brains.base import ChatChunk
9+
from zhub.brains.anthropic import AnthropicAdapter
10+
11+
12+
class _FakeStream:
13+
def __init__(self, lines: Iterable[str]):
14+
self._lines = list(lines)
15+
16+
async def aiter_lines(self):
17+
for line in self._lines:
18+
yield line
19+
20+
async def __aenter__(self):
21+
return self
22+
23+
async def __aexit__(self, *exc):
24+
return None
25+
26+
27+
class _FakeAsyncClient:
28+
def __init__(self, lines: Iterable[str]):
29+
self._lines = list(lines)
30+
self.last_call: dict | None = None
31+
32+
def stream(self, method, url, **kw):
33+
self.last_call = {"method": method, "url": url, **kw}
34+
return _FakeStream(self._lines)
35+
36+
async def aclose(self):
37+
pass
38+
39+
40+
def test_try_init_none_without_key(monkeypatch):
41+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
42+
assert AnthropicAdapter.try_init() is None
43+
44+
45+
def test_try_init_returns_adapter_when_probe_succeeds(monkeypatch):
46+
class R:
47+
status_code = 200
48+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-xyz")
49+
monkeypatch.setattr(httpx, "get",
50+
lambda url, headers=None, timeout=None: R())
51+
adapter = AnthropicAdapter.try_init()
52+
assert adapter is not None
53+
assert adapter.name == "anthropic"
54+
assert adapter.api_key == "sk-ant-xyz"
55+
56+
57+
@pytest.mark.asyncio
58+
async def test_stream_parses_anthropic_sse():
59+
"""Anthropic Messages API streams `event:` + `data:` SSE pairs.
60+
The relevant event types are `content_block_delta` (text deltas)
61+
and `message_stop` (terminal)."""
62+
lines = [
63+
'event: message_start',
64+
'data: {"type":"message_start","message":{"id":"msg_x"}}',
65+
'',
66+
'event: content_block_delta',
67+
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi "}}',
68+
'',
69+
'event: content_block_delta',
70+
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"there"}}',
71+
'',
72+
'event: message_delta',
73+
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}',
74+
'',
75+
'event: message_stop',
76+
'data: {"type":"message_stop"}',
77+
'',
78+
]
79+
fake = _FakeAsyncClient(lines)
80+
adapter = AnthropicAdapter(api_key="sk-ant-xyz",
81+
model="claude-sonnet-4-5",
82+
http=fake)
83+
out = [c async for c in adapter.stream(
84+
[{"role": "user", "content": "hi"}], system="be brief"
85+
)]
86+
deltas = [c.delta for c in out if c.delta]
87+
assert "".join(deltas) == "Hi there"
88+
assert out[-1].done is True
89+
assert out[-1].finish_reason == "end_turn"
90+
91+
body = fake.last_call["json"]
92+
headers = fake.last_call["headers"]
93+
assert body["model"] == "claude-sonnet-4-5"
94+
assert body["stream"] is True
95+
assert body["system"] == "be brief"
96+
assert body["messages"][-1] == {"role": "user", "content": "hi"}
97+
assert headers["x-api-key"] == "sk-ant-xyz"
98+
assert headers["anthropic-version"]
99+
100+
101+
def test_anthropic_in_default_registry():
102+
from zhub.brains import REGISTRY
103+
names = [c.name for c in REGISTRY]
104+
assert "anthropic" in names

tests/test_brains_registry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_registry_holds_classes_in_priority_order():
3737
The four shipped adapters land in this order: Ollama, Groq, OpenAI,
3838
Cerebras."""
3939
names = [cls.name for cls in REGISTRY]
40-
assert names == ["ollama", "groq", "openai", "cerebras"]
40+
assert names == ["ollama", "groq", "openai", "cerebras", "anthropic"]
4141

4242

4343
def test_detect_returns_first_available(monkeypatch):

tests/test_cli_up.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Test the `python -m zhub up` quickstart command.
2+
3+
The `up` command's job: in one shot, bring up a hub + (optionally) a
4+
brain publisher, and print URL + key + ready-to-paste BYOK config so
5+
any user (or AI installing zhub) can be reachable in one terminal.
6+
7+
For tests: invoke as a subprocess with --no-tunnel and a fake brain
8+
registered via env-var injected into a shim, on a free port. Verify
9+
stdout contains URL, key, and a BYOK summary.
10+
"""
11+
12+
import asyncio
13+
import os
14+
import socket
15+
import subprocess
16+
import sys
17+
import textwrap
18+
import time
19+
20+
import pytest
21+
22+
try:
23+
import fastapi # noqa
24+
import uvicorn # noqa
25+
import httpx # noqa
26+
DEPS_AVAILABLE = True
27+
except ImportError:
28+
DEPS_AVAILABLE = False
29+
30+
31+
def _free_port() -> int:
32+
with socket.socket() as s:
33+
s.bind(("", 0))
34+
return s.getsockname()[1]
35+
36+
37+
@pytest.mark.asyncio
38+
async def test_up_command_with_fake_brain_prints_url_and_key(tmp_path):
39+
"""`python -m zhub up --port <free> --no-tunnel --name testpub --brain fake`
40+
should boot a hub, publish, and emit URL/key on stdout."""
41+
if not DEPS_AVAILABLE:
42+
pytest.skip("fastapi/uvicorn/httpx not installed")
43+
port = _free_port()
44+
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
45+
46+
shim = tmp_path / "fake_brain_shim.py"
47+
shim.write_text(textwrap.dedent(f"""
48+
import sys
49+
sys.path.insert(0, {repo_root!r})
50+
from zhub.brains.base import BrainAdapter, ChatChunk
51+
import zhub.brains as _brains
52+
53+
class FakeBrain(BrainAdapter):
54+
name = "fake"
55+
label = "fake test brain"
56+
57+
@classmethod
58+
def try_init(cls):
59+
return cls()
60+
61+
async def stream(self, messages, *, system=None, temperature=0.7,
62+
max_tokens=2048, tools=None):
63+
yield ChatChunk(delta="ok", done=True, finish_reason="stop")
64+
65+
_brains.REGISTRY = [FakeBrain]
66+
67+
import runpy, sys as _sys
68+
_sys.argv = ["zhub", "up", "--port", "{port}",
69+
"--no-tunnel", "--name", "testpub",
70+
"--brain", "fake",
71+
"--db", {str(tmp_path / "up.db")!r}]
72+
runpy.run_module("zhub", run_name="__main__")
73+
"""))
74+
75+
env = dict(os.environ)
76+
env["PYTHONUNBUFFERED"] = "1"
77+
78+
proc = await asyncio.create_subprocess_exec(
79+
sys.executable, str(shim),
80+
stdout=asyncio.subprocess.PIPE,
81+
stderr=asyncio.subprocess.PIPE,
82+
env=env,
83+
)
84+
85+
stdout_buf: list[str] = []
86+
api_key = None
87+
deadline = time.time() + 12.0
88+
try:
89+
while time.time() < deadline:
90+
line = await asyncio.wait_for(proc.stdout.readline(), timeout=4.0)
91+
if not line:
92+
break
93+
text = line.decode().rstrip()
94+
stdout_buf.append(text)
95+
if text.startswith("KEY:"):
96+
api_key = text.removeprefix("KEY:").strip()
97+
if api_key and any(s.startswith("URL:") for s in stdout_buf):
98+
break
99+
100+
full = "\n".join(stdout_buf)
101+
assert any(s.startswith("URL:") for s in stdout_buf), \
102+
f"no URL line printed; stdout={full!r}"
103+
assert api_key and api_key.startswith("zk_"), \
104+
f"no key extracted; stdout={full!r}"
105+
106+
# And the hub really should be reachable on that port now
107+
async with httpx.AsyncClient(timeout=5.0) as client:
108+
r = await client.get(f"http://127.0.0.1:{port}/healthz")
109+
assert r.status_code == 200
110+
finally:
111+
proc.terminate()
112+
try:
113+
await asyncio.wait_for(proc.wait(), timeout=3.0)
114+
except asyncio.TimeoutError:
115+
proc.kill()
116+
await proc.wait()

zhub/__main__.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""`python -m zhub` — top-level CLI dispatcher.
2+
3+
Subcommands:
4+
up one-shot: hub + (optional) tunnel + brain publisher; prints URL/key
5+
server same as `python -m zhub.server` (for backwards compat)
6+
doctor inspect the current install + entity recipes for common issues
7+
8+
Default `python -m zhub` (no args) prints the usage line.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import sys
14+
15+
16+
_USAGE = """\
17+
usage: python -m zhub <command> [options]
18+
19+
commands:
20+
up start hub + tunnel + brain publisher in one go (recommended)
21+
server start just the hub server (legacy entry point)
22+
doctor diagnose the install using shipped entity recipes
23+
24+
run `python -m zhub <command> --help` for command-specific options.
25+
"""
26+
27+
28+
def main(argv: list[str] | None = None) -> None:
29+
args = list(argv if argv is not None else sys.argv[1:])
30+
if not args or args[0] in ("-h", "--help"):
31+
print(_USAGE)
32+
return
33+
cmd, rest = args[0], args[1:]
34+
if cmd == "server":
35+
# Defer to the existing server entry point with the rest of argv.
36+
sys.argv = ["zhub.server"] + rest
37+
from zhub.server import main as server_main
38+
server_main()
39+
return
40+
if cmd == "up":
41+
from zhub.cli_up import run as up_run
42+
up_run(rest)
43+
return
44+
if cmd == "doctor":
45+
from zhub.cli_doctor import run as doctor_run
46+
doctor_run(rest)
47+
return
48+
print(f"unknown command: {cmd!r}\n", file=sys.stderr)
49+
print(_USAGE, file=sys.stderr)
50+
sys.exit(2)
51+
52+
53+
if __name__ == "__main__":
54+
main()

zhub/brains/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
from .groq import GroqAdapter
2121
from .openai import OpenAIAdapter
2222
from .cerebras import CerebrasAdapter
23+
from .anthropic import AnthropicAdapter
2324

2425

2526
REGISTRY: list[type[BrainAdapter]] = [
2627
OllamaAdapter,
2728
GroqAdapter,
2829
OpenAIAdapter,
2930
CerebrasAdapter,
31+
AnthropicAdapter,
3032
]
3133

3234

@@ -55,5 +57,6 @@ def list_available() -> list[BrainAdapter]:
5557
__all__ = [
5658
"BrainAdapter", "ChatChunk", "REGISTRY",
5759
"detect", "list_available",
58-
"OllamaAdapter", "GroqAdapter", "OpenAIAdapter", "CerebrasAdapter",
60+
"OllamaAdapter", "GroqAdapter", "OpenAIAdapter",
61+
"CerebrasAdapter", "AnthropicAdapter",
5962
]

0 commit comments

Comments
 (0)