diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a366d..6af61e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.8.3] - 2025-12-16 + +### Fixed + +- **Open WebUI streaming rendering** - Use `reasoning_content` field for thinking (DeepSeek R1 format) instead of `` tags in `content` +- Open WebUI now renders thinking as proper collapsible blocks instead of broken HTML + ## [1.8.2] - 2025-12-16 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 70058e9..cd48792 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tatlock" -version = "1.8.2" +version = "1.8.3" description = "OpenAI-compatible API with Ollama backend" requires-python = ">=3.12" dependencies = [] diff --git a/src/chat/schemas.py b/src/chat/schemas.py index aed6045..d781689 100644 --- a/src/chat/schemas.py +++ b/src/chat/schemas.py @@ -55,6 +55,7 @@ class ChatCompletionChunkDelta(CustomBaseModel): """Delta in streaming chunk.""" role: str | None = None content: str | None = None + reasoning_content: str | None = None # For thinking/reasoning (DeepSeek R1 format) class ChatCompletionChunkChoice(CustomBaseModel): diff --git a/src/chat/service.py b/src/chat/service.py index fd83592..ca68796 100644 --- a/src/chat/service.py +++ b/src/chat/service.py @@ -172,24 +172,9 @@ async def create_chat_completion_stream( async for event in stream_generator: if event.event == StreamEventType.REASONING_SUMMARY_DELTA: - # Start block if needed - if not in_reasoning: - yield ChatCompletionChunk( - id=completion_id, - object=constants.CHAT_COMPLETION_CHUNK_OBJECT, - created=created_at, - model=request.model, - choices=[ - ChatCompletionChunkChoice( - index=0, - delta=ChatCompletionChunkDelta(content="\n"), - finish_reason=None, - ) - ], - ) - in_reasoning = True - - # Stream reasoning delta + # Stream reasoning via reasoning_content field (DeepSeek R1 format) + # Open WebUI renders this as collapsible thinking block + in_reasoning = True yield ChatCompletionChunk( id=completion_id, object=constants.CHAT_COMPLETION_CHUNK_OBJECT, @@ -198,29 +183,15 @@ async def create_chat_completion_stream( choices=[ ChatCompletionChunkChoice( index=0, - delta=ChatCompletionChunkDelta(content=event.delta), + delta=ChatCompletionChunkDelta(reasoning_content=event.delta), finish_reason=None, ) ], ) elif event.event == StreamEventType.REASONING_SUMMARY_DONE: - # Close block - if in_reasoning: - yield ChatCompletionChunk( - id=completion_id, - object=constants.CHAT_COMPLETION_CHUNK_OBJECT, - created=created_at, - model=request.model, - choices=[ - ChatCompletionChunkChoice( - index=0, - delta=ChatCompletionChunkDelta(content="\n\n"), - finish_reason=None, - ) - ], - ) - in_reasoning = False + # Signal end of reasoning block (no content needed) + in_reasoning = False elif event.event == StreamEventType.OUTPUT_TEXT_DELTA: # Stream message content diff --git a/tests/chat/test_streaming_wrapper.py b/tests/chat/test_streaming_wrapper.py index 08386c4..964b440 100644 --- a/tests/chat/test_streaming_wrapper.py +++ b/tests/chat/test_streaming_wrapper.py @@ -4,7 +4,7 @@ Tests for chat completions streaming wrapper. Tests that the wrapper correctly: - Wraps Responses API - Enables reasoning automatically -- Converts reasoning to tags +- Streams reasoning via reasoning_content field (DeepSeek R1 format) - Streams both reasoning and content """ import json @@ -17,7 +17,7 @@ from src.chat import constants @pytest.mark.unit @pytest.mark.asyncio async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient): - """Test that streaming wrapper automatically enables reasoning.""" + """Test that streaming wrapper automatically enables reasoning via reasoning_content.""" request_data = { "model": "lorem-tester", "messages": [ @@ -27,7 +27,7 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient): } chunks_received = [] - think_tags_found = False + reasoning_content_found = False async with async_client.stream( "POST", @@ -51,12 +51,12 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient): chunk = json.loads(data_str) chunks_received.append(chunk) - # Check for tags in delta content + # Check for reasoning_content in delta (DeepSeek R1 format) if "choices" in chunk and len(chunk["choices"]) > 0: delta = chunk["choices"][0].get("delta", {}) - content = delta.get("content") - if content and ("" in content or "" in content): - think_tags_found = True + reasoning = delta.get("reasoning_content") + if reasoning: + reasoning_content_found = True except json.JSONDecodeError: pass @@ -64,14 +64,14 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient): # Should have received chunks assert len(chunks_received) > 0 - # Should have found tags (reasoning enabled automatically) - assert think_tags_found, "Expected tags in streaming output" + # Should have found reasoning_content (reasoning enabled automatically) + assert reasoning_content_found, "Expected reasoning_content in streaming output" @pytest.mark.unit @pytest.mark.asyncio async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncClient): - """Test that reasoning ( tags) comes before actual content.""" + """Test that reasoning_content comes before regular content.""" request_data = { "model": "lorem-tester", "messages": [ @@ -80,10 +80,7 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli "stream": True } - all_content = [] - found_think_opening = False - found_think_closing = False - found_content_after_think = False + chunk_types = [] # Track order: 'reasoning' or 'content' async with async_client.stream( "POST", @@ -106,28 +103,22 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli chunk = json.loads(data_str) if "choices" in chunk and len(chunk["choices"]) > 0: delta = chunk["choices"][0].get("delta", {}) - content = delta.get("content", "") - if content: - all_content.append(content) + reasoning = delta.get("reasoning_content") + content = delta.get("content") - if "" in content: - found_think_opening = True - if "" in content: - found_think_closing = True - # Content after closing think tag - if found_think_closing and content.strip() and "" not in content and "" not in content: - found_content_after_think = True + if reasoning: + chunk_types.append("reasoning") + if content: + chunk_types.append("content") except json.JSONDecodeError: pass - # Verify ordering - full_text = "".join(all_content) - if found_think_opening and found_think_closing: - # Reasoning should come before main content - think_start = full_text.index("") - think_end = full_text.index("") - assert think_start < think_end, "Opening should come before closing " + # Verify reasoning comes before content + if "reasoning" in chunk_types and "content" in chunk_types: + first_reasoning = chunk_types.index("reasoning") + first_content = chunk_types.index("content") + assert first_reasoning < first_content, "reasoning_content should come before content" @pytest.mark.unit