Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8092740fa4 | ||
|
|
e469746f75 | ||
|
|
31e7884d8f | ||
|
|
e15def607d | ||
|
|
6dd1c2e2a9 | ||
|
|
3617218359 | ||
|
|
c7a4012831 |
@@ -1,8 +1,9 @@
|
|||||||
name: Build and Push
|
name: Build and Push
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
push:
|
||||||
types: [published]
|
tags:
|
||||||
|
- 'v[0-9]*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
|||||||
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.0.5] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Streaming JSON compatibility** - Exclude null fields from streaming chunks using `exclude_none=True`; OpenAI's API omits null fields entirely, and including them (e.g., `content: null`, `reasoning_content: null`) caused parsing issues in Open WebUI
|
||||||
|
|
||||||
|
## [2.0.4] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Open WebUI streaming compatibility** - Replaced `sse_starlette` `EventSourceResponse` with plain `StreamingResponse` for chat completions; `sse_starlette` added `\r\n` line endings and extra SSE fields that Open WebUI couldn't parse
|
||||||
|
|
||||||
|
## [2.0.3] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Steward analysis leaking into responses** - Removed internal routing analysis (`DELEGATE: tatlock_core...`) from user-visible reasoning in both streaming and non-streaming paths
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **tool_choice format incompatibility** - Removed `extra_body` tool_choice hack for Claude backend; PydanticAI handles tool_choice natively for Anthropic, preventing infinite tool call loops
|
||||||
|
- **CI trigger** - Changed workflow trigger from `release:published` to `push:tags:v[0-9]*`
|
||||||
|
|
||||||
|
## [2.0.1] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Expert agent registration failure** - `AnthropicModel` does not accept `api_key` directly; now passes it via `AnthropicProvider`
|
||||||
|
|
||||||
## [2.0.0] - 2026-02-05
|
## [2.0.0] - 2026-02-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tatlock"
|
name = "tatlock"
|
||||||
version = "2.0.0"
|
version = "2.0.5"
|
||||||
description = "OpenAI-compatible API with Ollama backend"
|
description = "OpenAI-compatible API with Ollama backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -494,13 +494,13 @@ class TatlockAgent(AgentInterface):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Run with scoped tools and tracker
|
# Run with scoped tools and tracker
|
||||||
# Force tool_choice: required to make LLM actually call tools
|
# Force tool_choice to make LLM actually call tools
|
||||||
from pydantic_ai.settings import ModelSettings
|
from src.anthropic.model_selector import get_tool_choice_settings
|
||||||
result = await scoped_agent.run(
|
result = await scoped_agent.run(
|
||||||
enriched_message,
|
enriched_message,
|
||||||
message_history=pydantic_history if pydantic_history else None,
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
deps=tool_tracker,
|
deps=tool_tracker,
|
||||||
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
|
model_settings=get_tool_choice_settings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -624,7 +624,6 @@ class TatlockAgent(AgentInterface):
|
|||||||
- tool_outputs: Dict mapping tool names to their outputs
|
- tool_outputs: Dict mapping tool names to their outputs
|
||||||
- raw_output: The agent's raw text output
|
- raw_output: The agent's raw text output
|
||||||
"""
|
"""
|
||||||
from pydantic_ai.settings import ModelSettings
|
|
||||||
from pydantic_ai.messages import (
|
from pydantic_ai.messages import (
|
||||||
ModelRequest,
|
ModelRequest,
|
||||||
ModelResponse,
|
ModelResponse,
|
||||||
@@ -684,11 +683,12 @@ class TatlockAgent(AgentInterface):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Run with scoped tools and tracker
|
# Run with scoped tools and tracker
|
||||||
|
from src.anthropic.model_selector import get_tool_choice_settings
|
||||||
result = await scoped_agent.run(
|
result = await scoped_agent.run(
|
||||||
enriched_message,
|
enriched_message,
|
||||||
message_history=pydantic_history if pydantic_history else None,
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
deps=tool_tracker,
|
deps=tool_tracker,
|
||||||
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
|
model_settings=get_tool_choice_settings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract tool calls and results from the agent's messages
|
# Extract tool calls and results from the agent's messages
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ Provides model selection with automatic fallback between Claude and Ollama.
|
|||||||
from src.anthropic.model_selector import (
|
from src.anthropic.model_selector import (
|
||||||
check_claude_health,
|
check_claude_health,
|
||||||
get_model,
|
get_model,
|
||||||
|
get_tool_choice_settings,
|
||||||
is_claude_available,
|
is_claude_available,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"check_claude_health",
|
"check_claude_health",
|
||||||
"get_model",
|
"get_model",
|
||||||
|
"get_tool_choice_settings",
|
||||||
"is_claude_available",
|
"is_claude_available",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Union
|
|||||||
|
|
||||||
from pydantic_ai.models.anthropic import AnthropicModel
|
from pydantic_ai.models.anthropic import AnthropicModel
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from pydantic_ai.providers.anthropic import AnthropicProvider
|
||||||
|
|
||||||
from src.core.config import config
|
from src.core.config import config
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
@@ -113,7 +114,7 @@ def get_model(prefer_cloud: bool | None = None) -> Union[AnthropicModel, OpenAIC
|
|||||||
)
|
)
|
||||||
return AnthropicModel(
|
return AnthropicModel(
|
||||||
model_name=config.ANTHROPIC_MODEL,
|
model_name=config.ANTHROPIC_MODEL,
|
||||||
api_key=config.ANTHROPIC_API_KEY,
|
provider=AnthropicProvider(api_key=config.ANTHROPIC_API_KEY),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fall back to Ollama
|
# Fall back to Ollama
|
||||||
@@ -131,6 +132,23 @@ def get_model(prefer_cloud: bool | None = None) -> Union[AnthropicModel, OpenAIC
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_choice_settings() -> 'ModelSettings':
|
||||||
|
"""
|
||||||
|
Get model_settings for forcing tool calls on the first request.
|
||||||
|
|
||||||
|
For Claude: PydanticAI handles tool_choice natively, so no extra_body needed.
|
||||||
|
For Ollama: Pass tool_choice="required" via extra_body to force tool calling.
|
||||||
|
"""
|
||||||
|
from pydantic_ai.settings import ModelSettings
|
||||||
|
|
||||||
|
if is_claude_available() and config.PREFER_CLOUD_BACKEND:
|
||||||
|
# PydanticAI's Anthropic model handles tool_choice internally
|
||||||
|
return ModelSettings()
|
||||||
|
else:
|
||||||
|
# Ollama needs explicit tool_choice via extra_body
|
||||||
|
return ModelSettings(extra_body={"tool_choice": "required"})
|
||||||
|
|
||||||
|
|
||||||
def get_model_info() -> dict:
|
def get_model_info() -> dict:
|
||||||
"""
|
"""
|
||||||
Get information about the current model configuration.
|
Get information about the current model configuration.
|
||||||
|
|||||||
+17
-13
@@ -7,7 +7,7 @@ import logging
|
|||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from src.chat import service
|
from src.chat import service
|
||||||
from src.chat.schemas import (
|
from src.chat.schemas import (
|
||||||
@@ -22,36 +22,33 @@ router = APIRouter(prefix="/chat", tags=["chat"])
|
|||||||
|
|
||||||
async def _stream_response(
|
async def _stream_response(
|
||||||
request: ChatCompletionRequest,
|
request: ChatCompletionRequest,
|
||||||
) -> AsyncGenerator[dict, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""
|
"""
|
||||||
Generate SSE stream for chat completion.
|
Generate SSE stream for chat completion.
|
||||||
|
|
||||||
EventSourceResponse adds "data: " prefix automatically.
|
Yields raw SSE-formatted strings matching OpenAI's format exactly:
|
||||||
We just yield the dict/string content.
|
data: {json}\n\n
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async for chunk in service.create_chat_completion_stream(request):
|
async for chunk in service.create_chat_completion_stream(request):
|
||||||
# Yield dict - EventSourceResponse will format as SSE
|
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
|
||||||
yield {"data": chunk.model_dump_json()}
|
|
||||||
|
|
||||||
# Send [DONE] message
|
yield "data: [DONE]\n\n"
|
||||||
yield {"data": "[DONE]"}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in streaming response: {e}")
|
logger.error(f"Error in streaming response: {e}")
|
||||||
error_data = {"error": {"message": str(e), "type": "internal_error"}}
|
error_data = json.dumps({"error": {"message": str(e), "type": "internal_error"}})
|
||||||
yield {"data": json.dumps(error_data)}
|
yield f"data: {error_data}\n\n"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/completions", response_model=ChatCompletionResponse)
|
@router.post("/completions", response_model=ChatCompletionResponse)
|
||||||
async def create_chat_completion(
|
async def create_chat_completion(
|
||||||
request: ChatCompletionRequest,
|
request: ChatCompletionRequest,
|
||||||
) -> ChatCompletionResponse | EventSourceResponse:
|
) -> ChatCompletionResponse | StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Create chat completion (OpenAI-compatible).
|
Create chat completion (OpenAI-compatible).
|
||||||
|
|
||||||
Supports both regular and streaming responses.
|
Supports both regular and streaming responses.
|
||||||
Currently returns mock lorem ipsum responses.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: Chat completion request
|
request: Chat completion request
|
||||||
@@ -63,6 +60,13 @@ async def create_chat_completion(
|
|||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
logger.info("Streaming response requested")
|
logger.info("Streaming response requested")
|
||||||
return EventSourceResponse(_stream_response(request))
|
return StreamingResponse(
|
||||||
|
_stream_response(request),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return await service.create_chat_completion(request)
|
return await service.create_chat_completion(request)
|
||||||
|
|||||||
@@ -651,16 +651,6 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|||||||
# Build response output items
|
# Build response output items
|
||||||
output_items = []
|
output_items = []
|
||||||
|
|
||||||
# Add Steward reasoning as a reasoning output item
|
|
||||||
output_items.append(ReasoningOutputItem(
|
|
||||||
id=f"reasoning_{generate_id()}",
|
|
||||||
summary=[
|
|
||||||
"🎩 Steward's Analysis:",
|
|
||||||
enriched.steward_reasoning,
|
|
||||||
],
|
|
||||||
status="completed"
|
|
||||||
))
|
|
||||||
|
|
||||||
# Add Tatlock's message
|
# Add Tatlock's message
|
||||||
output_items.append(MessageOutputItem(
|
output_items.append(MessageOutputItem(
|
||||||
id=f"msg_{generate_id()}",
|
id=f"msg_{generate_id()}",
|
||||||
|
|||||||
@@ -166,26 +166,6 @@ class StreamingCoordinator:
|
|||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stream Steward's analysis as reasoning summary
|
|
||||||
steward_lines = enriched.steward_reasoning.split('\n')
|
|
||||||
for line in steward_lines:
|
|
||||||
if line.strip():
|
|
||||||
yield ReasoningSummaryDelta(delta=line + "\n")
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
|
|
||||||
yield ReasoningSummaryDone()
|
|
||||||
|
|
||||||
# Add Steward reasoning to output items
|
|
||||||
reasoning_item = ReasoningOutputItem(
|
|
||||||
id=f"reasoning_{generate_id()}",
|
|
||||||
summary=[
|
|
||||||
"🎩 Steward's Analysis:",
|
|
||||||
enriched.steward_reasoning,
|
|
||||||
],
|
|
||||||
status="completed"
|
|
||||||
)
|
|
||||||
output_items.append(reasoning_item)
|
|
||||||
|
|
||||||
# Initialize tool tracker
|
# Initialize tool tracker
|
||||||
tracker = ToolCallTracker(
|
tracker = ToolCallTracker(
|
||||||
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||||
|
|||||||
Reference in New Issue
Block a user