|
16 | 16 |
|
17 | 17 | from fastapi import FastAPI, Request |
18 | 18 | from fastapi.exceptions import RequestValidationError |
19 | | -from fastapi.responses import JSONResponse, PlainTextResponse |
| 19 | +from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse |
20 | 20 | from starlette.types import ASGIApp, Message, Receive, Scope, Send |
21 | 21 |
|
22 | 22 | from .errors import ( |
@@ -128,6 +128,84 @@ def error_response( |
128 | 128 | ) |
129 | 129 |
|
130 | 130 |
|
| 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 | + |
131 | 209 | def create_app(registry: ModelRegistry, config: ServerConfig) -> FastAPI: |
132 | 210 | metrics = Metrics() |
133 | 211 |
|
@@ -238,10 +316,13 @@ async def execute( |
238 | 316 | if request.n != 1: |
239 | 317 | metrics.reject(route, 400) |
240 | 318 | 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: |
242 | 320 | metrics.reject(route, 400) |
243 | 321 | 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", |
245 | 326 | ) |
246 | 327 | if request.stop is not None: |
247 | 328 | metrics.reject(route, 400) |
@@ -386,11 +467,25 @@ async def execute( |
386 | 467 | } |
387 | 468 | object_name = "text_completion" |
388 | 469 | 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 | + ) |
389 | 484 | return JSONResponse( |
390 | 485 | content={ |
391 | 486 | "id": response_id, |
392 | 487 | "object": object_name, |
393 | | - "created": int(time.time()), |
| 488 | + "created": created, |
394 | 489 | "model": request.model, |
395 | 490 | "choices": [choice], |
396 | 491 | "usage": { |
|
0 commit comments