Skip to content

Commit ca22c32

Browse files
Merge pull request #126 from secondly-com/feat/broker-streaming-and-tests
Stream broker responses through and add pytest coverage
2 parents f020e5a + e9b61b0 commit ca22c32

5 files changed

Lines changed: 516 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,10 @@ jobs:
4545
- name: Run OpenPhone checks
4646
run: ./scripts/check.sh
4747

48+
- name: Run model broker unit tests
49+
run: |
50+
pip install pytest
51+
python3 -m pytest services/model-broker/tests -q
52+
4853
- name: Check whitespace
4954
run: git diff --check

scripts/smoke-test-model-broker.sh

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,31 @@ import http.server
6565
import json
6666
import pathlib
6767
import sys
68+
import time
6869
6970
state_path = pathlib.Path(sys.argv[1])
7071
7172
7273
class Handler(http.server.BaseHTTPRequestHandler):
7374
def do_POST(self):
7475
length = int(self.headers.get("Content-Length", "0"))
75-
self.rfile.read(length)
76+
body = self.rfile.read(length)
77+
try:
78+
payload = json.loads(body.decode("utf-8"))
79+
except (UnicodeDecodeError, json.JSONDecodeError):
80+
payload = {}
81+
if isinstance(payload, dict) and payload.get("stream") is True:
82+
self.send_response(200)
83+
self.send_header("Content-Type", "text/event-stream")
84+
self.send_header("Connection", "close")
85+
self.end_headers()
86+
for index in range(3):
87+
self.wfile.write(f"data: chunk-{index}\n\n".encode("utf-8"))
88+
self.wfile.flush()
89+
time.sleep(0.4)
90+
self.wfile.write(b"data: [DONE]\n\n")
91+
self.wfile.flush()
92+
return
7693
attempts = int(state_path.read_text(encoding="utf-8")) if state_path.exists() else 0
7794
attempts += 1
7895
state_path.write_text(str(attempts), encoding="utf-8")
@@ -318,6 +335,59 @@ expect_status 200 \
318335
-H 'Content-Type: application/json' \
319336
--data '{"model":"gpt-4.1-mini","metadata":{"openphone_task":"true","risk_flags":""},"input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}]}'
320337

338+
# Streaming: the broker must relay SSE chunks incrementally instead of
339+
# buffering the full upstream response before replying.
340+
python3 - <<'PY' "$port" "$static_token"
341+
import http.client
342+
import json
343+
import sys
344+
import time
345+
346+
port, token = sys.argv[1:]
347+
body = json.dumps({
348+
"model": "gpt-4.1-mini",
349+
"stream": True,
350+
"metadata": {"openphone_task": "true", "risk_flags": ""},
351+
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
352+
})
353+
connection = http.client.HTTPConnection("127.0.0.1", int(port), timeout=30)
354+
connection.request(
355+
"POST",
356+
"/v1/responses",
357+
body=body,
358+
headers={
359+
"Authorization": f"Bearer {token}",
360+
"Content-Type": "application/json",
361+
"Accept": "text/event-stream",
362+
},
363+
)
364+
response = connection.getresponse()
365+
if response.status != 200:
366+
raise SystemExit(f"streaming request failed: HTTP {response.status}")
367+
content_type = response.headers.get("Content-Type", "")
368+
if not content_type.startswith("text/event-stream"):
369+
raise SystemExit(f"streaming response has wrong content-type: {content_type}")
370+
371+
chunks = []
372+
arrival_times = []
373+
while True:
374+
chunk = response.read1(65536)
375+
if not chunk:
376+
break
377+
chunks.append(chunk)
378+
arrival_times.append(time.monotonic())
379+
payload = b"".join(chunks).decode("utf-8")
380+
for expected in ("data: chunk-0", "data: chunk-1", "data: chunk-2", "data: [DONE]"):
381+
if expected not in payload:
382+
raise SystemExit(f"streaming response missing {expected!r}: {payload!r}")
383+
if len(arrival_times) < 2:
384+
raise SystemExit("streaming response arrived as a single buffered chunk")
385+
spread = arrival_times[-1] - arrival_times[0]
386+
if spread < 0.2:
387+
raise SystemExit(f"streaming chunks were not delivered incrementally (spread={spread:.3f}s)")
388+
print(f"streaming smoke case passed ({len(chunks)} chunks over {spread:.2f}s)")
389+
PY
390+
321391
expect_status 429 \
322392
-X POST "http://127.0.0.1:$port/v1/responses" \
323393
-H "Authorization: Bearer $static_token" \
@@ -374,7 +444,7 @@ for event in events:
374444
if "body" in event or "request" in event or "response" in event:
375445
raise SystemExit(f"audit event contains body-like key: {event}")
376446
proxied = [event for event in events if event.get("outcome") == "proxied"]
377-
if not proxied or proxied[-1].get("provider_attempts") != 2:
447+
if not any(event.get("provider_attempts") == 2 for event in proxied):
378448
raise SystemExit("broker did not record retried provider forwarding")
379449
PY
380450

