The 21 the automatic pass could not make on its own. `ruff check` and `ruff format --check` are both clean now; typecheck is still red and is next. `in_reasoning` in chat/service.py was a complete state machine that nothing read: initialised False, set True when a reasoning delta arrived, set False when the summary ended — three assignments, zero reads. Ruff reported one at a time, and removing each revealed the next, so what looked like a single stray variable took three passes to bottom out. The branches themselves do real work and are untouched; only the flag is gone. Four `raise HTTPException` inside `except` blocks now chain with `from e`. Until now a failure while handling an error was indistinguishable from the error, which matters most in exactly the situation where the traceback is all you have. In biographer/tools.py the binding was unused but the call is not: MemoryType() is called for the ValueError it raises on an invalid name. The binding is gone and the call and its comment stay, because dropping the line would have removed the validation. The rest are unused bindings in tests where the assertions are on something else (call_args, mostly), plus three unused loop variables and an isinstance tuple. One correction to my own work: removing a dead comprehension in test_error_handling.py left an `if` block with nothing but comments in it, which is a SyntaxError. Ruff caught it immediately. The block now says what the test actually pins — that the stream parses without crashing, which reaching that line demonstrates — rather than computing a list nobody asserts on. `make test` is intermittent here, and it is not this change. test_tatlock_tool_call_logging_calculator failed in two of five full runs across both HEAD and this branch, and passes in the other three; it also fails in isolation at HEAD while passing in isolation here. Order- or timing-dependent. Recorded rather than chased, since tests are not gated in this repo yet. Co-Authored-By: Claude <noreply@anthropic.com>
223 lines
7.2 KiB
Python
223 lines
7.2 KiB
Python
"""
|
|
Chat completion service.
|
|
|
|
Wrapper around Responses API that converts to Chat Completions format.
|
|
Embeds reasoning in <think> tags for Open WebUI compatibility.
|
|
"""
|
|
|
|
import time
|
|
import uuid
|
|
from collections.abc import AsyncGenerator
|
|
|
|
from src.chat import constants
|
|
from src.chat.schemas import (
|
|
ChatCompletionChoice,
|
|
ChatCompletionChunk,
|
|
ChatCompletionChunkChoice,
|
|
ChatCompletionChunkDelta,
|
|
ChatCompletionRequest,
|
|
ChatCompletionResponse,
|
|
ChatCompletionUsage,
|
|
ChatMessage,
|
|
)
|
|
from src.responses.schemas import ResponseRequest
|
|
from src.responses.service import create_response, create_response_with_steward
|
|
|
|
|
|
async def create_chat_completion(
|
|
request: ChatCompletionRequest,
|
|
) -> ChatCompletionResponse:
|
|
"""
|
|
Create chat completion by wrapping Responses API.
|
|
|
|
Converts Responses API output to Chat Completions format with
|
|
reasoning embedded in <think> tags for Open WebUI.
|
|
|
|
Args:
|
|
request: Chat completion request
|
|
|
|
Returns:
|
|
Chat completion response with reasoning as <think> tags
|
|
"""
|
|
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
|
created_at = int(time.time())
|
|
|
|
# Convert Chat request to Responses request
|
|
input_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
|
|
|
|
response_request = ResponseRequest(
|
|
model=request.model,
|
|
input=input_messages,
|
|
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
|
|
temperature=request.temperature or 1.0,
|
|
max_output_tokens=request.max_tokens,
|
|
stop=request.stop
|
|
if isinstance(request.stop, list)
|
|
else ([request.stop] if request.stop else None),
|
|
)
|
|
|
|
# Call Responses API (will use Steward for Tatlock)
|
|
model_id = request.model
|
|
if "." in model_id:
|
|
model_id = model_id.split(".", 1)[1]
|
|
|
|
use_steward = model_id.lower() == "tatlock"
|
|
|
|
if use_steward:
|
|
response = await create_response_with_steward(response_request)
|
|
else:
|
|
response = await create_response(response_request)
|
|
|
|
# Convert Responses API output to Chat format
|
|
content_parts = []
|
|
|
|
for item in response.output:
|
|
if item.type == "reasoning":
|
|
reasoning_text = "\n".join(item.summary)
|
|
content_parts.append(f"<think>\n{reasoning_text}\n</think>\n\n")
|
|
elif item.type == "message":
|
|
content_parts.append(item.content[0].text)
|
|
|
|
content = "".join(content_parts)
|
|
|
|
return ChatCompletionResponse(
|
|
id=completion_id,
|
|
object=constants.CHAT_COMPLETION_OBJECT,
|
|
created=created_at,
|
|
model=request.model,
|
|
choices=[
|
|
ChatCompletionChoice(
|
|
index=0,
|
|
message=ChatMessage(
|
|
role=constants.ROLE_ASSISTANT,
|
|
content=content,
|
|
),
|
|
finish_reason=constants.FINISH_REASON_STOP,
|
|
)
|
|
],
|
|
usage=ChatCompletionUsage(
|
|
prompt_tokens=response.usage.input_tokens,
|
|
completion_tokens=response.usage.output_tokens,
|
|
total_tokens=response.usage.total_tokens,
|
|
),
|
|
)
|
|
|
|
|
|
async def create_chat_completion_stream(
|
|
request: ChatCompletionRequest,
|
|
) -> AsyncGenerator[ChatCompletionChunk, None]:
|
|
"""
|
|
Create streaming chat completion by wrapping Responses API.
|
|
|
|
Streams reasoning in <think> tags followed by message content.
|
|
|
|
Args:
|
|
request: Chat completion request with stream=True
|
|
|
|
Yields:
|
|
Chat completion chunks with reasoning as <think> tags
|
|
"""
|
|
from src.responses.streaming import StreamEventType, StreamingCoordinator
|
|
|
|
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
|
created_at = int(time.time())
|
|
|
|
# Convert Chat request to Responses request
|
|
input_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
|
|
|
|
response_request = ResponseRequest(
|
|
model=request.model,
|
|
input=input_messages,
|
|
reasoning={"effort": "medium", "summary": "auto"},
|
|
temperature=request.temperature or 1.0,
|
|
max_output_tokens=request.max_tokens,
|
|
stop=request.stop
|
|
if isinstance(request.stop, list)
|
|
else ([request.stop] if request.stop else None),
|
|
stream=True,
|
|
)
|
|
|
|
# Determine if we should use Steward
|
|
model_id = request.model
|
|
if "." in model_id:
|
|
model_id = model_id.split(".", 1)[1]
|
|
|
|
use_steward = model_id.lower() == "tatlock"
|
|
|
|
# First chunk with role
|
|
yield ChatCompletionChunk(
|
|
id=completion_id,
|
|
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
|
created=created_at,
|
|
model=request.model,
|
|
choices=[
|
|
ChatCompletionChunkChoice(
|
|
index=0,
|
|
delta=ChatCompletionChunkDelta(role=constants.ROLE_ASSISTANT),
|
|
finish_reason=None,
|
|
)
|
|
],
|
|
)
|
|
|
|
# Stream from Responses API
|
|
coordinator = StreamingCoordinator()
|
|
|
|
if use_steward:
|
|
stream_generator = coordinator.stream_response_with_steward(response_request)
|
|
else:
|
|
stream_generator = coordinator.stream_response(response_request)
|
|
|
|
async for event in stream_generator:
|
|
if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
|
|
# Stream reasoning via reasoning_content field (DeepSeek R1 format)
|
|
# Open WebUI renders this as collapsible thinking block
|
|
yield ChatCompletionChunk(
|
|
id=completion_id,
|
|
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
|
created=created_at,
|
|
model=request.model,
|
|
choices=[
|
|
ChatCompletionChunkChoice(
|
|
index=0,
|
|
delta=ChatCompletionChunkDelta(reasoning_content=event.delta),
|
|
finish_reason=None,
|
|
)
|
|
],
|
|
)
|
|
|
|
elif event.event == StreamEventType.REASONING_SUMMARY_DONE:
|
|
# Signal end of reasoning block (no content needed)
|
|
pass # nothing downstream reads this; the event just ends the block
|
|
|
|
elif event.event == StreamEventType.OUTPUT_TEXT_DELTA:
|
|
# Stream message content
|
|
yield ChatCompletionChunk(
|
|
id=completion_id,
|
|
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
|
created=created_at,
|
|
model=request.model,
|
|
choices=[
|
|
ChatCompletionChunkChoice(
|
|
index=0,
|
|
delta=ChatCompletionChunkDelta(content=event.delta),
|
|
finish_reason=None,
|
|
)
|
|
],
|
|
)
|
|
|
|
elif event.event == StreamEventType.RESPONSE_DONE:
|
|
# Final chunk with finish_reason
|
|
yield ChatCompletionChunk(
|
|
id=completion_id,
|
|
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
|
created=created_at,
|
|
model=request.model,
|
|
choices=[
|
|
ChatCompletionChunkChoice(
|
|
index=0,
|
|
delta=ChatCompletionChunkDelta(),
|
|
finish_reason=constants.FINISH_REASON_STOP,
|
|
)
|
|
],
|
|
)
|