From 63e14677f618255103e740ab4ca88b61e8e3e1e2 Mon Sep 17 00:00:00 2001 From: GyeongChan Jang Date: Mon, 13 Jul 2026 10:13:32 +0900 Subject: [PATCH] fix(stream): emit a single finish_reason for tool-call chat completions sse_translate_chat emitted finish_reason "tool_calls" for each completed function_call item and then an additional finish_reason "stop" chunk from the response.completed handler. Per the OpenAI streaming format a choice carries exactly one finish_reason; clients that honor the last one classify a mixed text + tool_call response as a plain text turn and silently drop the accumulated tool calls. Mark the stop chunk as already sent when the tool_calls finish chunk goes out, so response.completed no longer appends the spurious "stop". Adds a streaming regression test that fails without the fix. --- chatmock/utils.py | 2 ++ tests/test_routes.py | 61 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/chatmock/utils.py b/chatmock/utils.py index c04b997..0d180a0 100644 --- a/chatmock/utils.py +++ b/chatmock/utils.py @@ -599,6 +599,7 @@ def _merge_from(src): ], } yield f"data: {json.dumps(finish_chunk)}\n\n".encode("utf-8") + sent_stop_chunk = True except Exception: pass @@ -682,6 +683,7 @@ def _merge_from(src): "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}], } yield f"data: {json.dumps(finish_chunk)}\n\n".encode("utf-8") + sent_stop_chunk = True elif kind == "response.reasoning_summary_part.added": if compat in ("think-tags", "o3"): if saw_any_summary: diff --git a/tests/test_routes.py b/tests/test_routes.py index acf135d..5fb3c9a 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -97,6 +97,67 @@ def test_chat_completions(self, mock_start) -> None: self.assertEqual(body["choices"][0]["message"]["content"], "hello") self.assertEqual(body["model"], "gpt5.4-mini") + @patch("chatmock.routes_openai.start_upstream_request") + def test_chat_completions_stream_single_finish_reason_with_tool_call(self, mock_start) -> None: + """A streamed response that mixes assistant text with a function call must + emit exactly one finish_reason ("tool_calls"), not a trailing "stop" chunk. + + Clients that honor the last finish_reason otherwise treat the response as a + plain text turn and drop the accumulated tool calls entirely. + """ + mock_start.return_value = ( + FakeUpstream( + [ + {"type": "response.output_text.delta", "delta": "Let me look that up."}, + { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": "call_1", + "name": "get_weather", + "arguments": "{\"city\": \"Seoul\"}", + }, + }, + {"type": "response.completed", "response": {"id": "resp-tool-stream"}}, + ] + ), + None, + ) + response = self.client.post( + "/v1/chat/completions", + json={ + "model": "gpt-5.4", + "messages": [{"role": "user", "content": "weather in seoul?"}], + "stream": True, + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + } + ], + }, + ) + self.assertEqual(response.status_code, 200) + finish_reasons: list[str] = [] + saw_tool_call_delta = False + for line in response.get_data(as_text=True).splitlines(): + if not line.startswith("data: ") or line == "data: [DONE]": + continue + chunk = json.loads(line[len("data: ") :]) + for choice in chunk.get("choices", []): + if choice.get("finish_reason"): + finish_reasons.append(choice["finish_reason"]) + if (choice.get("delta") or {}).get("tool_calls"): + saw_tool_call_delta = True + self.assertTrue(saw_tool_call_delta) + self.assertEqual(finish_reasons, ["tool_calls"]) + @patch("chatmock.routes_openai.start_upstream_request") def test_chat_completions_honors_debug_model_override(self, mock_start) -> None: app = create_app(debug_model="gpt-5.4")