Studio: forward parallel_tool_calls through /v1/responses bridge

Round 3 reviewer feedback:

- studio/backend/routes/inference.py: _build_chat_request (the
  /v1/responses → /v1/chat/completions translator) was dropping
  parallel_tool_calls on the floor. A Responses-API caller that set
  `parallel_tool_calls=false` saw the flag accepted at the schema
  layer but never reach llama-server because the translated
  ChatCompletionRequest had no first-class field for it. Now that
  parallel_tool_calls IS a first-class field on ChatCompletionRequest
  (added by this PR's earlier commits), translate it through the
  bridge so the preference actually fires.

- studio/frontend/src/features/chat/utils/chat-settings-storage.ts:
  the stop sanitizer silently dropped `stop: []` instead of persisting
  the empty array. That meant a user could not clear the last chip —
  on reload, the previously-persisted stops came back. Persist empty
  arrays explicitly so the cleared state round-trips.

- studio/backend/tests/test_sampling_params_routing.py: pin both with
  the raw reproductions reviewers cited.
This commit is contained in:
Daniel Han 2026-05-24 13:57:46 +00:00
commit b8cef29b50
3 changed files with 59 additions and 9 deletions

View file

@ -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]:

View file

@ -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 ────────────

View file

@ -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) {