Skip to content

Commit 80436b4

Browse files
committed
feat(server): add buffered streaming responses
Accept text-only OpenAI content blocks and return Chat or Completions results through SSE framing when stream=true. Preserve family-owned generation behavior by buffering the native result until the current Task contract completes. Document direct Pi configuration and keep tool calling and incremental token delivery outside this MVP. Signed-off-by: Vivian Chen <140748220+xuanzic@users.noreply.github.com>
1 parent d9b4fd9 commit 80436b4

5 files changed

Lines changed: 254 additions & 16 deletions

File tree

apps/server/python/tests/test_app.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,13 @@ def test_completion_and_chat_keep_model_semantics_in_worker(caplog: Any) -> None
128128
"model": "test/model",
129129
"messages": [
130130
{"role": "system", "content": "Be brief"},
131-
{"role": "user", "content": "Capital?"},
131+
{
132+
"role": "user",
133+
"content": [
134+
{"type": "text", "text": "Cap"},
135+
{"type": "text", "text": "ital?"},
136+
],
137+
},
132138
],
133139
"max_completion_tokens": 8,
134140
},
@@ -138,6 +144,7 @@ def test_completion_and_chat_keep_model_semantics_in_worker(caplog: Any) -> None
138144
assert config["use_chat_template"] is True
139145
assert config["system_prompt"] == "Be brief"
140146
assert config["max_new_tokens"] == 8
147+
assert registry.requests[-1][1]["prompt"] == "Capital?"
141148

