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
18 changes: 16 additions & 2 deletions chatlas/_provider_openai_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,10 @@ def stream_content(self, chunk, completion, turns=()) -> list[Content]:

text = delta.content
if text is None:
# A refused request streams its text as `refusal` instead of `content`
refusal = getattr(delta, "refusal", None)
if refusal:
return [ContentText.model_construct(text=refusal)]
return []
return [ContentText.model_construct(text=text)]

Expand Down Expand Up @@ -503,12 +507,20 @@ def _response_as_turn(
completion: "ChatCompletion", has_data_model: bool
) -> AssistantTurn[ChatCompletion]:
finish_reason = normalize_finish_reason(completion.choices[0].finish_reason)
message = completion.choices[0].message

# A refused request ends with "stop", has no content, and carries its text
# in `refusal`.
refusal = getattr(message, "refusal", None)
if not isinstance(refusal, str):
refusal = None
if refusal and not message.content and finish_reason == "success":
finish_reason = "content_filter"

if has_data_model:
# Must precede the JSON parse below; see check_finish_reason().
check_finish_reason(finish_reason, "error")

message = completion.choices[0].message

contents: list[Content] = []

reasoning = getattr(message, "reasoning", None) or getattr(
Expand All @@ -526,6 +538,8 @@ def _response_as_turn(
contents.append(ContentJson(value=data))
else:
contents.append(ContentText(text=message.content))
elif refusal:
contents.append(ContentText(text=refusal))

tool_calls = message.tool_calls

Expand Down
73 changes: 73 additions & 0 deletions tests/test_provider_openai_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,79 @@ def test_normalize_finish_reason_handles_none():
assert completions_normalize_finish_reason(None) is None


def _refusal_completion():
return ChatCompletion.model_validate(
{
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 0,
"model": "test-model",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": None,
"refusal": "I'm sorry, I can't help with that.",
},
}
],
}
)


def test_refusal_is_kept_as_text_and_reported_as_content_filter():
turn = OpenAICompletionsProvider._response_as_turn(
_refusal_completion(), has_data_model=False
)
assert turn.text == "I'm sorry, I can't help with that."
assert turn.finish_reason == "content_filter"


def test_refusal_with_a_data_model_reports_the_content_filter():
with pytest.raises(Exception, match="content moderation"):
OpenAICompletionsProvider._response_as_turn(
_refusal_completion(), has_data_model=True
)


def test_streamed_refusal_is_yielded_and_ends_the_turn():
from openai.types.chat import ChatCompletionChunk

def chunk(delta, finish_reason=None):
return ChatCompletionChunk.model_validate(
{
"id": "chatcmpl-1",
"object": "chat.completion.chunk",
"created": 0,
"model": "test-model",
"choices": [
{"index": 0, "delta": delta, "finish_reason": finish_reason}
],
}
)

provider = OpenAICompletionsProvider(model="test-model", api_key="test-key")
chunks = [
chunk({"role": "assistant", "content": None}),
chunk({"refusal": "I'm sorry, "}),
chunk({"refusal": "I can't help with that."}),
chunk({}, finish_reason="stop"),
]

completion = None
streamed = []
for c in chunks:
completion = provider.stream_merge_chunks(completion, c)
streamed += [x.text for x in provider.stream_content(c, completion)]
turn = provider.stream_turn(completion, has_data_model=False)

assert "".join(streamed) == "I'm sorry, I can't help with that."
assert turn.text == "I'm sorry, I can't help with that."
assert turn.finish_reason == "content_filter"


@pytest.mark.vcr
def test_openai_simple_request():
chat = ChatOpenAICompletions(
Expand Down
Loading