Skip to content

Commit 3e4f1c5

Browse files
Zawwarsami16claude
andcommitted
phase 0.4: SSE streaming end-to-end (chat-chunk wire path + OpenAI-format /v1)
publisher's chat_handler can now yield chunks (sync iterator or async iterator). hub forwards as SSE in OpenAI streaming format. external clients use openai library's stream=True natively. protocol additions: - protocol.py: chat_chunk(delta, request_id, done, finish_reason) — new envelope type. publisher emits one or more, terminates with done=True. server side: - server.py /v1/chat/completions: when body.stream==true, returns text/event-stream StreamingResponse. Hub holds a queue per request_id; publisher's chat-chunk envelopes flow into the queue, the SSE generator consumes and emits OpenAI-format `data: {...}\n\n` lines plus the final `data: [DONE]\n\n` sentinel. - server.py proxy_chat: gains stream=True path that hands back a queue + request_id instead of a future. - server.py ws_publish: chat-response from a non-streaming publisher paired with a streaming queue caller is auto-wrapped into a single chunk + done — keeps backwards compatibility for handlers that don't yet support streaming. client side: - client.py _handle_chat: detects async iterators (yields chunks via chat-chunk), sync iterators when stream was requested, and falls back to the existing single-shot string/dict shape. operator's chat handler can be a yield-generator now. example handler shape: def my_handler(messages, options): for word in "the quick brown fox".split(): yield word + " " invoking from outside: import openai client = openai.OpenAI(base_url=..., api_key=...) for chunk in client.chat.completions.create( model="my-ai", messages=[...], stream=True, ): print(chunk.choices[0].delta.content, end="", flush=True) works through hub natively. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 54d44c2 commit 3e4f1c5

3 files changed

Lines changed: 129 additions & 9 deletions

File tree

zhub/client.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
from .manifest import Capability, Manifest, chat_only_manifest
2525
from .protocol import (
26-
Envelope, register_publisher, register_connection, chat_request,
26+
Envelope, register_publisher, register_connection, chat_request, chat_chunk,
2727
invoke_request, invoke_result,
2828
)
2929
from .errors import AuthError, ConnectionError as ZhubConnectionError
@@ -184,10 +184,27 @@ async def runner() -> None:
184184
async def _handle_chat(pub: ZhubPublication, ws, env: Envelope) -> None:
185185
messages = env.payload.get("messages", [])
186186
options = {k: v for k, v in env.payload.items() if k != "messages"}
187+
streaming_requested = bool(options.get("stream"))
187188
try:
188189
result = pub.chat_handler(messages, options)
190+
191+
# Coroutines
189192
if asyncio.iscoroutine(result):
190193
result = await result
194+
195+
# Sync iterators / async iterators — streaming
196+
if hasattr(result, "__aiter__"):
197+
async for chunk in result:
198+
await ws.send(chat_chunk(str(chunk), env.request_id).to_json())
199+
await ws.send(chat_chunk("", env.request_id, done=True, finish_reason="stop").to_json())
200+
return
201+
if streaming_requested and hasattr(result, "__iter__") and not isinstance(result, (str, dict, bytes)):
202+
for chunk in result:
203+
await ws.send(chat_chunk(str(chunk), env.request_id).to_json())
204+
await ws.send(chat_chunk("", env.request_id, done=True, finish_reason="stop").to_json())
205+
return
206+
207+
# Single-shot
191208
if isinstance(result, str):
192209
payload = {"text": result, "finish_reason": "stop"}
193210
elif isinstance(result, dict):

zhub/protocol.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,25 @@ def chat_response(text: str, request_id: str, finish_reason: str = "stop",
116116
)
117117

118118

119+
def chat_chunk(delta: str, request_id: str, done: bool = False,
120+
finish_reason: Optional[str] = None) -> Envelope:
121+
"""Streaming chunk — incremental delta of a chat response.
122+
123+
The publisher sends one or more chat-chunk envelopes followed by a final
124+
chunk with done=True. The hub forwards them to the HTTP client as SSE
125+
events in OpenAI streaming format.
126+
"""
127+
return Envelope(
128+
type="chat-chunk",
129+
request_id=request_id,
130+
payload={
131+
"delta": delta,
132+
"done": done,
133+
"finish_reason": finish_reason,
134+
},
135+
)
136+
137+
119138
def invoke_request(connection_id: str, capability: str,
120139
args: dict[str, Any]) -> Envelope:
121140
return Envelope(

zhub/server.py

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import argparse
2121
import asyncio
22-
import json
22+
import json # noqa: F401 -- used in inline SSE serialization
2323
import logging
2424
import secrets
2525
import time
@@ -29,7 +29,7 @@
2929

3030
try:
3131
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
32-
from fastapi.responses import JSONResponse
32+
from fastapi.responses import JSONResponse, StreamingResponse
3333
import uvicorn
3434
except ImportError as e:
3535
raise SystemExit(
@@ -152,13 +152,21 @@ async def unregister_connection(self, ai_name: str, connection_id: str) -> None:
152152

153153
async def proxy_chat(self, ai_name: str, messages: list[dict[str, Any]],
154154
model: str, temperature: float, max_tokens: int,
155-
timeout: float = 60.0) -> dict[str, Any]:
156-
"""Route an HTTP chat request to the publisher and await its response."""
155+
timeout: float = 60.0,
156+
stream: bool = False) -> dict[str, Any]:
157+
"""Route an HTTP chat request to the publisher and await its response.
158+
If stream=True the future delivers a queue of streaming chunks instead."""
157159
publisher = self.publishers.get(ai_name)
158160
if publisher is None:
159161
raise LookupError("publisher not registered")
160162
env = chat_request(messages=messages, model=model,
161-
temperature=temperature, max_tokens=max_tokens)
163+
temperature=temperature, max_tokens=max_tokens,
164+
extras={"stream": True} if stream else None)
165+
if stream:
166+
queue: asyncio.Queue = asyncio.Queue()
167+
publisher.pending[env.request_id] = queue # type: ignore[assignment]
168+
await publisher.websocket.send_text(env.to_json())
169+
return {"_stream_queue": queue, "_request_id": env.request_id}
162170
future: asyncio.Future = asyncio.get_running_loop().create_future()
163171
publisher.pending[env.request_id] = future
164172
try:
@@ -243,7 +251,7 @@ async def manifest(ai_name: str) -> JSONResponse:
243251
return JSONResponse(m)
244252

