Skip to content

Commit b237cc3

Browse files
committed
feat(v7-f06): live token streaming via /ws/gen/{job_id}
Honest-closure: stream_generate already had on_token callback support in cppmega_v4/runtime/generate_stream.py, but no WS endpoint forwarded the per-token events to the UI. The audit listed it as 'F06 streaming generation tokens в UI: нет WS'. - runtime/gen_event_bus.py: mirror of train_event_bus keyed by job_id. Thread-safe publish/subscribe with None sentinel for completion. - jsonrpc/gen_run_method.py: GenRunParams.job_id; when set, gen.run routes via stream_generate(on_token=...) so each token publishes onto the bus. None sentinel fires on return. - jsonrpc/server.py: /ws/gen/{job_id} endpoint subscribes to the bus and forwards each event as {job_id, event} frames + final {finish:'ok'}. Same 200ms-poll pattern as /ws/train/{run_id}. - tests/v4/test_gen_event_bus.py: no-publish without job_id; full per-token + sentinel delivery; cross-job isolation. F07 latency bench already swapped train(1) proxy for gen_run in 8f7ec3a; the streaming endpoint completes the F-block trio (F01 → F02 → F06 + F03/F04 already wired in).
1 parent 24acc59 commit b237cc3

4 files changed

Lines changed: 179 additions & 2 deletions

File tree

cppmega_v4/jsonrpc/gen_run_method.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
from pydantic import BaseModel, ConfigDict, Field
2323

2424
from cppmega_v4.jsonrpc.cache import LRUCache
25-
from cppmega_v4.runtime.generate_stream import collect_stream
25+
from cppmega_v4.runtime import gen_event_bus
26+
from cppmega_v4.runtime.generate_stream import stream_generate
2627
from cppmega_v4.runtime.samplers import (
2728
greedy, temperature_sample, top_k_sample, top_p_sample,
2829
)
@@ -53,6 +54,9 @@ class GenRunParams(BaseModel):
5354
# GenRunResult.kv_cache. 0 disables KV-cache reporting.
5455
kv_cache_layers: int = Field(0, ge=0, le=128)
5556
kv_cache_head_dim: int = Field(16, ge=1, le=4096)
57+
# V7-F06: optional job_id; when set, gen_run publishes each token
58+
# onto gen_event_bus so /ws/gen/{job_id} subscribers see streaming.
59+
job_id: str | None = None
5660

5761

5862
class GenRunEvent(BaseModel):
@@ -141,12 +145,25 @@ def _step(last: int) -> int:
141145
kv_cache_state["growth_events"] += 1
142146
return tok
143147