services/model-broker/openphone_model_broker.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ def do_POST(self) -> None: # noqa: N802
211211
body_size,
212212
started_at,
213213
model,
214+
stream=self._wants_stream(payload),
214215
)
215216
return
216217

@@ -326,6 +327,7 @@ def _proxy_to_openai(
326327
body_size: int,
327328
started_at: float,
328329
model: str | None,
330+
stream: bool = False,
329331
) -> None:
330332
headers = {
331333
"Authorization": f"Bearer {self.server.config.api_key}",
@@ -338,6 +340,17 @@ def _proxy_to_openai(
338340
attempts += 1
339341
try:
340342
with urllib.request.urlopen(request, timeout=120) as response:
343+
if stream:
344+
self._stream_provider_response(
345+
response,
346+
token_subject,
347+
body_size,
348+
started_at,
349+
endpoint,
350+
model,
351+
attempts,
352+
)
353+
return
341354
response_body = response.read()
342355
self._audit_request(
343356
"proxied",
@@ -404,6 +417,53 @@ def _proxy_to_openai(
404417
)
405418
return
406419

420+
def _wants_stream(self, payload: dict[str, object]) -> bool:
421+
accept = self.headers.get("Accept", "")
422+
if "text/event-stream" in accept.lower():
423+
return True
424+
return payload.get("stream") is True
425+
426+
def _stream_provider_response(
427+
self,
428+
response: object,
429+
token_subject: str,
430+
body_size: int,
431+
started_at: float,
432+
endpoint: str,
433+
model: str | None,
434+
attempts: int,
435+
) -> None:
436+
"""Relay the upstream body chunk-by-chunk instead of buffering it.
437+
438+
The response is delimited by connection close (HTTP/1.0 semantics),
439+
which every SSE-capable client handles; no Content-Length is sent.
440+
"""
441+
self._audit_request(
442+
"proxied",
443+
token_subject,
444+
response.status,
445+
body_size,
446+
started_at,
447+
endpoint,
448+
model,
449+
provider_attempts=attempts,
450+
)
451+
self.send_response(response.status)
452+
self.send_header(
453+
"Content-Type",
454+
response.headers.get("Content-Type", "text/event-stream"),
455+
)
456+
self.send_header("Cache-Control", "no-store")
457+
self.send_header("Connection", "close")
458+
self.end_headers()
459+
self.close_connection = True
460+
while True:
461+
chunk = response.read1(65536)
462+
if not chunk:
463+
break
464+
self.wfile.write(chunk)
465+
self.wfile.flush()
466+
407467
def _should_retry_provider(self, status: int, attempts: int) -> bool:
408468
if attempts > self.server.config.provider_max_retries:
409469
return False
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Make the stdlib-only broker module importable from the tests directory."""
2+
3+
import pathlib
4+
import sys
5+
6+
BROKER_DIR = pathlib.Path(__file__).resolve().parent.parent
7+
if str(BROKER_DIR) not in sys.path:
8+
sys.path.insert(0, str(BROKER_DIR))

0 commit comments

Comments
 (0)