245253
@app.post("/{ai_name}/v1/chat/completions")
246-
async def chat_completions(ai_name: str, request: Request) -> JSONResponse:
254+
async def chat_completions(ai_name: str, request: Request):
247255
body = await request.json()
248256
api_key_header = request.headers.get("authorization", "").removeprefix("Bearer ").strip()
249257
if hub.lookup_by_api_key(api_key_header) != ai_name:
@@ -253,14 +261,72 @@ async def chat_completions(ai_name: str, request: Request) -> JSONResponse:
253261
model = body.get("model", "default")
254262
temperature = float(body.get("temperature", 0.4))
255263
max_tokens = int(body.get("max_tokens", 4096))
264+
stream = bool(body.get("stream", False))
265+
266+
if stream:
267+
try:
268+
response = await hub.proxy_chat(
269+
ai_name, messages, model, temperature, max_tokens, stream=True,
270+
)
271+
except LookupError:
272+
raise HTTPException(404, "AI offline")
273+
274+
queue: asyncio.Queue = response["_stream_queue"]
275+
request_id = response["_request_id"]
276+
277+
async def event_stream():
278+
created = int(time.time())
279+
completion_id = "chatcmpl-" + new_request_id()[:16]
280+
while True:
281+
chunk = await queue.get()
282+
if chunk is None:
283+
break
284+
delta_text = chunk.get("delta", "")
285+
done = chunk.get("done", False)
286+
finish_reason = chunk.get("finish_reason")
287+
if done:
288+
# final chunk per OpenAI streaming spec
289+
sse = {
290+
"id": completion_id,
291+
"object": "chat.completion.chunk",
292+
"created": created,
293+
"model": model,
294+
"choices": [{
295+
"index": 0,
296+
"delta": {},
297+
"finish_reason": finish_reason or "stop",
298+
}],
299+
}
300+
yield f"data: {json.dumps(sse)}\n\n"
301+
yield "data: [DONE]\n\n"
302+
break
303+
sse = {
304+
"id": completion_id,
305+
"object": "chat.completion.chunk",
306+
"created": created,
307+
"model": model,
308+
"choices": [{
309+
"index": 0,
310+
"delta": {"role": "assistant", "content": delta_text},
311+
"finish_reason": None,
312+
}],
313+
}
314+
yield f"data: {json.dumps(sse)}\n\n"
315+
316+
return StreamingResponse(
317+
event_stream(),
318+
media_type="text/event-stream",
319+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
320+
)
321+
256322
try:
257323
response = await hub.proxy_chat(ai_name, messages, model, temperature, max_tokens)
258324
except LookupError:
259325
raise HTTPException(404, "AI offline")
260326
except asyncio.TimeoutError:
261327
raise HTTPException(504, "AI did not respond in time")
262328

263-
# Wrap into OpenAI-style response shape
329+
# Wrap into OpenAI-style response shape (non-streaming)
264330
text = response.get("text", "")
265331
usage = response.get("usage", {})
266332
return JSONResponse({
@@ -298,7 +364,25 @@ async def ws_publish(websocket: WebSocket) -> None:
298364
elif env.type == "chat-response" and ai_name:
299365
publisher = hub.publishers.get(ai_name)
300366
if publisher and env.request_id in publisher.pending:
301-
publisher.pending[env.request_id].set_result(env.payload)
367+
target = publisher.pending[env.request_id]
368+
if isinstance(target, asyncio.Queue):
369+
# streaming caller — convert non-streaming response to single chunk
370+
await target.put({"delta": env.payload.get("text", ""),
371+
"done": False})
372+
await target.put({"done": True,
373+
"finish_reason": env.payload.get("finish_reason", "stop")})
374+
await target.put(None)
375+
else:
376+
target.set_result(env.payload)
377+
378+
elif env.type == "chat-chunk" and ai_name:
379+
publisher = hub.publishers.get(ai_name)
380+
if publisher and env.request_id in publisher.pending:
381+
target = publisher.pending[env.request_id]
382+
if isinstance(target, asyncio.Queue):
383+
await target.put(env.payload)
384+
if env.payload.get("done"):
385+
await target.put(None)
302386

303387
elif env.type == "invoke-request" and ai_name:
304388
# Publisher wants to call a connected client.

0 commit comments

Comments
 (0)