Compare commits

...
1 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 4907798e74 fix: use reasoning_content for Open WebUI streaming
Build and Push / build (release) Successful in 51s
Use DeepSeek R1 format (reasoning_content field) instead of <think>
tags in content. Open WebUI now renders thinking as proper
collapsible blocks instead of broken escaped HTML.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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