From f6e0bdaf63172496632318999b2000d5923fbf49 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Wed, 21 Oct 2026 13:20:55 -0400 Subject: [PATCH] fix(openai-completions): keep a refusal instead of returning an empty turn A refused Chat Completions request ends with finish_reason "stop", no content and the text in message.refusal (delta.refusal when streamed). The provider ignored the field, so the turn was empty and reported as a success. Yield the streamed refusal, keep it as the turn's text, and report content_filter so the usual "filtered by the provider" warning (or error, with a data model) applies. Co-Authored-By: Claude Sonnet 5.5 --- chatlas/_provider_openai_completions.py | 18 +++++- tests/test_provider_openai_completions.py | 73 +++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/chatlas/_provider_openai_completions.py b/chatlas/_provider_openai_completions.py index 5051bcfc..ba5162cf 100644 --- a/chatlas/_provider_openai_completions.py +++ b/chatlas/_provider_openai_completions.py @@ -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)] @@ -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( @@ -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 diff --git a/tests/test_provider_openai_completions.py b/tests/test_provider_openai_completions.py index eb5178ee..374f6f11 100644 --- a/tests/test_provider_openai_completions.py +++ b/tests/test_provider_openai_completions.py @@ -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(