144-
tokens, reason, raw_events = collect_stream(
148+
# V7-F06: when a job_id was supplied, route through stream_generate
149+
# with an on_token callback that publishes onto the bus, then mirror
150+
# the same collected events for the synchronous return path.
151+
raw_events: list[dict] = []
152+
153+
def _on_token(ev: dict) -> None:
154+
raw_events.append(ev)
155+
if params.job_id:
156+
gen_event_bus.publish(params.job_id, ev)
157+
158+
tokens, reason = stream_generate(
145159
initial_tokens=list(params.prompt_tokens),
146160
step_fn=_step,
147161
eos_token_id=params.eos_token_id,
148162
max_new_tokens=params.max_new_tokens,
163+
on_token=_on_token,
149164
)
165+
if params.job_id:
166+
gen_event_bus.publish(params.job_id, None)
150167
if kv_obj is not None:
151168
kv_cache_state["total_bytes"] = int(kv_obj.total_bytes())
152169
kv_cache_state["lengths_per_layer"] = [

cppmega_v4/jsonrpc/server.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,41 @@ async def rpc(payload: dict) -> dict:
7979
response = await _dispatch(payload, cache)
8080
return response.model_dump(mode="json", exclude_none=True)
8181

82+
@app.websocket("/ws/gen/{job_id}")
83+
async def ws_gen(socket: WebSocket, job_id: str) -> None:
84+
"""V7-F06: live token stream from gen.run.
85+
86+
Client opens this WS *before* calling gen.run with the same
87+
job_id, then receives {job_id, event:{step, token_id,
88+
finish_reason}} frames until {finish:'ok'} fires."""
89+
from cppmega_v4.runtime import gen_event_bus
90+
import queue as _queue
91+
await socket.accept()
92+
q = gen_event_bus.subscribe(job_id)
93+
94+
def _try_get(timeout: float = 0.2):
95+
try:
96+
return q.get(timeout=timeout)
97+
except _queue.Empty:
98+
return _SENTINEL_EMPTY
99+
100+
try:
101+
while True:
102+
ev = await asyncio.to_thread(_try_get, 0.2)
103+
if ev is _SENTINEL_EMPTY:
104+
await asyncio.sleep(0)
105+
continue
106+
if ev is None:
107+
await socket.send_json({"finish": "ok",
108+
"job_id": job_id})
109+
return
110+
await socket.send_json({"job_id": job_id,
111+
"event": ev})
112+
except WebSocketDisconnect:
113+
pass
114+
finally:
115+
gen_event_bus.unsubscribe(job_id, q)
116+
82117
@app.websocket("/ws/train/{run_id}")
83118
async def ws_train(socket: WebSocket, run_id: str) -> None:
84119
"""V7-H05: live per-step training metric stream.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""V7-F06: cross-thread pub/sub for live token-by-token generation events.
2+
3+
Mirrors train_event_bus but keyed by a generation job_id supplied
4+
to gen.run via params.run_id. stream_generate's on_token callback
5+
publishes each {step, token_id, finish_reason} frame onto the bus
6+
and the WS endpoint /ws/gen/{job_id} forwards them to the UI.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import queue
12+
import threading
13+
from typing import Any
14+
15+
16+
_LOCK = threading.Lock()
17+
_QUEUES: dict[str, list[queue.Queue]] = {}
18+
19+
20+
def publish(job_id: str | None, event: dict[str, Any] | None) -> None:
21+
if not job_id:
22+
return
23+
with _LOCK:
24+
subs = list(_QUEUES.get(job_id, []))
25+
for q in subs:
26+
try:
27+
q.put_nowait(event)
28+
except Exception:
29+
pass
30+
31+
32+
def subscribe(job_id: str) -> queue.Queue:
33+
q: queue.Queue = queue.Queue(maxsize=4096)
34+
with _LOCK:
35+
_QUEUES.setdefault(job_id, []).append(q)
36+
return q
37+
38+
39+
def unsubscribe(job_id: str, q: queue.Queue) -> None:
40+
with _LOCK:
41+
subs = _QUEUES.get(job_id, [])
42+
if q in subs:
43+
subs.remove(q)
44+
if not subs:
45+
_QUEUES.pop(job_id, None)
46+
47+
48+
def reset() -> None:
49+
with _LOCK:
50+
_QUEUES.clear()
51+
52+
53+
def subscriber_count(job_id: str) -> int:
54+
with _LOCK:
55+
return len(_QUEUES.get(job_id, []))
56+
57+
58+
__all__ = ["publish", "subscribe", "unsubscribe", "reset",
59+
"subscriber_count"]

tests/v4/test_gen_event_bus.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""V7-F06: gen_event_bus pub/sub + gen.run job_id wiring."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from fastapi.testclient import TestClient
7+
8+
from cppmega_v4.jsonrpc import create_app
9+
from cppmega_v4.jsonrpc.gen_run_method import GenRunParams, gen_run
10+
from cppmega_v4.runtime import gen_event_bus as bus
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def _clean():
15+
bus.reset()
16+
yield
17+
bus.reset()
18+
19+
20+
@pytest.fixture
21+
def client():
22+
return TestClient(create_app(cache_capacity=2))
23+
24+
25+
def test_gen_run_without_job_id_does_not_publish():
26+
res = gen_run(GenRunParams(prompt_tokens=[0], eos_token_id=-1,
27+
max_new_tokens=3))
28+
# No subscribers, no job_id — bus stays empty.
29+
assert bus.subscriber_count("anything") == 0
30+
assert len(res.events) == 3
31+
32+
33+
def test_gen_run_with_job_id_publishes_per_token_and_finish():
34+
q = bus.subscribe("job-7")
35+
res = gen_run(GenRunParams(prompt_tokens=[0], eos_token_id=-1,
36+
max_new_tokens=4,
37+
job_id="job-7"))
38+
received: list = []
39+
while True:
40+
ev = q.get(timeout=1.0)
41+
if ev is None:
42+
break
43+
received.append(ev)
44+
assert len(received) == 4
45+
for ev in received:
46+
assert "token_id" in ev
47+
assert "step" in ev
48+
assert res.finish_reason == "length"
49+
50+
51+
def test_gen_run_unsubscribe_isolates_jobs():
52+
q1 = bus.subscribe("job-A")
53+
q2 = bus.subscribe("job-B")
54+
gen_run(GenRunParams(prompt_tokens=[0], eos_token_id=-1,
55+
max_new_tokens=2, job_id="job-A"))
56+
# job-A queue receives events + sentinel; job-B stays untouched.
57+
a_count = 0
58+
while not q1.empty():
59+
ev = q1.get_nowait()
60+
if ev is None:
61+
break
62+
a_count += 1
63+
assert a_count == 2
64+
assert q2.empty()
65+
bus.unsubscribe("job-A", q1)
66+
bus.unsubscribe("job-B", q2)

0 commit comments

Comments
 (0)