diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 1ee20c6459..8c7267c0e2 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3780,6 +3780,14 @@ def _build_chat_request( chat_kwargs["top_p"] = payload.top_p if payload.max_output_tokens is not None: chat_kwargs["max_tokens"] = payload.max_output_tokens + # `parallel_tool_calls` is now a first-class field on + # ChatCompletionRequest (PR #5711) and the OpenAI-compat + # passthrough builder forwards it. Translate it here so a Responses + # API caller (e.g. OpenAI Codex SDK) that sets + # `parallel_tool_calls=false` actually sees the preference reach + # llama-server instead of getting silently dropped at the bridge. + if payload.parallel_tool_calls is not None: + chat_kwargs["parallel_tool_calls"] = payload.parallel_tool_calls chat_tools = _translate_responses_tools_to_chat(payload.tools) if chat_tools is not None: @@ -3789,13 +3797,7 @@ def _build_chat_request( if chat_tool_choice is not None: chat_kwargs["tool_choice"] = chat_tool_choice - req = ChatCompletionRequest(**chat_kwargs) - # `parallel_tool_calls` is not a first-class field on ChatCompletionRequest, - # but the model allows extras and _build_openai_passthrough_body forwards - # only explicitly-known fields. Llama-server does not currently implement - # parallel_tool_calls semantics, so we accept-and-ignore it on the - # Responses side to avoid breaking SDK clients that always send it. - return req + return ChatCompletionRequest(**chat_kwargs) def _chat_tool_calls_to_responses_output(tool_calls: list[dict]) -> list[dict]: diff --git a/studio/backend/tests/test_sampling_params_routing.py b/studio/backend/tests/test_sampling_params_routing.py index b0d0c5c3b4..c27d782c62 100644 --- a/studio/backend/tests/test_sampling_params_routing.py +++ b/studio/backend/tests/test_sampling_params_routing.py @@ -547,6 +547,51 @@ def test_local_openai_passthrough_forwards_new_sampling_fields(): assert body["parallel_tool_calls"] is False, body +# ── Responses → ChatCompletions bridge preserves parallel_tool_calls ── + + +def test_responses_to_chat_bridge_preserves_parallel_tool_calls(): + """Round 3 reviewers flagged that `_build_chat_request` (the + /v1/responses → /v1/chat/completions translator) dropped + `parallel_tool_calls`, so a Responses-API caller that set + `parallel_tool_calls=false` never saw the flag reach llama-server. + Pin the translation.""" + from models.inference import ChatMessage, ResponsesRequest + from routes.inference import _build_chat_request, _build_openai_passthrough_body + + payload = ResponsesRequest( + input = "hi", + stream = True, + parallel_tool_calls = False, + ) + chat_req = _build_chat_request( + payload, + [ChatMessage(role = "user", content = "hi")], + stream = True, + ) + assert chat_req.parallel_tool_calls is False, chat_req + body = _build_openai_passthrough_body(chat_req, backend_ctx = 4096) + assert body["parallel_tool_calls"] is False, body + + +def test_responses_to_chat_bridge_omits_unset_parallel_tool_calls(): + """Unset `parallel_tool_calls` (None) must not appear on the + translated body — the upstream default is `true` everywhere, so + forwarding `parallel_tool_calls=None` would over-specify.""" + from models.inference import ChatMessage, ResponsesRequest + from routes.inference import _build_chat_request, _build_openai_passthrough_body + + payload = ResponsesRequest(input = "hi", stream = True) + chat_req = _build_chat_request( + payload, + [ChatMessage(role = "user", content = "hi")], + stream = True, + ) + assert chat_req.parallel_tool_calls is None, chat_req + body = _build_openai_passthrough_body(chat_req, backend_ctx = 4096) + assert "parallel_tool_calls" not in body, body + + # ── Backend ChatInferenceSettings schema accepts new fields ──────────── diff --git a/studio/frontend/src/features/chat/utils/chat-settings-storage.ts b/studio/frontend/src/features/chat/utils/chat-settings-storage.ts index 7446aa961b..e529b792d1 100644 --- a/studio/frontend/src/features/chat/utils/chat-settings-storage.ts +++ b/studio/frontend/src/features/chat/utils/chat-settings-storage.ts @@ -171,10 +171,13 @@ function sanitizeInferenceParams( // re-truncates to the wire cap (4 for OpenAI Chat, 16 for Anthropic, // dropped entirely for OpenAI Responses) before the request hits the // network. Capping to 4 here would defeat Anthropic's UI cap of 16 - // for users who switch providers between sessions. + // for users who switch providers between sessions. An EMPTY array + // must persist as an empty array (not be sanitized away) so the user + // can clear the last chip and have the change saved — otherwise the + // previously stored stops come back on reload. if (Array.isArray(value.stop)) { const stops = value.stop.filter((s): s is string => typeof s === "string"); - if (stops.length > 0) params.stop = stops.slice(0, 16); + params.stop = stops.slice(0, 16); } // serviceTier: nullable enum string. if (value.serviceTier === null) {