142149
request_logs = [
143150
json.loads(record.message)
@@ -153,6 +160,86 @@ def test_completion_and_chat_keep_model_semantics_in_worker(caplog: Any) -> None
153160
assert "Capital?" not in json.dumps(request_logs)
154161

155162

163+
def test_streaming_chat_uses_openai_sse_shape() -> None:
164+
registry = FakeRegistry()
165+
with make_client(registry) as client:
166+
response = client.post(
167+
"/v1/chat/completions",
168+
json={
169+
"model": "test/model",
170+
"messages": [
171+
{
172+
"role": "user",
173+
"content": [{"type": "text", "text": "Capital?"}],
174+
}
175+
],
176+
"stream": True,
177+
"stream_options": {"include_usage": True},
178+
},
179+
)
180+
181+
assert response.status_code == 200
182+
assert response.headers["content-type"].startswith("text/event-stream")
183+
events = [event for event in response.text.split("\n\n") if event]
184+
assert events[-1] == "data: [DONE]"
185+
chunks = [json.loads(event.removeprefix("data: ")) for event in events[:-1]]
186+
assert chunks[0]["object"] == "chat.completion.chunk"
187+
assert chunks[0]["choices"][0]["delta"] == {
188+
"role": "assistant",
189+
"content": "Paris",
190+
}
191+
assert chunks[1]["choices"][0]["delta"] == {}
192+
assert chunks[1]["choices"][0]["finish_reason"] is None
193+
assert chunks[2]["choices"] == []
194+
assert chunks[2]["usage"]["completion_tokens"] == 1
195+
assert registry.requests[-1][1]["prompt"] == "Capital?"
196+
197+
198+
def test_streaming_completion_and_stream_options_validation() -> None:
199+
registry = FakeRegistry()
200+
with make_client(registry) as client:
201+
streamed = client.post(
202+
"/v1/completions",
203+
json={"model": "test/model", "prompt": "Capital?", "stream": True},
204+
)
205+
invalid = client.post(
206+
"/v1/completions",
207+
json={
208+
"model": "test/model",
209+
"prompt": "Capital?",
210+
"stream_options": {"include_usage": True},
211+
},
212+
)
213+
214+
assert streamed.status_code == 200
215+
assert '"object":"text_completion"' in streamed.text
216+
assert '"text":"Paris"' in streamed.text
217+
assert streamed.text.endswith("data: [DONE]\n\n")
218+
assert invalid.status_code == 400
219+
assert invalid.json()["error"]["param"] == "stream_options"
220+
221+
222+
def test_chat_rejects_non_text_content_blocks() -> None:
223+
registry = FakeRegistry()
224+
with make_client(registry) as client:
225+
response = client.post(
226+
"/v1/chat/completions",
227+
json={
228+
"model": "test/model",
229+
"messages": [
230+
{
231+
"role": "user",
232+
"content": [{"type": "image_url", "image_url": {"url": "file:///tmp/x"}}],
233+
}
234+
],
235+
},
236+
)
237+
238+
assert response.status_code == 400
239+
assert response.json()["error"]["param"] in {"str", "type", "text"}
240+
assert registry.requests == []
241+
242+
156243
def test_validation_overload_and_metrics_are_explicit() -> None:
157244
registry = FakeRegistry()
158245
with make_client(registry) as client:

apps/server/python/trtmc_server/app.py

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from fastapi import FastAPI, Request
1818
from fastapi.exceptions import RequestValidationError
19-
from fastapi.responses import JSONResponse, PlainTextResponse
19+
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
2020
from starlette.types import ASGIApp, Message, Receive, Scope, Send
2121

2222
from .errors import (
@@ -128,6 +128,84 @@ def error_response(
128128
)
129129

130130

131+
def _sse_data(payload: dict[str, Any] | str) -> bytes:
132+
data = payload if isinstance(payload, str) else json.dumps(payload, separators=(",", ":"))
133+
return f"data: {data}\n\n".encode("utf-8")
134+
135+
136+
def _streaming_completion_response(
137+
*,
138+
response_id: str,
139+
created: int,
140+
model: str,
141+
text: str,
142+
completion_tokens: int,
143+
chat: bool,
144+
include_usage: bool,
145+
request_id: str,
146+
) -> StreamingResponse:
147+
if chat:
148+
object_name = "chat.completion.chunk"
149+
content_choice: dict[str, Any] = {
150+
"index": 0,
151+
"delta": {"role": "assistant", "content": text},
152+
"logprobs": None,
153+
"finish_reason": None,
154+
}
155+
terminal_choice: dict[str, Any] = {
156+
"index": 0,
157+
"delta": {},
158+
"logprobs": None,
159+
"finish_reason": None,
160+
}
161+
else:
162+
object_name = "text_completion"
163+
content_choice = {
164+
"index": 0,
165+
"text": text,
166+
"logprobs": None,
167+
"finish_reason": None,
168+
}
169+
terminal_choice = {
170+
"index": 0,
171+
"text": "",
172+
"logprobs": None,
173+
"finish_reason": None,
174+
}
175+
176+
def chunk(choices: list[dict[str, Any]]) -> dict[str, Any]:
177+
return {
178+
"id": response_id,
179+
"object": object_name,
180+
"created": created,
181+
"model": model,
182+
"choices": choices,
183+
}
184+
185+
async def events() -> AsyncIterator[bytes]:
186+
yield _sse_data(chunk([content_choice]))
187+
yield _sse_data(chunk([terminal_choice]))
188+
if include_usage:
189+
usage_chunk = chunk([])
190+
usage_chunk["usage"] = {
191+
"prompt_tokens": 0,
192+
"completion_tokens": completion_tokens,
193+
"total_tokens": completion_tokens,
194+
}
195+
yield _sse_data(usage_chunk)
196+
yield _sse_data("[DONE]")
197+
198+
return StreamingResponse(
199+
events(),
200+
media_type="text/event-stream",
201+
headers={
202+
"Cache-Control": "no-cache",
203+
"X-Accel-Buffering": "no",
204+
"X-Request-ID": request_id,
205+
},
206+
)
207+
208+
131209
def create_app(registry: ModelRegistry, config: ServerConfig) -> FastAPI:
132210
metrics = Metrics()
133211

@@ -238,10 +316,13 @@ async def execute(
238316
if request.n != 1:
239317
metrics.reject(route, 400)
240318
return error_response(400, "unsupported_parameter", "n must be 1", param="n")
241-
if request.stream:
319+
if request.stream_options is not None and not request.stream:
242320
metrics.reject(route, 400)
243321
return error_response(
244-
400, "streaming_not_supported", "streaming is not available", param="stream"
322+
400,
323+
"invalid_request",
324+
"stream_options requires stream=true",
325+
param="stream_options",
245326
)
246327
if request.stop is not None:
247328
metrics.reject(route, 400)
@@ -386,11 +467,25 @@ async def execute(
386467
}
387468
object_name = "text_completion"
388469
response_id = f"cmpl-{uuid.uuid4().hex}"
470+
created = int(time.time())
471+
if request.stream:
472+
return _streaming_completion_response(
473+
response_id=response_id,
474+
created=created,
475+
model=request.model,
476+
text=text,
477+
completion_tokens=completion_tokens,
478+
chat=chat,
479+
include_usage=(
480+
request.stream_options is not None and request.stream_options.include_usage
481+
),
482+
request_id=request_id,
483+
)
389484
return JSONResponse(
390485
content={
391486
"id": response_id,
392487
"object": object_name,
393-
"created": int(time.time()),
488+
"created": created,
394489
"model": request.model,
395490
"choices": [choice],
396491
"usage": {

apps/server/python/trtmc_server/protocol.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Any
1010

1111
from .errors import WorkerProtocolError, WorkerRemoteError, WorkerRequestTooLargeError
12-
from .schemas import ChatCompletionRequest, GenerationRequest
12+
from .schemas import ChatCompletionRequest, GenerationRequest, TextContentPart
1313

1414

1515
def generation_config(request: GenerationRequest, max_tokens: int) -> dict[str, Any]:
@@ -24,16 +24,22 @@ def generation_config(request: GenerationRequest, max_tokens: int) -> dict[str,
2424
def chat_prompt(request: ChatCompletionRequest) -> tuple[str, str]:
2525
messages = request.messages
2626
if len(messages) == 1 and messages[0].role == "user":
27-
return messages[0].content, ""
27+
return _message_text(messages[0].content), ""
2828
if (
2929
len(messages) == 2
3030
and messages[0].role == "system"
3131
and messages[1].role == "user"
3232
):
33-
return messages[1].content, messages[0].content
33+
return _message_text(messages[1].content), _message_text(messages[0].content)
3434
raise ValueError("messages must be one user message with an optional preceding system message")
3535

3636

37+
def _message_text(content: str | list[TextContentPart]) -> str:
38+
if isinstance(content, str):
39+
return content
40+
return "".join(part.text for part in content)
41+
42+
3743
def extract_result(result: Any) -> tuple[str, int, dict[str, float]]:
3844
if not isinstance(result, Mapping) or not isinstance(result.get("text"), str):
3945
raise WorkerProtocolError("worker generate result does not contain text")

apps/server/python/trtmc_server/schemas.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from __future__ import annotations
77

8-
from typing import Any
8+
from typing import Any, Literal
99

1010
from pydantic import BaseModel, ConfigDict, Field
1111

@@ -14,9 +14,18 @@ class StrictRequest(BaseModel):
1414
model_config = ConfigDict(extra="forbid")
1515

1616

17+
class TextContentPart(StrictRequest):
18+
type: Literal["text"]
19+
text: str
20+
21+
1722
class ChatMessage(StrictRequest):
1823
role: str
19-
content: str
24+
content: str | list[TextContentPart]
25+
26+
27+
class StreamOptions(StrictRequest):
28+
include_usage: bool = False
2029

2130

2231
class GenerationRequest(StrictRequest):
@@ -30,6 +39,7 @@ class GenerationRequest(StrictRequest):
3039
enable_thinking: bool | None = None
3140
n: int = Field(default=1, ge=1)
3241
stream: bool = False
42+
stream_options: StreamOptions | None = None
3343
stop: Any | None = None
3444

3545

website/docs/user-guides/serve-text-generation.md

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: Serve Text Generation
33
description: Expose text-generation bundles through the local hybrid server.
44
---
55

6-
`trtmc-server` exposes a deliberately small, non-streaming subset of the
6+
`trtmc-server` exposes a deliberately small subset of the
77
OpenAI Completions and Chat Completions protocols. It is a local evaluation
88
server, not a production or distributed serving system.
99

@@ -96,18 +96,58 @@ support that placement.
9696
| Route | Supported behavior |
9797
| --- | --- |
9898
| `POST /v1/completions` | One string `prompt`; `model` is required. |
99-
| `POST /v1/chat/completions` | One text `user` message and optional preceding `system` message. |
99+
| `POST /v1/chat/completions` | One text `user` message and optional preceding `system` message; content may be a string or text-only content blocks. |
100100
| `GET /v1/models` | Configured text models. |
101101
| `GET /health/live` | Control-plane process liveness. |
102102
| `GET /health/ready` | Worker readiness and admission state. |
103103
| `GET /metrics` | Prometheus text metrics for admission and inference. |
104104

105105
Generation accepts `temperature`, `top_p`, `min_p`, `top_k`, `seed`,
106-
`enable_thinking`, `n`, and `stream`. Completions accept `max_tokens`;
106+
`enable_thinking`, `n`, `stream`, and `stream_options.include_usage`.
107+
Completions accept `max_tokens`;
107108
chat accepts either `max_tokens` or `max_completion_tokens`. The MVP
108-
requires `n=1` and `stream=false`. It rejects unknown fields, prompt arrays,
109-
multi-turn chat, structured content, stop sequences, tools, log probabilities,
110-
and streaming instead of silently ignoring them.
109+
requires `n=1`. It rejects unknown fields, prompt arrays, non-text content
110+
blocks, multi-turn chat, stop sequences, tools, and log probabilities instead
111+
of silently ignoring them.
112+
113+
Streaming responses use the OpenAI-compatible server-sent event framing and
114+
terminate with `data: [DONE]`. The native text task currently returns a complete
115+
generation, so the MVP buffers inference before emitting the content chunk;
116+
streaming provides client compatibility but does not yet reduce time to first
117+
token. Incremental token delivery requires a future extension to the family and
118+
runtime text-generation contract.
119+
120+
For example, a Pi custom provider can use the local endpoint without an
121+
authentication header:
122+
123+
```json
124+
{
125+
"providers": {
126+
"trtmc": {
127+
"baseUrl": "http://127.0.0.1:8000/v1",
128+
"api": "openai-completions",
129+
"apiKey": "EMPTY",
130+
"authHeader": false,
131+
"compat": {
132+
"supportsStore": false,
133+
"supportsDeveloperRole": false,
134+
"supportsReasoningEffort": false,
135+
"supportsFinishReason": false,
136+
"maxTokensField": "max_tokens"
137+
},
138+
"models": [{"id": "Qwen/Qwen3-0.6B"}]
139+
}
140+
}
141+
}
142+
```
143+
144+
Pi sends text-only content blocks and consumes the buffered SSE response. Run
145+
Pi with `--no-tools`; tool definitions and tool-result messages remain outside
146+
the MVP protocol.
147+
148+
```bash
149+
pi --provider trtmc --model Qwen/Qwen3-0.6B --no-tools
150+
```
111151

112152
Model-specific chat templates, tokenization, sampling, stopping, engine
113153
composition, and validation remain inside the family selected by the bundle.

0 commit comments

Comments
 (0)