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] ## [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 ## [1.8.0] - 2025-12-15
### Fixed ### Fixed
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tatlock" name = "tatlock"
version = "1.8.0" 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 = []
+4 -7
View File
@@ -102,18 +102,15 @@ _biographer_agent: Optional[Agent[None, str]] = None
def _create_biographer_agent() -> Agent[None, str]: def _create_biographer_agent() -> Agent[None, str]:
"""Create The Biographer PydanticAI agent.""" """Create The Biographer PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1 from src.ollama.provider import get_ollama_provider
clean_host = str(config.OLLAMA_HOST).rstrip('/')
base_url = f"{clean_host}/v1"
# Create Ollama model with provider # Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel( model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL, model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url) provider=get_ollama_provider(),
) )
agent: Agent[None, str] = Agent( 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]: def _create_housekeeper_agent() -> Agent[None, str]:
"""Create the Housekeeper PydanticAI agent.""" """Create the Housekeeper PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1 from src.ollama.provider import get_ollama_provider
clean_host = str(config.OLLAMA_HOST).rstrip("/")
base_url = f"{clean_host}/v1"
# Create Ollama model with provider # Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel( model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL, model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url), provider=get_ollama_provider(),
) )
agent: Agent[None, str] = Agent( 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]: def _create_librarian_agent() -> Agent[None, str]:
"""Create the Librarian PydanticAI agent.""" """Create the Librarian PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1 from src.ollama.provider import get_ollama_provider
clean_host = str(config.OLLAMA_HOST).rstrip('/')
base_url = f"{clean_host}/v1"
# Create Ollama model with provider # Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel( model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL, model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url) provider=get_ollama_provider(),
) )
agent: Agent[None, str] = Agent( agent: Agent[None, str] = Agent(
+8 -1
View File
@@ -281,9 +281,16 @@ class LibraryDeskClient:
metadata=r.get("metadata", {}), 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( return HybridRAGResponse(
results=results, results=results,
keywords=data.get("keywords", []), keywords=keywords,
synonyms=data.get("synonyms", []), synonyms=data.get("synonyms", []),
related_dossiers=data.get("related_dossiers", []), related_dossiers=data.get("related_dossiers", []),
formatted_context=data.get("formatted_context", ""), formatted_context=data.get("formatted_context", ""),
+10 -10
View File
@@ -143,7 +143,7 @@ class TatlockAgent(AgentInterface):
# Import required classes for Ollama configuration # Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel 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 # PydanticAI expects Ollama base URL to end with /v1
# Remove trailing slash from ollama_host if present # Remove trailing slash from ollama_host if present
@@ -153,7 +153,7 @@ class TatlockAgent(AgentInterface):
# Create Ollama model with provider # Create Ollama model with provider
ollama_model = OpenAIChatModel( ollama_model = OpenAIChatModel(
model_name=self.model_name, model_name=self.model_name,
provider=OllamaProvider(base_url=base_url) provider=get_ollama_provider()
) )
# Create PydanticAI agent with Ollama model # Create PydanticAI agent with Ollama model
@@ -448,7 +448,7 @@ class TatlockAgent(AgentInterface):
... ) ... )
""" """
from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider from src.ollama.provider import get_ollama_provider
logger.info( logger.info(
"tatlock_run_with_scoped_tools", "tatlock_run_with_scoped_tools",
@@ -464,7 +464,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel( ollama_model = OpenAIChatModel(
model_name=self.model_name, model_name=self.model_name,
provider=OllamaProvider(base_url=base_url) provider=get_ollama_provider()
) )
# Create agent with scoped tools # Create agent with scoped tools
@@ -542,7 +542,7 @@ class TatlockAgent(AgentInterface):
Text chunks from the streaming response Text chunks from the streaming response
""" """
from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider from src.ollama.provider import get_ollama_provider
logger.info( logger.info(
"tatlock_run_with_scoped_tools_stream", "tatlock_run_with_scoped_tools_stream",
@@ -557,7 +557,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel( ollama_model = OpenAIChatModel(
model_name=self.model_name, model_name=self.model_name,
provider=OllamaProvider(base_url=base_url) provider=get_ollama_provider()
) )
# Create agent with scoped tools # Create agent with scoped tools
@@ -637,7 +637,7 @@ class TatlockAgent(AgentInterface):
- raw_output: The agent's raw text output - raw_output: The agent's raw text output
""" """
from pydantic_ai.models.openai import OpenAIChatModel 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.settings import ModelSettings
from pydantic_ai.messages import ( from pydantic_ai.messages import (
ModelRequest, ModelRequest,
@@ -661,7 +661,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel( ollama_model = OpenAIChatModel(
model_name=self.model_name, model_name=self.model_name,
provider=OllamaProvider(base_url=base_url) provider=get_ollama_provider()
) )
# Create agent with scoped tools # Create agent with scoped tools
@@ -759,7 +759,7 @@ class TatlockAgent(AgentInterface):
str: Butler-toned response synthesized from all results str: Butler-toned response synthesized from all results
""" """
from pydantic_ai.models.openai import OpenAIChatModel 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 from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
logger.info( logger.info(
@@ -802,7 +802,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel( ollama_model = OpenAIChatModel(
model_name=self.model_name, model_name=self.model_name,
provider=OllamaProvider(base_url=base_url) provider=get_ollama_provider()
) )
# Synthesis agent uses butler prompt but no tools # Synthesis agent uses butler prompt but no tools
+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
+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 # 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", []): for think_msg in orchestration_results.get("think_messages", []):
yield ReasoningSummaryDelta(delta=think_msg) yield ReasoningSummaryDelta(delta=think_msg)
yield ReasoningSummaryDone()
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
else: else:
+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