diff --git a/chatlas/_provider_openai_completions.py b/chatlas/_provider_openai_completions.py index cebe2794..52ebc564 100644 --- a/chatlas/_provider_openai_completions.py +++ b/chatlas/_provider_openai_completions.py @@ -24,6 +24,7 @@ ContentJson, ContentPDF, ContentText, + ContentThinking, ContentToolRequest, ContentToolResult, ) @@ -194,7 +195,20 @@ def _chat_perform_args( def stream_content(self, chunk) -> Optional[Content]: if not chunk.choices: return None - text = chunk.choices[0].delta.content + delta = chunk.choices[0].delta + + reasoning_content = getattr(delta, "reasoning_content", None) + if ( + reasoning_content is None + and hasattr(delta, "model_extra") + and delta.model_extra + ): + reasoning_content = delta.model_extra.get("reasoning_content") + + if reasoning_content: + return ContentThinking(thinking=reasoning_content) + + text = delta.content if text is None: return None return ContentText.model_construct(text=text) @@ -207,9 +221,20 @@ def stream_merge_chunks(self, completion, chunk): def stream_turn(self, completion, has_data_model): delta = completion["choices"][0].pop("delta") + + reasoning_content = delta.get("reasoning_content") + completion["choices"][0]["message"] = delta - completion = ChatCompletion.construct(**completion) - return self._response_as_turn(completion, has_data_model) + completion_obj = ChatCompletion.construct(**completion) + + turn = self._response_as_turn(completion_obj, has_data_model) + + if reasoning_content and not any( + isinstance(c, ContentThinking) for c in turn.contents + ): + turn.contents.insert(0, ContentThinking(thinking=reasoning_content)) + + return turn def value_turn(self, completion, has_data_model): return self._response_as_turn(completion, has_data_model) @@ -251,12 +276,15 @@ def _turns_as_inputs(turns: list[Turn]) -> list["ChatCompletionMessageParam"]: elif isinstance(turn, AssistantTurn): content_parts: list["ContentArrayOfContentPart"] = [] tool_calls: list["ChatCompletionMessageToolCallParam"] = [] + reasoning_content = "" for x in turn.contents: if isinstance(x, ContentText): content_parts.append({"type": "text", "text": x.text}) elif isinstance(x, ContentJson): text = orjson.dumps(x.value).decode("utf-8") content_parts.append({"type": "text", "text": text}) + elif isinstance(x, ContentThinking): + reasoning_content += x.thinking elif isinstance(x, ContentToolRequest): tool_calls.append( { @@ -276,7 +304,7 @@ def _turns_as_inputs(turns: list[Turn]) -> list["ChatCompletionMessageParam"]: ) # Some OpenAI-compatible models (e.g., Groq) don't work nicely with empty content - args = { + args: dict[str, Any] = { "role": "assistant", "content": content_parts, "tool_calls": tool_calls, @@ -285,8 +313,10 @@ def _turns_as_inputs(turns: list[Turn]) -> list["ChatCompletionMessageParam"]: del args["content"] if not tool_calls: del args["tool_calls"] + if reasoning_content: + args["reasoning_content"] = reasoning_content - res.append(ChatCompletionAssistantMessageParam(**args)) + res.append(cast("ChatCompletionAssistantMessageParam", args)) elif isinstance(turn, UserTurn): contents: list["ChatCompletionContentPartParam"] = [] @@ -343,11 +373,11 @@ def _turns_as_inputs(turns: list[Turn]) -> list["ChatCompletionMessageParam"]: f"Don't know how to handle content type {type(x)} for role='user'." ) + res.extend(tool_results) if contents: res.append( ChatCompletionUserMessageParam(content=contents, role="user") ) - res.extend(tool_results) else: raise ValueError(f"Unknown role: {turn.role}") @@ -361,15 +391,27 @@ def _response_as_turn( message = completion.choices[0].message contents: list[Content] = [] + + reasoning_content = getattr(message, "reasoning_content", None) + if ( + reasoning_content is None + and hasattr(message, "model_extra") + and message.model_extra + ): + reasoning_content = message.model_extra.get("reasoning_content") + + if reasoning_content: + contents.append(ContentThinking(thinking=reasoning_content)) + if message.content is not None: if has_data_model: data = message.content # Some providers (e.g., Cloudflare) may already provide a dict if not isinstance(data, dict): data = orjson.loads(data) - contents = [ContentJson(value=data)] + contents.append(ContentJson(value=data)) else: - contents = [ContentText(text=message.content)] + contents.append(ContentText(text=message.content)) tool_calls = message.tool_calls diff --git a/tests/test_provider_openai_completions.py b/tests/test_provider_openai_completions.py index 828164a4..d1847e8d 100644 --- a/tests/test_provider_openai_completions.py +++ b/tests/test_provider_openai_completions.py @@ -1,6 +1,15 @@ import httpx import pytest from chatlas import ChatOpenAICompletions +from chatlas._content import ( + ContentText, + ContentThinking, + ContentToolRequest, + ContentToolResult, +) +from chatlas._provider_openai_completions import OpenAICompletionsProvider +from chatlas._turn import AssistantTurn, UserTurn +from openai.types.chat import ChatCompletion from .conftest import ( assert_data_extraction, @@ -110,3 +119,76 @@ def test_openai_custom_http_client(): @pytest.mark.vcr def test_openai_list_models(): assert_list_models(ChatOpenAICompletions) + + +def test_tool_results_ordering(): + """Ensure tool results precede user text in _turns_as_inputs.""" + req = ContentToolRequest(id="call_123", name="my_tool", arguments={}) + + # Simulate a user turn containing both a tool result and new user text + turn = UserTurn( + [ + ContentToolResult(value="tool output", request=req), + ContentText(text="Here is some extra user text"), + ] + ) + + inputs = OpenAICompletionsProvider._turns_as_inputs([turn]) + + # Must generate 2 distinct messages, and the tool message must come first + assert len(inputs) == 2 + assert inputs[0]["role"] == "tool" + assert inputs[0]["tool_call_id"] == "call_123" + assert inputs[1]["role"] == "user" + assert inputs[1]["content"][0]["text"] == "Here is some extra user text" + + +def test_reasoning_content_serialization(): + """Ensure ContentThinking is serialized to reasoning_content.""" + # Simulate an Assistant turn containing both thinking and final text + turn = AssistantTurn( + [ContentThinking(thinking="Let me think..."), ContentText(text="Final answer")] + ) + + inputs = OpenAICompletionsProvider._turns_as_inputs([turn]) + + assert len(inputs) == 1 + assert inputs[0]["role"] == "assistant" + assert inputs[0].get("reasoning_content") == "Let me think..." + assert inputs[0]["content"][0]["text"] == "Final answer" + + +def test_reasoning_content_deserialization(): + """Ensure reasoning_content from OpenAI completion is parsed into ContentThinking.""" + # Simulate a raw dictionary returned from the API + mock_data = { + "id": "chatcmpl-123", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "role": "assistant", + "content": "Final answer", + "reasoning_content": "Thinking process", + }, + } + ], + "created": 123456, + "model": "deepseek-chat", + "object": "chat.completion", + } + + mock_completion = ChatCompletion.model_validate(mock_data) + + # Parse using our updated method + turn = OpenAICompletionsProvider._response_as_turn( + mock_completion, has_data_model=False + ) + + # Verify ContentThinking is successfully parsed and precedes ContentText + assert len(turn.contents) == 2 + assert isinstance(turn.contents[0], ContentThinking) + assert turn.contents[0].thinking == "Thinking process" + assert isinstance(turn.contents[1], ContentText) + assert turn.contents[1].text == "Final answer"