Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4907798e74 |
@@ -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
@@ -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 = []
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user