Compare commits

...
3 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
jpmschweitzerandClaude Opus 4.5 fb54887c03 fix: handle HybridRAG keywords schema change
Build and Push / build (release) Successful in 52s
library-desk now returns keywords as dict with core_keywords field.
Client now handles both list and dict formats for backwards compat.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:32:36 +01:00
jpmschweitzerandClaude Opus 4.5 d5e5fc1ad8 fix: Ollama message sanitization and streaming think slugs
Build and Push / build (release) Successful in 52s
- Fix `invalid message content type: <nil>` error from Ollama
- Create TatlockOllamaProvider that sanitizes messages (null → "")
- Update all agents to use sanitized provider
- Fix repeating think messages by adding ReasoningSummaryDone signal

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:19:03 +01:00
12 changed files with 223 additions and 99 deletions
+31
View File
@@ -7,6 +7,37 @@ 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 `<think>` tags in `content`
- Open WebUI now renders thinking as proper collapsible blocks instead of broken HTML
## [1.8.2] - 2025-12-16
### Fixed
- **HybridRAG keywords schema mismatch** - library-desk now returns `keywords` as dict with `core_keywords`, client now handles both formats
## [1.8.1] - 2025-12-16
### Fixed
#### Ollama Message Sanitization
- **Fixed `invalid message content type: <nil>` error** from Ollama
- Created custom `TatlockOllamaProvider` that sanitizes messages before sending to Ollama
- Ollama rejects assistant messages with `content: null` (tool-only messages from PydanticAI)
- Provider converts `null` content to empty string `""` for compatibility
- Updated all agents (Librarian, Biographer, Housekeeper, Tatlock) to use sanitized provider
- Added `src/ollama/provider.py` with reusable provider pattern
#### Streaming Think Message Accumulation
- **Fixed repeating think messages in frontend** (e.g., 10x "The Librarian has compiled...")
- Frontend was accumulating `ReasoningSummaryDelta` events expecting concatenation
- Added `ReasoningSummaryDone()` signal after each think message to indicate completion
- Each think slug is now treated as a complete message, not a continuation
## [1.8.0] - 2025-12-15
### Fixed
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tatlock"
version = "1.8.0"
version = "1.8.3"
description = "OpenAI-compatible API with Ollama backend"
requires-python = ">=3.12"
dependencies = []
+4 -7
View File
@@ -102,18 +102,15 @@ _biographer_agent: Optional[Agent[None, str]] = None
def _create_biographer_agent() -> Agent[None, str]:
"""Create The Biographer PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1
clean_host = str(config.OLLAMA_HOST).rstrip('/')
base_url = f"{clean_host}/v1"
from src.ollama.provider import get_ollama_provider
# Create Ollama model with provider
# Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider(),
)
agent: Agent[None, str] = Agent(
+4 -7
View File
@@ -113,18 +113,15 @@ _housekeeper_agent: Optional[Agent[None, str]] = None
def _create_housekeeper_agent() -> Agent[None, str]:
"""Create the Housekeeper PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1
clean_host = str(config.OLLAMA_HOST).rstrip("/")
base_url = f"{clean_host}/v1"
from src.ollama.provider import get_ollama_provider
# Create Ollama model with provider
# Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url),
provider=get_ollama_provider(),
)
agent: Agent[None, str] = Agent(
+4 -7
View File
@@ -122,18 +122,15 @@ _librarian_agent: Optional[Agent[None, str]] = None
def _create_librarian_agent() -> Agent[None, str]:
"""Create the Librarian PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1
clean_host = str(config.OLLAMA_HOST).rstrip('/')
base_url = f"{clean_host}/v1"
from src.ollama.provider import get_ollama_provider
# Create Ollama model with provider
# Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider(),
)
agent: Agent[None, str] = Agent(
+8 -1
View File
@@ -281,9 +281,16 @@ class LibraryDeskClient:
metadata=r.get("metadata", {}),
))
# Handle keywords being either a list or a dict with core_keywords
raw_keywords = data.get("keywords", [])
if isinstance(raw_keywords, dict):
keywords = raw_keywords.get("core_keywords", [])
else:
keywords = raw_keywords
return HybridRAGResponse(
results=results,
keywords=data.get("keywords", []),
keywords=keywords,
synonyms=data.get("synonyms", []),
related_dossiers=data.get("related_dossiers", []),
formatted_context=data.get("formatted_context", ""),
+10 -10
View File
@@ -143,7 +143,7 @@ class TatlockAgent(AgentInterface):
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from src.ollama.provider import get_ollama_provider
# PydanticAI expects Ollama base URL to end with /v1
# Remove trailing slash from ollama_host if present
@@ -153,7 +153,7 @@ class TatlockAgent(AgentInterface):
# Create Ollama model with provider
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Create PydanticAI agent with Ollama model
@@ -448,7 +448,7 @@ class TatlockAgent(AgentInterface):
... )
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from src.ollama.provider import get_ollama_provider
logger.info(
"tatlock_run_with_scoped_tools",
@@ -464,7 +464,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Create agent with scoped tools
@@ -542,7 +542,7 @@ class TatlockAgent(AgentInterface):
Text chunks from the streaming response
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from src.ollama.provider import get_ollama_provider
logger.info(
"tatlock_run_with_scoped_tools_stream",
@@ -557,7 +557,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Create agent with scoped tools
@@ -637,7 +637,7 @@ class TatlockAgent(AgentInterface):
- raw_output: The agent's raw text output
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from src.ollama.provider import get_ollama_provider
from pydantic_ai.settings import ModelSettings
from pydantic_ai.messages import (
ModelRequest,
@@ -661,7 +661,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Create agent with scoped tools
@@ -759,7 +759,7 @@ class TatlockAgent(AgentInterface):
str: Butler-toned response synthesized from all results
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from src.ollama.provider import get_ollama_provider
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
logger.info(
@@ -802,7 +802,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Synthesis agent uses butler prompt but no tools
+1
View File
@@ -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):
+6 -35
View File
@@ -172,24 +172,9 @@ async def create_chat_completion_stream(
async for event in stream_generator:
if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
# Start <think> 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="<think>\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 <think> 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="</think>\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
+130
View File
@@ -0,0 +1,130 @@
"""
PydanticAI provider for Ollama with message sanitization.
Ollama's OpenAI-compatible API rejects messages with `content: null`,
which PydanticAI sends for assistant messages that only contain tool calls.
This provider sanitizes messages to use empty strings instead of null.
"""
from typing import Any
from openai import AsyncOpenAI
from pydantic_ai.providers.ollama import OllamaProvider
from src.core.config import config
from src.core.logging_config import get_logger
logger = get_logger(__name__)
class TatlockOllamaProvider(OllamaProvider):
"""
Custom OllamaProvider with message sanitization for Tatlock agents.
Fixes the 'invalid message content type: <nil>' error that occurs
when assistant messages have `content: null` with tool calls.
"""
def __init__(self, base_url: str | None = None):
"""
Initialize provider with Ollama base URL.
Args:
base_url: Ollama API URL (defaults to config.OLLAMA_HOST/v1)
"""
if base_url is None:
clean_host = str(config.OLLAMA_HOST).rstrip("/")
base_url = f"{clean_host}/v1"
super().__init__(base_url=base_url)
# Override the client with our sanitized version
self._openai_client = _SanitizedAsyncOpenAI(base_url=base_url)
logger.debug("tatlock_ollama_provider_created", base_url=base_url)
class _SanitizedAsyncOpenAI(AsyncOpenAI):
"""AsyncOpenAI client that sanitizes messages before sending."""
def __init__(self, **kwargs: Any):
# Ollama doesn't need an API key
super().__init__(api_key="ollama", **kwargs)
@property
def chat(self) -> "_SanitizedChat":
"""Return sanitized chat interface."""
return _SanitizedChat(self)
class _SanitizedChat:
"""Chat interface wrapper with sanitized completions."""
def __init__(self, client: _SanitizedAsyncOpenAI):
self._client = client
self._original_chat = AsyncOpenAI.chat.fget(client) # type: ignore
@property
def completions(self) -> "_SanitizedCompletions":
"""Return sanitized completions interface."""
return _SanitizedCompletions(self._original_chat.completions)
class _SanitizedCompletions:
"""Completions wrapper that sanitizes messages before API calls."""
def __init__(self, original_completions: Any):
self._original = original_completions
async def create(self, **kwargs: Any) -> Any:
"""
Create chat completion with sanitized messages.
Converts `content: null` to `content: ""` in assistant messages
to prevent Ollama's 'invalid message content type: <nil>' error.
"""
if "messages" in kwargs:
kwargs["messages"] = _sanitize_messages(kwargs["messages"])
return await self._original.create(**kwargs)
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Sanitize messages to fix null content issues.
When an assistant message has tool_calls but no text content,
PydanticAI sets content to None. Ollama rejects this.
We convert None to empty string.
Args:
messages: List of chat messages
Returns:
Sanitized messages with null content replaced by empty strings
"""
sanitized = []
for msg in messages:
msg_copy = dict(msg)
# Fix null content in assistant messages with tool calls
if msg_copy.get("role") == "assistant":
if msg_copy.get("content") is None and msg_copy.get("tool_calls"):
msg_copy["content"] = ""
logger.debug(
"sanitized_null_content",
tool_call_count=len(msg_copy["tool_calls"]),
)
sanitized.append(msg_copy)
return sanitized
def get_ollama_provider() -> TatlockOllamaProvider:
"""
Get a configured Ollama provider for PydanticAI agents.
Returns:
TatlockOllamaProvider configured with sanitization
"""
return TatlockOllamaProvider()
+2
View File
@@ -211,8 +211,10 @@ class StreamingCoordinator:
)
# Stream think slugs that were collected during delegation
# Each think message is complete, so we signal done after each
for think_msg in orchestration_results.get("think_messages", []):
yield ReasoningSummaryDelta(delta=think_msg)
yield ReasoningSummaryDone()
await asyncio.sleep(0.05)
else:
+22 -31
View File
@@ -4,7 +4,7 @@ Tests for chat completions streaming wrapper.
Tests that the wrapper correctly:
- Wraps Responses API
- Enables reasoning automatically
- Converts reasoning to <think> 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 <think> 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 ("<think>" in content or "</think>" 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 <think> tags (reasoning enabled automatically)
assert think_tags_found, "Expected <think> 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 (<think> 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 "<think>" in content:
found_think_opening = True
if "</think>" in content:
found_think_closing = True
# 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
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>")
think_end = full_text.index("</think>")
assert think_start < think_end, "Opening <think> should come before closing </think>"
# 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