fix(studio/responses): forward chat_template_kwargs enable_thinking to chat request (#6202)

* fix(studio/responses): forward chat_template_kwargs enable_thinking to chat request

The /v1/responses translation in _build_chat_request dropped
chat_template_kwargs (e.g. {"enable_thinking": true}) sent via the
Responses extra-body, so reasoning control was silently ignored.
Lift enable_thinking onto the typed ChatCompletionRequest field,
mirroring openai_chat_completions, so both the non-streaming and
streaming Responses pass-through paths honor it.

Fixes #6198

Signed-off-by: Tai An <antai12232931@outlook.com>

* Fix/adjust Responses reasoning for PR #6202

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix/adjust reasoning none for PR #6202

* Fix/adjust structured reasoning for PR #6202

* Fix/adjust responses reasoning review findings for PR #6202

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix/adjust responses reasoning follow-ups for PR #6202

* Fix/adjust think parsing gate for PR #6202

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Tai An 2026-06-12 04:20:07 -07:00 committed by GitHub
commit b72cf0af24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1084 additions and 91 deletions

View file

@ -990,8 +990,10 @@ class LlamaCppBackend:
# enable_thinking / reasoning_effort -- skip.
if self._supports_reasoning and not self._reasoning_always_on:
if self._reasoning_style == "reasoning_effort":
if reasoning_effort in ("low", "medium", "high"):
if reasoning_effort in ("none", "low", "medium", "high"):
kwargs["reasoning_effort"] = reasoning_effort
elif reasoning_effort == "minimal":
kwargs["reasoning_effort"] = "low"
elif enable_thinking is not None:
kwargs["reasoning_effort"] = "high" if enable_thinking else "low"
else:

View file

@ -1331,6 +1331,23 @@ class ResponsesOutputMessage(BaseModel):
content: list[ResponsesOutputTextContent] = Field(default_factory = list)
class ResponsesOutputReasoningContent(BaseModel):
"""A reasoning text content block inside a reasoning output item."""
type: Literal["reasoning_text"] = "reasoning_text"
text: str
class ResponsesOutputReasoning(BaseModel):
"""A top-level reasoning output item in the Responses API response."""
type: Literal["reasoning"] = "reasoning"
id: str = Field(default_factory = lambda: f"rs_{uuid.uuid4().hex[:12]}")
status: Literal["completed", "in_progress", "incomplete"] = "completed"
summary: list = Field(default_factory = list)
content: Optional[list[ResponsesOutputReasoningContent]] = None
class ResponsesOutputFunctionCall(BaseModel):
"""A function-call output item in the Responses API response.
@ -1345,7 +1362,11 @@ class ResponsesOutputFunctionCall(BaseModel):
status: Literal["completed", "in_progress", "incomplete"] = "completed"
ResponsesOutputItem = Union[ResponsesOutputMessage, ResponsesOutputFunctionCall]
ResponsesOutputItem = Union[
ResponsesOutputMessage,
ResponsesOutputReasoning,
ResponsesOutputFunctionCall,
]
class ResponsesUsage(BaseModel):

View file

@ -682,6 +682,8 @@ from models.inference import (
ResponsesFunctionCallOutputInputItem,
ResponsesOutputTextContent,
ResponsesOutputMessage,
ResponsesOutputReasoning,
ResponsesOutputReasoningContent,
ResponsesOutputFunctionCall,
ResponsesUsage,
ResponsesResponse,
@ -5074,6 +5076,149 @@ def _responses_tool_output_text(output: Union[str, list]) -> str:
return "(no output)"
_RESPONSES_THINK_OPEN = "<think>"
_RESPONSES_THINK_CLOSE = "</think>"
_RESPONSES_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "max", "xhigh"}
def _coerce_responses_reasoning_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
return "".join(_coerce_responses_reasoning_text(part) for part in value)
if isinstance(value, dict):
for key in ("text", "reasoning_text", "content"):
text = _coerce_responses_reasoning_text(value.get(key))
if text:
return text
return ""
return json.dumps(value)
def _responses_marker_holdback(text: str, markers: tuple[str, ...]) -> int:
"""Number of trailing chars to retain because they may start a marker."""
for size in range(min(len(text), max(len(m) for m in markers) - 1), 0, -1):
suffix = text[-size:]
if any(marker.startswith(suffix) for marker in markers):
return size
return 0
class _ResponsesReasoningExtractor:
"""Split local <think> markup into Responses reasoning and visible text."""
def __init__(self, *, parse_think_markers: bool = False) -> None:
self._buffer = ""
self._in_reasoning = False
self._parse_think_markers = parse_think_markers
def feed(
self,
text: str = "",
reasoning_content: Any = None,
) -> tuple[str, str]:
reasoning_parts: list[str] = []
visible_parts: list[str] = []
structured_reasoning = _coerce_responses_reasoning_text(reasoning_content)
if structured_reasoning:
reasoning_parts.append(structured_reasoning)
if text:
self._buffer += text
if not self._parse_think_markers:
visible_parts.append(self._buffer)
self._buffer = ""
return "".join(reasoning_parts), "".join(visible_parts)
while self._buffer:
if self._in_reasoning:
close_idx = self._buffer.find(_RESPONSES_THINK_CLOSE)
if close_idx != -1:
reasoning_parts.append(self._buffer[:close_idx])
self._buffer = self._buffer[close_idx + len(_RESPONSES_THINK_CLOSE) :]
self._in_reasoning = False
continue
keep = _responses_marker_holdback(self._buffer, (_RESPONSES_THINK_CLOSE,))
if keep == len(self._buffer):
break
reasoning_parts.append(self._buffer[:-keep] if keep else self._buffer)
self._buffer = self._buffer[-keep:] if keep else ""
break
open_idx = self._buffer.find(_RESPONSES_THINK_OPEN)
close_idx = self._buffer.find(_RESPONSES_THINK_CLOSE)
if close_idx != -1 and (open_idx == -1 or close_idx < open_idx):
visible_parts.append(self._buffer[:close_idx])
self._buffer = self._buffer[close_idx + len(_RESPONSES_THINK_CLOSE) :]
continue
if open_idx != -1:
visible_parts.append(self._buffer[:open_idx])
self._buffer = self._buffer[open_idx + len(_RESPONSES_THINK_OPEN) :]
self._in_reasoning = True
continue
keep = _responses_marker_holdback(
self._buffer,
(_RESPONSES_THINK_OPEN, _RESPONSES_THINK_CLOSE),
)
if keep == len(self._buffer):
break
visible_parts.append(self._buffer[:-keep] if keep else self._buffer)
self._buffer = self._buffer[-keep:] if keep else ""
break
return "".join(reasoning_parts), "".join(visible_parts)
def finish(self) -> tuple[str, str]:
if not self._buffer:
return "", ""
remaining = self._buffer
self._buffer = ""
if not self._parse_think_markers:
return "", remaining
if self._in_reasoning:
self._in_reasoning = False
return remaining, ""
return "", remaining.replace(_RESPONSES_THINK_CLOSE, "")
def _extract_responses_reasoning(
text: str = "",
reasoning_content: Any = None,
*,
parse_think_markers: bool = False,
) -> tuple[str, str]:
extractor = _ResponsesReasoningExtractor(parse_think_markers = parse_think_markers)
reasoning, visible = extractor.feed(text, reasoning_content)
final_reasoning, final_visible = extractor.finish()
return reasoning + final_reasoning, visible + final_visible
def _responses_should_parse_think_markers(
chat_req: ChatCompletionRequest, llama_backend: Any = None
) -> bool:
if llama_backend is not None and getattr(llama_backend, "is_loaded", False):
if getattr(llama_backend, "reasoning_always_on", False):
return True
if not getattr(llama_backend, "supports_reasoning", False):
return False
if chat_req.enable_thinking is True:
return True
return chat_req.enable_thinking is None and chat_req.reasoning_effort not in (None, "none")
def _responses_reasoning_output_item(reasoning_text: str, item_id: Optional[str] = None) -> dict:
kwargs: dict[str, Any] = {
"status": "completed",
"summary": [],
"content": [ResponsesOutputReasoningContent(text = reasoning_text)],
}
if item_id is not None:
kwargs["id"] = item_id
return ResponsesOutputReasoning(**kwargs).model_dump()
def _normalise_responses_input(payload: ResponsesRequest) -> list[ChatMessage]:
"""Convert a ResponsesRequest's ``input`` into a Chat-format ``ChatMessage`` list.
@ -5232,6 +5377,33 @@ def _build_chat_request(
if payload.parallel_tool_calls is not None:
chat_kwargs["parallel_tool_calls"] = payload.parallel_tool_calls
# ``chat_template_kwargs`` (e.g. ``{"enable_thinking": true}``) arrives via
# the Responses extra-body: ResponsesRequest has ``extra="allow"``, so the
# OpenAI SDK's ``extra_body`` spread lands the dict in ``model_extra``. The
# downstream Chat Completions paths consume the typed ``enable_thinking``
# field -- the non-streaming path lifts it in ``openai_chat_completions``
# only when it is still ``None``, and the streaming pass-through reads
# ``payload.enable_thinking`` directly -- so lift it here, mirroring that
# handler, to cover both Responses paths.
explicit_enable_thinking = False
_extra = getattr(payload, "model_extra", None)
if isinstance(_extra, dict):
_tpl_kw = _extra.get("chat_template_kwargs")
if isinstance(_tpl_kw, dict) and "enable_thinking" in _tpl_kw:
chat_kwargs["enable_thinking"] = bool(_tpl_kw["enable_thinking"])
explicit_enable_thinking = True
if isinstance(payload.reasoning, dict):
effort = payload.reasoning.get("effort")
if isinstance(effort, str) and effort in _RESPONSES_REASONING_EFFORTS:
if not explicit_enable_thinking:
chat_kwargs["reasoning_effort"] = effort
chat_kwargs["enable_thinking"] = effort != "none"
elif chat_kwargs.get("enable_thinking") is False:
chat_kwargs["reasoning_effort"] = "none"
elif effort != "none":
chat_kwargs["reasoning_effort"] = effort
return ChatCompletionRequest(**chat_kwargs)
@ -5275,10 +5447,18 @@ async def _responses_non_streaming(
choices = body.get("choices", [])
text = ""
reasoning_text = ""
tool_calls: list[dict] = []
if choices:
msg = choices[0].get("message", {}) or {}
text = msg.get("content", "") or ""
raw_content = msg.get("content", "") or ""
raw_text = raw_content if isinstance(raw_content, str) else json.dumps(raw_content)
llama_backend = get_llama_cpp_backend()
reasoning_text, text = _extract_responses_reasoning(
raw_text,
msg.get("reasoning_content"),
parse_think_markers = _responses_should_parse_think_markers(chat_req, llama_backend),
)
tool_calls = msg.get("tool_calls") or []
usage_data = body.get("usage", {})
@ -5292,6 +5472,10 @@ async def _responses_non_streaming(
# the model produced content, so clients expecting a pure tool-call turn
# (finish_reason="tool_calls") don't see a spurious empty message item.
output_items: list[dict] = []
if reasoning_text and not text and not tool_calls:
text = reasoning_text
if reasoning_text:
output_items.append(_responses_reasoning_output_item(reasoning_text))
if text:
msg_id = f"msg_{uuid.uuid4().hex[:12]}"
output_items.append(
@ -5339,16 +5523,15 @@ async def _responses_stream(
avoids that. Non-GGUF falls back to the wrapper (which doesn't use httpx, so
the issue doesn't apply).
Text deltas arrive as ``response.output_text.delta`` on a single
``message`` output item at ``output_index=0``. Each tool call from
Output items are allocated as upstream deltas appear. Reasoning/text deltas
open top-level ``reasoning`` / ``message`` items; each tool call from
``delta.tool_calls[]`` is promoted to its own top-level ``function_call``
output item (one per distinct ``tool_calls[].index``) and relayed as
item (one per distinct ``tool_calls[].index``) and relayed as
``response.function_call_arguments.delta`` / ``.done`` events so clients
(Codex, OpenAI Python SDK) can reconstruct the call incrementally and reply
with a ``function_call_output`` item next turn.
"""
resp_id = f"resp_{uuid.uuid4().hex[:12]}"
msg_id = f"msg_{uuid.uuid4().hex[:12]}"
created_at = int(time.time())
chat_req = _build_chat_request(payload, messages, stream = True)
@ -5388,61 +5571,166 @@ async def _responses_stream(
async def event_generator():
full_text = ""
full_reasoning = ""
input_tokens = 0
output_tokens = 0
extractor = _ResponsesReasoningExtractor(
parse_think_markers = _responses_should_parse_think_markers(chat_req, llama_backend)
)
reasoning_state: dict[str, Any] = {"output_index": None, "item_id": None, "opened": False}
message_state: dict[str, Any] = {"output_index": None, "item_id": None, "opened": False}
# Per-tool-call state keyed by Chat Completions `tool_calls[].index`,
# stable across chunks for the same call. Values:
# {output_index, item_id, call_id, name, arguments, opened}
tool_call_state: dict[int, dict] = {}
# Text message lives at output_index 0; tool calls claim 1, 2, ...
next_output_index = 1
next_output_index = 0
def _sse(event_name: str, payload: dict) -> str:
return f"event: {event_name}\ndata: {json.dumps(payload)}\n\n"
def _claim_output_index() -> int:
nonlocal next_output_index
output_index = next_output_index
next_output_index += 1
return output_index
def _ensure_reasoning_open() -> list[str]:
if reasoning_state["opened"]:
return []
reasoning_state["output_index"] = _claim_output_index()
reasoning_state["item_id"] = f"rs_{uuid.uuid4().hex[:12]}"
reasoning_state["opened"] = True
output_index = reasoning_state["output_index"]
item_id = reasoning_state["item_id"]
return [
_sse(
"response.output_item.added",
{
"type": "response.output_item.added",
"output_index": output_index,
"item": {
"type": "reasoning",
"id": item_id,
"status": "in_progress",
"summary": [],
"content": [],
},
},
),
_sse(
"response.content_part.added",
{
"type": "response.content_part.added",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": {"type": "reasoning_text", "text": ""},
},
),
]
def _ensure_message_open() -> list[str]:
if message_state["opened"]:
return []
message_state["output_index"] = _claim_output_index()
message_state["item_id"] = f"msg_{uuid.uuid4().hex[:12]}"
message_state["opened"] = True
output_index = message_state["output_index"]
item_id = message_state["item_id"]
return [
_sse(
"response.output_item.added",
{
"type": "response.output_item.added",
"output_index": output_index,
"item": {
"type": "message",
"id": item_id,
"status": "in_progress",
"role": "assistant",
"content": [],
},
},
),
_sse(
"response.content_part.added",
{
"type": "response.content_part.added",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": {"type": "output_text", "text": "", "annotations": []},
},
),
]
def _snapshot_output() -> list[dict]:
"""Snapshot of all completed output items for response.completed."""
items: list[dict] = [
{
"type": "message",
"id": msg_id,
"status": "completed",
"role": "assistant",
"content": [
indexed_items: list[tuple[int, dict]] = []
if reasoning_state["opened"]:
indexed_items.append(
(
reasoning_state["output_index"],
{
"type": "output_text",
"text": full_text,
"annotations": [],
}
],
}
]
for st in sorted(tool_call_state.values(), key = lambda s: s["output_index"]):
items.append(
{
"type": "function_call",
"id": st["item_id"],
"status": "completed",
"call_id": st["call_id"],
"name": st["name"],
"arguments": st["arguments"],
}
"type": "reasoning",
"id": reasoning_state["item_id"],
"status": "completed",
"summary": [],
"content": [{"type": "reasoning_text", "text": full_reasoning}],
},
)
)
return items
if message_state["opened"]:
indexed_items.append(
(
message_state["output_index"],
{
"type": "message",
"id": message_state["item_id"],
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": full_text,
"annotations": [],
}
],
},
)
)
for st in tool_call_state.values():
indexed_items.append(
(
st["output_index"],
{
"type": "function_call",
"id": st["item_id"],
"status": "completed",
"call_id": st["call_id"],
"name": st["name"],
"arguments": st["arguments"],
},
)
)
return [item for _, item in sorted(indexed_items, key = lambda pair: pair[0])]
# ── Preamble events ──
yield f"event: response.created\ndata: {json.dumps({'type': 'response.created', 'response': {'id': resp_id, 'object': 'response', 'created_at': created_at, 'status': 'in_progress', 'model': payload.model, 'output': [], 'usage': {'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0}}})}\n\n"
# output_item.added (text message at output_index 0)
output_item = {
"type": "message",
"id": msg_id,
"status": "in_progress",
"role": "assistant",
"content": [],
}
yield f"event: response.output_item.added\ndata: {json.dumps({'type': 'response.output_item.added', 'output_index': 0, 'item': output_item})}\n\n"
# content_part.added
content_part = {"type": "output_text", "text": "", "annotations": []}
yield f"event: response.content_part.added\ndata: {json.dumps({'type': 'response.content_part.added', 'item_id': msg_id, 'output_index': 0, 'content_index': 0, 'part': content_part})}\n\n"
yield _sse(
"response.created",
{
"type": "response.created",
"response": {
"id": resp_id,
"object": "response",
"created_at": created_at,
"status": "in_progress",
"model": payload.model,
"output": [],
"usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
},
},
)
# ── Direct httpx lifecycle to llama-server ──
# Full same-task open + close, same pattern as
@ -5459,7 +5747,21 @@ async def _responses_stream(
resp = await client.send(req, stream = True)
except httpx.RequestError as e:
logger.error("responses stream: upstream unreachable: %s", e)
yield f"event: response.failed\ndata: {json.dumps({'type': 'response.failed', 'response': {'id': resp_id, 'object': 'response', 'created_at': created_at, 'status': 'failed', 'model': payload.model, 'output': [], 'error': {'code': 502, 'message': _friendly_error(e)}}})}\n\n"
yield _sse(
"response.failed",
{
"type": "response.failed",
"response": {
"id": resp_id,
"object": "response",
"created_at": created_at,
"status": "failed",
"model": payload.model,
"output": [],
"error": {"code": 502, "message": _friendly_error(e)},
},
},
)
return
if resp.status_code != 200:
@ -5470,7 +5772,24 @@ async def _responses_stream(
resp.status_code,
err_text[:500],
)
yield f"event: response.failed\ndata: {json.dumps({'type': 'response.failed', 'response': {'id': resp_id, 'object': 'response', 'created_at': created_at, 'status': 'failed', 'model': payload.model, 'output': [], 'error': {'code': resp.status_code, 'message': f'llama-server error: {err_text[:500]}'}}})}\n\n"
yield _sse(
"response.failed",
{
"type": "response.failed",
"response": {
"id": resp_id,
"object": "response",
"created_at": created_at,
"status": "failed",
"model": payload.model,
"output": [],
"error": {
"code": resp.status_code,
"message": f"llama-server error: {err_text[:500]}",
},
},
},
)
return
lines_iter = resp.aiter_lines()
@ -5500,17 +5819,38 @@ async def _responses_stream(
continue
delta = choices[0].get("delta", {}) or {}
content = delta.get("content")
if content:
full_text += content
delta_event = {
"type": "response.output_text.delta",
"item_id": msg_id,
"output_index": 0,
"content_index": 0,
"delta": content,
}
yield f"event: response.output_text.delta\ndata: {json.dumps(delta_event)}\n\n"
reasoning_delta, visible_delta = extractor.feed(
delta.get("content") or "",
delta.get("reasoning_content"),
)
if reasoning_delta:
for event in _ensure_reasoning_open():
yield event
full_reasoning += reasoning_delta
yield _sse(
"response.reasoning_text.delta",
{
"type": "response.reasoning_text.delta",
"item_id": reasoning_state["item_id"],
"output_index": reasoning_state["output_index"],
"content_index": 0,
"delta": reasoning_delta,
},
)
if visible_delta:
for event in _ensure_message_open():
yield event
full_text += visible_delta
yield _sse(
"response.output_text.delta",
{
"type": "response.output_text.delta",
"item_id": message_state["item_id"],
"output_index": message_state["output_index"],
"content_index": 0,
"delta": visible_delta,
},
)
for tc in delta.get("tool_calls") or []:
idx = tc.get("index", 0)
@ -5520,14 +5860,13 @@ async def _responses_stream(
# First chunk for this tool call -- allocate an
# output_index and emit output_item.added.
st = {
"output_index": next_output_index,
"output_index": _claim_output_index(),
"item_id": f"fc_{uuid.uuid4().hex[:12]}",
"call_id": tc.get("id") or "",
"name": fn.get("name") or "",
"arguments": "",
"opened": False,
}
next_output_index += 1
tool_call_state[idx] = st
else:
# Later chunks sometimes carry id/name only once; merge
@ -5550,7 +5889,7 @@ async def _responses_stream(
"arguments": "",
},
}
yield f"event: response.output_item.added\ndata: {json.dumps(item_added)}\n\n"
yield _sse("response.output_item.added", item_added)
st["opened"] = True
arg_delta = fn.get("arguments") or ""
@ -5562,7 +5901,7 @@ async def _responses_stream(
"output_index": st["output_index"],
"delta": arg_delta,
}
yield f"event: response.function_call_arguments.delta\ndata: {json.dumps(args_delta_event)}\n\n"
yield _sse("response.function_call_arguments.delta", args_delta_event)
elif arg_delta:
# Buffer args until we can open the item (some models
# send id/name in the same chunk as the first arg delta;
@ -5591,8 +5930,134 @@ async def _responses_stream(
except Exception:
pass
# ── Closing events for tool calls ──
for st in sorted(tool_call_state.values(), key = lambda s: s["output_index"]):
final_reasoning, final_visible = extractor.finish()
if final_reasoning:
for event in _ensure_reasoning_open():
yield event
full_reasoning += final_reasoning
yield _sse(
"response.reasoning_text.delta",
{
"type": "response.reasoning_text.delta",
"item_id": reasoning_state["item_id"],
"output_index": reasoning_state["output_index"],
"content_index": 0,
"delta": final_reasoning,
},
)
if final_visible:
for event in _ensure_message_open():
yield event
full_text += final_visible
yield _sse(
"response.output_text.delta",
{
"type": "response.output_text.delta",
"item_id": message_state["item_id"],
"output_index": message_state["output_index"],
"content_index": 0,
"delta": final_visible,
},
)
if full_reasoning and not full_text and not tool_call_state:
for event in _ensure_message_open():
yield event
full_text = full_reasoning
yield _sse(
"response.output_text.delta",
{
"type": "response.output_text.delta",
"item_id": message_state["item_id"],
"output_index": message_state["output_index"],
"content_index": 0,
"delta": full_text,
},
)
close_items: list[tuple[int, str, dict[str, Any]]] = []
if reasoning_state["opened"]:
close_items.append((reasoning_state["output_index"], "reasoning", reasoning_state))
if message_state["opened"]:
close_items.append((message_state["output_index"], "message", message_state))
close_items.extend((st["output_index"], "tool", st) for st in tool_call_state.values())
for _, kind, st in sorted(close_items, key = lambda item: item[0]):
if kind == "reasoning":
yield _sse(
"response.reasoning_text.done",
{
"type": "response.reasoning_text.done",
"item_id": st["item_id"],
"output_index": st["output_index"],
"content_index": 0,
"text": full_reasoning,
},
)
yield _sse(
"response.content_part.done",
{
"type": "response.content_part.done",
"item_id": st["item_id"],
"output_index": st["output_index"],
"content_index": 0,
"part": {"type": "reasoning_text", "text": full_reasoning},
},
)
yield _sse(
"response.output_item.done",
{
"type": "response.output_item.done",
"output_index": st["output_index"],
"item": {
"type": "reasoning",
"id": st["item_id"],
"status": "completed",
"summary": [],
"content": [{"type": "reasoning_text", "text": full_reasoning}],
},
},
)
continue
if kind == "message":
yield _sse(
"response.output_text.done",
{
"type": "response.output_text.done",
"item_id": st["item_id"],
"output_index": st["output_index"],
"content_index": 0,
"text": full_text,
},
)
yield _sse(
"response.content_part.done",
{
"type": "response.content_part.done",
"item_id": st["item_id"],
"output_index": st["output_index"],
"content_index": 0,
"part": {"type": "output_text", "text": full_text, "annotations": []},
},
)
yield _sse(
"response.output_item.done",
{
"type": "response.output_item.done",
"output_index": st["output_index"],
"item": {
"type": "message",
"id": st["item_id"],
"status": "completed",
"role": "assistant",
"content": [
{"type": "output_text", "text": full_text, "annotations": []}
],
},
},
)
continue
# If id/name never arrived (malformed upstream), synthesise so the
# client still sees a coherent frame sequence.
if not st["opened"]:
@ -5610,20 +6075,16 @@ async def _responses_stream(
"arguments": "",
},
}
yield f"event: response.output_item.added\ndata: {json.dumps(item_added)}\n\n"
yield _sse("response.output_item.added", item_added)
if st["arguments"]:
yield (
"event: response.function_call_arguments.delta\n"
"data: "
+ json.dumps(
{
"type": "response.function_call_arguments.delta",
"item_id": st["item_id"],
"output_index": st["output_index"],
"delta": st["arguments"],
}
)
+ "\n\n"
yield _sse(
"response.function_call_arguments.delta",
{
"type": "response.function_call_arguments.delta",
"item_id": st["item_id"],
"output_index": st["output_index"],
"delta": st["arguments"],
},
)
st["opened"] = True
@ -5634,7 +6095,7 @@ async def _responses_stream(
"name": st["name"],
"arguments": st["arguments"],
}
yield f"event: response.function_call_arguments.done\ndata: {json.dumps(args_done)}\n\n"
yield _sse("response.function_call_arguments.done", args_done)
item_done = {
"type": "response.output_item.done",
@ -5648,14 +6109,7 @@ async def _responses_stream(
"arguments": st["arguments"],
},
}
yield f"event: response.output_item.done\ndata: {json.dumps(item_done)}\n\n"
# ── Closing events for text message ──
yield f"event: response.output_text.done\ndata: {json.dumps({'type': 'response.output_text.done', 'item_id': msg_id, 'output_index': 0, 'content_index': 0, 'text': full_text})}\n\n"
yield f"event: response.content_part.done\ndata: {json.dumps({'type': 'response.content_part.done', 'item_id': msg_id, 'output_index': 0, 'content_index': 0, 'part': {'type': 'output_text', 'text': full_text, 'annotations': []}})}\n\n"
yield f"event: response.output_item.done\ndata: {json.dumps({'type': 'response.output_item.done', 'output_index': 0, 'item': {'type': 'message', 'id': msg_id, 'status': 'completed', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': full_text, 'annotations': []}]}})}\n\n"
yield _sse("response.output_item.done", item_done)
# response.completed
total_tokens = input_tokens + output_tokens
@ -5675,7 +6129,7 @@ async def _responses_stream(
},
},
}
yield f"event: response.completed\ndata: {json.dumps(completed_response)}\n\n"
yield _sse("response.completed", completed_response)
return StreamingResponse(
event_generator(),

View file

@ -28,11 +28,13 @@ from models.inference import (
ChatMessage,
CompletionChoice,
CompletionMessage,
ResponsesRequest,
)
from core.inference.anthropic_compat import (
anthropic_tool_choice_to_openai,
)
from routes.inference import (
_build_chat_request,
_build_openai_passthrough_body,
_build_passthrough_payload,
_clamp_finish_reason,
@ -786,6 +788,22 @@ class TestPassthroughReasoningKwargs:
)
assert body["chat_template_kwargs"] == {"reasoning_effort": "high"}
def test_reasoning_effort_none_forwarded_for_effort_style_models(self):
body = _build_openai_passthrough_body(
self._payload(enable_thinking = False, reasoning_effort = "none"),
backend_ctx = 4096,
llama_backend = _reasoning_backend(reasoning_style = "reasoning_effort"),
)
assert body["chat_template_kwargs"] == {"reasoning_effort": "none"}
def test_reasoning_effort_minimal_maps_to_low_for_effort_style_models(self):
body = _build_openai_passthrough_body(
self._payload(enable_thinking = True, reasoning_effort = "minimal"),
backend_ctx = 4096,
llama_backend = _reasoning_backend(reasoning_style = "reasoning_effort"),
)
assert body["chat_template_kwargs"] == {"reasoning_effort": "low"}
def test_enable_thinking_maps_to_effort_for_effort_style_models(self):
body = _build_openai_passthrough_body(
self._payload(enable_thinking = False),
@ -1396,3 +1414,47 @@ class TestGgufVisionToolRouting:
assert seen_seeds == expected
assert [choice["index"] for choice in body["choices"]] == [0, 1, 2]
# =====================================================================
# Responses API -> Chat Completions translation: chat_template_kwargs
# (e.g. {"enable_thinking": true}) sent via the Responses extra-body must
# reach the built ChatCompletionRequest's typed ``enable_thinking`` field,
# otherwise /v1/responses silently ignores reasoning control (issue #6198).
# =====================================================================
class TestResponsesChatTemplateKwargs:
_messages = [ChatMessage(role = "user", content = "What is 100 - 67?")]
def test_enable_thinking_lifted_from_extra_body(self):
payload = ResponsesRequest(
model = "qwen-local",
input = "What is 100 - 67?",
chat_template_kwargs = {"enable_thinking": True},
)
chat_req = _build_chat_request(payload, self._messages, stream = False)
assert chat_req.enable_thinking is True
def test_enable_thinking_false_lifted_from_extra_body(self):
payload = ResponsesRequest(
model = "qwen-local",
input = "hi",
chat_template_kwargs = {"enable_thinking": False},
)
chat_req = _build_chat_request(payload, self._messages, stream = True)
assert chat_req.enable_thinking is False
def test_no_chat_template_kwargs_leaves_enable_thinking_unset(self):
payload = ResponsesRequest(model = "qwen-local", input = "hi")
chat_req = _build_chat_request(payload, self._messages, stream = False)
assert chat_req.enable_thinking is None
def test_chat_template_kwargs_without_enable_thinking_is_ignored(self):
payload = ResponsesRequest(
model = "qwen-local",
input = "hi",
chat_template_kwargs = {"some_other_flag": True},
)
chat_req = _build_chat_request(payload, self._messages, stream = False)
assert chat_req.enable_thinking is None

View file

@ -36,6 +36,7 @@ import json
import httpx
import pytest
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from models.inference import (
@ -46,6 +47,7 @@ from models.inference import (
ResponsesInputMessage,
ResponsesOutputFunctionCall,
ResponsesOutputMessage,
ResponsesOutputReasoning,
ResponsesOutputTextContent,
ResponsesOutputTextPart,
ResponsesRequest,
@ -59,6 +61,7 @@ from routes.inference import (
_chat_tool_calls_to_responses_output,
_normalise_responses_input,
_responses_tool_output_text,
_responses_non_streaming,
_responses_stream,
_translate_responses_tool_choice_to_chat,
_translate_responses_tools_to_chat,
@ -284,6 +287,59 @@ class TestBuildChatRequest:
assert chat_req.parallel_tool_calls is False
def test_chat_template_kwargs_enable_thinking_true_is_lifted(self):
payload = ResponsesRequest(
input = "hi",
chat_template_kwargs = {"enable_thinking": True},
)
messages = [ChatMessage(role = "user", content = "hi")]
chat_req = _build_chat_request(payload, messages, stream = False)
assert chat_req.enable_thinking is True
def test_chat_template_kwargs_enable_thinking_false_is_lifted(self):
payload = ResponsesRequest(
input = "hi",
chat_template_kwargs = {"enable_thinking": False},
)
messages = [ChatMessage(role = "user", content = "hi")]
chat_req = _build_chat_request(payload, messages, stream = False)
assert chat_req.enable_thinking is False
def test_reasoning_effort_high_enables_local_thinking(self):
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "high"})
messages = [ChatMessage(role = "user", content = "hi")]
chat_req = _build_chat_request(payload, messages, stream = False)
assert chat_req.reasoning_effort == "high"
assert chat_req.enable_thinking is True
def test_reasoning_effort_none_disables_local_thinking(self):
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "none"})
messages = [ChatMessage(role = "user", content = "hi")]
chat_req = _build_chat_request(payload, messages, stream = False)
assert chat_req.reasoning_effort == "none"
assert chat_req.enable_thinking is False
def test_explicit_enable_thinking_false_disables_reasoning_effort(self):
payload = ResponsesRequest(
input = "hi",
reasoning = {"effort": "high"},
chat_template_kwargs = {"enable_thinking": False},
)
messages = [ChatMessage(role = "user", content = "hi")]
chat_req = _build_chat_request(payload, messages, stream = False)
assert chat_req.reasoning_effort == "none"
assert chat_req.enable_thinking is False
# =====================================================================
# _normalise_responses_input — multi-turn tool mapping
@ -544,6 +600,119 @@ class TestChatToolCallsToResponsesOutput:
assert items[0]["arguments"] == ""
# =====================================================================
# Non-streaming Responses adapter
# =====================================================================
class TestResponsesNonStreamingAdapter:
class _Request:
pass
@staticmethod
def _run_with_message(
monkeypatch,
message,
payload = None,
llama_backend = None,
):
import routes.inference as inf_mod
async def fake_chat_completions(chat_req, request):
return JSONResponse(
content = {
"model": "test-model",
"choices": [{"message": message}],
"usage": {"prompt_tokens": 2, "completion_tokens": 3},
}
)
monkeypatch.setattr(inf_mod, "openai_chat_completions", fake_chat_completions)
if llama_backend is not None:
monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: llama_backend)
payload = payload or ResponsesRequest(input = "hi")
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_non_streaming(
payload, messages, TestResponsesNonStreamingAdapter._Request()
)
return json.loads(response.body.decode())
return asyncio.run(run())
def test_think_block_becomes_reasoning_item_before_message(self, monkeypatch):
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "high"})
body = self._run_with_message(
monkeypatch,
{"content": "<think>plan</think>33"},
payload = payload,
)
assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
assert body["output"][0]["content"] == [{"type": "reasoning_text", "text": "plan"}]
assert body["output"][0]["summary"] == []
assert body["output"][1]["content"][0]["text"] == "33"
assert "<think>" not in body["output"][1]["content"][0]["text"]
assert "</think>" not in body["output"][1]["content"][0]["text"]
def test_literal_think_tags_remain_visible_without_reasoning_request(self, monkeypatch):
body = self._run_with_message(monkeypatch, {"content": "show <think>x</think> tags"})
assert [item["type"] for item in body["output"]] == ["message"]
assert body["output"][0]["content"][0]["text"] == "show <think>x</think> tags"
def test_non_reasoning_gguf_keeps_literal_think_tags_visible(self, monkeypatch):
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "high"})
body = self._run_with_message(
monkeypatch,
{"content": "show <think>x</think> tags"},
payload = payload,
llama_backend = SimpleNamespace(
is_loaded = True,
reasoning_always_on = False,
supports_reasoning = False,
),
)
assert [item["type"] for item in body["output"]] == ["message"]
assert body["output"][0]["content"][0]["text"] == "show <think>x</think> tags"
def test_structured_reasoning_content_extracts_text_parts(self, monkeypatch):
body = self._run_with_message(
monkeypatch,
{
"content": "33",
"reasoning_content": [
{"type": "reasoning_text", "text": "plan"},
{"type": "reasoning_text", "text": " next"},
],
},
)
assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
assert body["output"][0]["content"] == [{"type": "reasoning_text", "text": "plan next"}]
assert body["output"][1]["content"][0]["text"] == "33"
def test_plain_content_remains_message_only(self, monkeypatch):
body = self._run_with_message(monkeypatch, {"content": "33"})
assert [item["type"] for item in body["output"]] == ["message"]
assert body["output"][0]["content"][0]["text"] == "33"
def test_reasoning_only_is_also_visible_message_text(self, monkeypatch):
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "high"})
body = self._run_with_message(
monkeypatch,
{"content": "<think>plan</think>"},
payload = payload,
)
assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
assert body["output"][0]["content"][0]["text"] == "plan"
assert body["output"][1]["content"][0]["text"] == "plan"
# =====================================================================
# Streaming Responses adapter
# =====================================================================
@ -570,6 +739,262 @@ class TestResponsesStreamAdapter:
if line.startswith(prefix)
]
@staticmethod
def _install_stream_mock(
monkeypatch,
chunks,
*,
supports_reasoning = True,
reasoning_always_on = False,
):
import routes.inference as inf_mod
def handler(request: httpx.Request) -> httpx.Response:
content = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks)
content += "data: [DONE]\n\n"
return httpx.Response(
200,
content = content.encode(),
headers = {"content-type": "text/event-stream"},
)
transport = httpx.MockTransport(handler)
real_async_client = httpx.AsyncClient
def _client(*args, **kwargs):
return real_async_client(
transport = transport,
timeout = kwargs.get("timeout", 600),
)
monkeypatch.setattr(inf_mod.httpx, "AsyncClient", _client)
monkeypatch.setattr(
inf_mod,
"get_llama_cpp_backend",
lambda: SimpleNamespace(
is_loaded = True,
is_vision = False,
context_length = 4096,
base_url = "http://llama.test",
supports_reasoning = supports_reasoning,
reasoning_always_on = reasoning_always_on,
_request_reasoning_kwargs = (
lambda enable_thinking = None, reasoning_effort = None, preserve_thinking = None: None
),
),
)
def test_split_think_markers_stream_as_reasoning_and_visible_text(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "<thi"}}]},
{"choices": [{"delta": {"content": "nk>pla"}}]},
{"choices": [{"delta": {"content": "n</th"}}]},
{"choices": [{"delta": {"content": "ink>33"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
payload = ResponsesRequest(input = "hi", stream = True, reasoning = {"effort": "high"})
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(payload, messages, self._Request())
return await self._collect(response)
lines = asyncio.run(run())
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
assert "".join(event["delta"] for event in text_deltas) == "33"
completed = self._payloads(lines, "response.completed")[0]
assert [item["type"] for item in completed["response"]["output"]] == [
"reasoning",
"message",
]
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
assert completed["response"]["output"][1]["content"][0]["text"] == "33"
def test_literal_think_tags_stream_as_visible_text_without_reasoning_request(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "show <thi"}}]},
{"choices": [{"delta": {"content": "nk>x</think> tags"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(payload, messages, self._Request())
return await self._collect(response)
lines = asyncio.run(run())
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert reasoning_deltas == []
assert "".join(event["delta"] for event in text_deltas) == "show <think>x</think> tags"
completed = self._payloads(lines, "response.completed")[0]
assert [item["type"] for item in completed["response"]["output"]] == ["message"]
assert completed["response"]["output"][0]["content"][0]["text"] == (
"show <think>x</think> tags"
)
def test_non_reasoning_gguf_stream_keeps_literal_think_tags_visible(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "show <thi"}}]},
{"choices": [{"delta": {"content": "nk>x</think> tags"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks, supports_reasoning = False)
payload = ResponsesRequest(input = "hi", stream = True, reasoning = {"effort": "high"})
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(payload, messages, self._Request())
return await self._collect(response)
lines = asyncio.run(run())
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert reasoning_deltas == []
assert "".join(event["delta"] for event in text_deltas) == "show <think>x</think> tags"
completed = self._payloads(lines, "response.completed")[0]
assert [item["type"] for item in completed["response"]["output"]] == ["message"]
assert completed["response"]["output"][0]["content"][0]["text"] == (
"show <think>x</think> tags"
)
def test_reasoning_only_streams_as_visible_message_text(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "<think>plan</think>"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
payload = ResponsesRequest(input = "hi", stream = True, reasoning = {"effort": "high"})
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(payload, messages, self._Request())
return await self._collect(response)
lines = asyncio.run(run())
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
assert "".join(event["delta"] for event in text_deltas) == "plan"
completed = self._payloads(lines, "response.completed")[0]
assert [item["type"] for item in completed["response"]["output"]] == [
"reasoning",
"message",
]
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
assert completed["response"]["output"][1]["content"][0]["text"] == "plan"
def test_structured_reasoning_content_streams_as_reasoning(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"reasoning_content": "plan"}}]},
{"choices": [{"delta": {"content": "33"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(payload, messages, self._Request())
return await self._collect(response)
lines = asyncio.run(run())
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
assert "".join(event["delta"] for event in text_deltas) == "33"
completed = self._payloads(lines, "response.completed")[0]
assert completed["response"]["output"][0]["type"] == "reasoning"
assert completed["response"]["output"][1]["type"] == "message"
def test_structured_reasoning_content_parts_stream_as_reasoning(self, monkeypatch):
chunks = [
{
"choices": [
{
"delta": {
"reasoning_content": {
"content": [
{"type": "reasoning_text", "text": "plan"},
{"type": "reasoning_text", "text": " next"},
]
}
}
}
]
},
{"choices": [{"delta": {"content": "33"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(payload, messages, self._Request())
return await self._collect(response)
lines = asyncio.run(run())
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert "".join(event["delta"] for event in reasoning_deltas) == "plan next"
assert "".join(event["delta"] for event in text_deltas) == "33"
assert "reasoning_text" not in "".join(event["delta"] for event in reasoning_deltas)
completed = self._payloads(lines, "response.completed")[0]
assert completed["response"]["output"][0]["content"][0]["text"] == "plan next"
assert completed["response"]["output"][1]["content"][0]["text"] == "33"
def test_tool_first_stream_closes_items_in_output_index_order(self, monkeypatch):
chunks = [
{
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_0",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}
]
}
}
]
},
{"choices": [{"delta": {"content": "done"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(payload, messages, self._Request())
return await self._collect(response)
lines = asyncio.run(run())
done_events = self._payloads(lines, "response.output_item.done")
assert [event["output_index"] for event in done_events] == [0, 1]
assert [event["item"]["type"] for event in done_events] == ["function_call", "message"]
completed = self._payloads(lines, "response.completed")[0]
assert [item["type"] for item in completed["response"]["output"]] == [
"function_call",
"message",
]
def test_requests_usage_and_caps_parallel_tool_calls(self, monkeypatch):
import routes.inference as inf_mod
@ -678,6 +1103,15 @@ class TestResponsesStreamAdapter:
class TestResponsesOutputFunctionCall:
def test_reasoning_output_item_serialises_full_reasoning_content(self):
item = ResponsesOutputReasoning(content = [{"type": "reasoning_text", "text": "plan"}])
d = item.model_dump()
assert d["type"] == "reasoning"
assert d["id"].startswith("rs_")
assert d["status"] == "completed"
assert d["summary"] == []
assert d["content"] == [{"type": "reasoning_text", "text": "plan"}]
def test_direct_construction(self):
fc = ResponsesOutputFunctionCall(
call_id = "call_1",
@ -778,6 +1212,26 @@ class TestCodexStyleRequestShapes:
assert len(req.input) == 3
assert isinstance(req.input[1], ResponsesUnknownInputItem)
def test_emitted_reasoning_item_replay_is_dropped_for_local_chat(self):
payload = ResponsesRequest(
input = [
{"role": "user", "content": "Hi"},
{
"type": "reasoning",
"id": "rs_1",
"summary": [],
"content": [{"type": "reasoning_text", "text": "plan"}],
},
{"role": "assistant", "content": "33"},
{"role": "user", "content": "Continue"},
],
)
msgs = _normalise_responses_input(payload)
assert [m.role for m in msgs] == ["user", "assistant", "user"]
assert all("plan" not in (m.content or "") for m in msgs if isinstance(m.content, str))
def test_unknown_content_part_type_accepted(self):
"""Unknown content-part types (e.g. future input_audio) validate as
ResponsesUnknownContentPart so the request doesn't 422."""