diff --git a/CHANGELOG.md b/CHANGELOG.md index b59b8a3..a2dd771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.4] - 2026-02-05 + +### Fixed + +- **Open WebUI streaming compatibility** - Replaced `sse_starlette` `EventSourceResponse` with plain `StreamingResponse` for chat completions; `sse_starlette` added `\r\n` line endings and extra SSE fields that Open WebUI couldn't parse + ## [2.0.3] - 2026-02-05 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index d151715..5c1186e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tatlock" -version = "2.0.3" +version = "2.0.4" description = "OpenAI-compatible API with Ollama backend" requires-python = ">=3.12" dependencies = [] diff --git a/src/chat/router.py b/src/chat/router.py index fbcc851..26abf3c 100644 --- a/src/chat/router.py +++ b/src/chat/router.py @@ -7,7 +7,7 @@ import logging from typing import AsyncGenerator from fastapi import APIRouter -from sse_starlette.sse import EventSourceResponse +from starlette.responses import StreamingResponse from src.chat import service from src.chat.schemas import ( @@ -22,47 +22,51 @@ router = APIRouter(prefix="/chat", tags=["chat"]) async def _stream_response( request: ChatCompletionRequest, -) -> AsyncGenerator[dict, None]: +) -> AsyncGenerator[str, None]: """ Generate SSE stream for chat completion. - EventSourceResponse adds "data: " prefix automatically. - We just yield the dict/string content. + Yields raw SSE-formatted strings matching OpenAI's format exactly: + data: {json}\n\n """ try: async for chunk in service.create_chat_completion_stream(request): - # Yield dict - EventSourceResponse will format as SSE - yield {"data": chunk.model_dump_json()} + yield f"data: {chunk.model_dump_json()}\n\n" - # Send [DONE] message - yield {"data": "[DONE]"} + yield "data: [DONE]\n\n" except Exception as e: logger.error(f"Error in streaming response: {e}") - error_data = {"error": {"message": str(e), "type": "internal_error"}} - yield {"data": json.dumps(error_data)} + error_data = json.dumps({"error": {"message": str(e), "type": "internal_error"}}) + yield f"data: {error_data}\n\n" @router.post("/completions", response_model=ChatCompletionResponse) async def create_chat_completion( request: ChatCompletionRequest, -) -> ChatCompletionResponse | EventSourceResponse: +) -> ChatCompletionResponse | StreamingResponse: """ Create chat completion (OpenAI-compatible). - + Supports both regular and streaming responses. - Currently returns mock lorem ipsum responses. - + Args: request: Chat completion request - + Returns: Chat completion response or SSE stream """ logger.info(f"Chat completion request for model: {request.model}") - + if request.stream: logger.info("Streaming response requested") - return EventSourceResponse(_stream_response(request)) - + return StreamingResponse( + _stream_response(request), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + }, + ) + return await service.create_chat_completion(request)