fix(transports): only treat a refusal as terminal when it is the sole payload

A chat-completions response that carries real text or tool calls *alongside*
a `message.refusal` note is a normal, usable turn — the model did work. The
prior logic flipped finish_reason to `content_filter` whenever a refusal
string was present, so the conversation loop reframed a content-bearing turn
as a *failed* safety refusal (failed=True) and buried the model's actual
output inside the "model declined" template, or dropped tool calls entirely.

Only promote to a terminal `content_filter` when the refusal is the sole
payload (no visible text AND no tool calls). The refusal explanation is still
recorded in provider_data in every case for observability. Refusal-only
responses (the bug this feature targets) are unaffected and still surface
terminally; the empty+refusal, bare content_filter passthrough, and no-refusal
common cases are byte-identical to before.

Updates the partial-content test to the corrected contract and adds a
tool_calls-alongside-refusal regression guard.
This commit is contained in:
kshitijk4poor
2026-06-14 12:12:52 +05:30
parent ab26541b9a
commit 12c84d6c77
2 changed files with 44 additions and 7 deletions
@@ -923,8 +923,10 @@ class TestChatCompletionsNormalize:
assert nr.content is None
def test_refusal_does_not_clobber_existing_content(self, transport):
"""If the model emitted partial text *and* a refusal, keep the visible
text as content but still flag the refusal via content_filter."""
"""If the model emitted real text *and* a refusal note, the turn is a
normal usable response: keep the visible text, record the refusal in
provider_data, and do NOT promote to a terminal content_filter (which
would discard the model's actual work by reframing it as a failure)."""
r = SimpleNamespace(
choices=[SimpleNamespace(
message=SimpleNamespace(
@@ -937,7 +939,32 @@ class TestChatCompletionsNormalize:
)
nr = transport.normalize_response(r)
assert nr.content == "partial answer"
assert nr.finish_reason == "content_filter"
assert nr.finish_reason == "stop"
assert nr.provider_data == {"refusal": "cannot continue"}
def test_refusal_with_tool_calls_is_not_promoted(self, transport):
"""A response that carries tool calls alongside a refusal note is a
usable tool turn — record the refusal but keep the tool calls and do
NOT terminate it as a content_filter refusal."""
tc = SimpleNamespace(
id="call_1", type="function",
function=SimpleNamespace(name="do_thing", arguments="{}"),
)
r = SimpleNamespace(
choices=[SimpleNamespace(
message=SimpleNamespace(
content=None, tool_calls=[tc],
reasoning_content=None, refusal="cannot continue",
),
finish_reason="tool_calls",
)],
usage=None,
)
nr = transport.normalize_response(r)
# Tool calls survive; finish reason is untouched; content not clobbered.
assert nr.tool_calls and nr.tool_calls[0].name == "do_thing"
assert nr.finish_reason == "tool_calls"
assert nr.content in (None, "")
assert nr.provider_data == {"refusal": "cannot continue"}