Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions chatlas/_provider_openai_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ContentJson,
ContentPDF,
ContentText,
ContentThinking,
ContentToolRequest,
ContentToolResult,
)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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(
{
Expand All @@ -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,
Expand All @@ -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"] = []
Expand Down Expand Up @@ -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}")
Expand All @@ -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))
Comment on lines +395 to +404
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be great to have a test covering this logic.


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

Expand Down
82 changes: 82 additions & 0 deletions tests/test_provider_openai_completions.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"