unsloth/studio/backend/tests/test_responses_api.py
Nilay 43bf5ea81c
Studio: Forward thinking controls and return reasoning on /v1/messages and /v1/responses (#9107)
* Forward thinking and reasoning controls on /v1/messages

* Accept Anthropic adaptive thinking types instead of rejecting them

* Emit reasoning_content as an Anthropic thinking block

* Forward and return reasoning on /v1/responses

* Accept replayed thinking blocks in /v1/messages input

* Remove stray Modal test harness from the PR

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

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

* Restrict thinking blocks to assistant turns; keep reply text verbatim in think split

* Gate think-tag parsing on reasoning capability and request controls

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

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

* Sign streamed thinking starts, replay preserved thinking, unify effort mapping

* Parse only the leading think block; count tokens with the request's reasoning kwargs

* Count streamed prompts with resolved reasoning kwargs; gate parsing on effective template mode

* Track reasoning provenance so literal leading think tags stay text

* Record wrap lengths in reasoning provenance so embedded close tags stay in the trace

* Skip provenance for suppressed forced-retry iterations

* Gate passthrough reasoning on the effective thinking mode

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

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

* Close reconstructed reasoning before same-chunk output

* Fix Anthropic passthrough token-count test double

* Protect the recorded reasoning span from the display tool-XML strip

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

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

* Never open a thinking block for a blank reasoning trace

Qwen3-style templates render "<think>\n\n</think>" on every reply when
thinking is switched off, and llama-server reports that as reasoning_content,
so the streaming emitter opened a thinking block holding nothing but newlines
on ordinary answers. The non-streaming reducer already dropped those, so the
two paths disagreed about the same model output: streaming produced a leading
empty thinking block, non-streaming produced only the text.

Hold the leading whitespace run of a thinking span and open the block on the
first real reasoning character, emitting the held run with it so a trace that
merely starts with a newline is still delivered verbatim.

Adds the grammar guard the suite was missing: assert_anthropic_stream_conformant
checks the whole Anthropic streaming contract (event order, one open block at a
time, indices gapless from 0, every block closed exactly once, message_delta
with stop_reason and usage before message_stop). It runs over a shape by
chunking matrix, over every two- and three-way split position of "<think>" and
"</think>", and over the tool interleavings. A further set drives the real
generator against a fake llama-server stream so the provenance ledger and the
emitter are checked together on the bytes llama-server actually sends.

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

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

* Bind a reasoning wrap to the turn that produced it

The streamed emitter consumes the provenance ledger while it is still
growing, so it only ever sees the wraps recorded up to the event it is
feeding. The non-streaming reducer runs after the drain and walked the
final aggregate by block order, so in a tool loop an earlier turn's
literal <think> claimed a later turn's genuine wrap: the literal block
was rendered as thinking, clamped to the later wrap's length so the
literal closing tag leaked inside it, and the real trace was delivered
as ordinary tagged text.

Stamp the live wrap count on each content event during the drain and let
the parse pass consume the span guard's own per-turn claim.

Also stop duplicating a whitespace run that precedes a literal <think>.
The run was emitted, then _turn_has_text stayed false because it only
sets on stripped text, so the whole payload including the prefix was
emitted again. Reachable with reasoning-format none, where a thinking
model's raw tags arrive as ordinary content.

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

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

* Fall back to the backend default when preserve_thinking is omitted

preserve_thinking is three valued: omitted means use the template the
model was launched with, and only an explicit value overrides it. Coercing
it with bool() made an omitted field indistinguishable from an explicit
false, so on a model whose launch template sets preserve_thinking_default
the request stripped reasoning_content from every replayed turn while
still leaving the server in preserve mode. The template was told to
preserve history that had already been deleted.

Resolve the effective value once and use it for both conversion and
counting so the two cannot drift.

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

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

* Let reasoning_effort outrank native thinking, as documented

The request model states the x-unsloth reasoning controls win over
thinking when both are present, and resolved_enable_thinking promises the
same, but it consults only the boolean. Reading it first made the
effort-to-boolean mapping dead whenever thinking was supplied, so on a
plain enable_thinking template a request asking for thinking enabled with
reasoning_effort none had the effort dropped and the model thought
anyway, and the inverse stayed off. The effort-dial templates already
honoured it, so one request meant three different things.

llama.cpp resolves this itself, but only for a top-level reasoning_effort
body field, and the passthrough payload forwards chat_template_kwargs
only, so that never fires here.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <samleejackson0@gmail.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-20 08:59:44 -07:00

367 lines
13 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Tests for OpenAI Responses API Pydantic schemas and the
_normalise_responses_input helper. No server or GPU required."""
import sys
import os
import json
import re
# Ensure backend is on path.
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
from models.inference import (
ResponsesRequest,
ResponsesInputMessage,
ResponsesInputTextPart,
ResponsesInputImagePart,
ResponsesOutputTextContent,
ResponsesOutputMessage,
ResponsesUsage,
ResponsesResponse,
ChatMessage,
TextContentPart,
ImageContentPart,
ImageUrl,
ChatCompletionRequest,
)
# Copied from routes/inference.py: can't import it directly because
# routes/__init__.py pulls in heavy deps (structlog/twisted/torch).
def _normalise_responses_input(payload: ResponsesRequest) -> list:
"""Convert a ResponsesRequest into ChatMessages for the completions backend."""
messages = []
# System / developer instructions.
if payload.instructions:
messages.append(ChatMessage(role = "system", content = payload.instructions))
# Simple string input.
if isinstance(payload.input, str):
if payload.input:
messages.append(ChatMessage(role = "user", content = payload.input))
return messages
# List of ResponsesInputMessage.
for msg in payload.input:
role = "system" if msg.role == "developer" else msg.role
if isinstance(msg.content, str):
messages.append(ChatMessage(role = role, content = msg.content))
else:
# Convert Responses content parts -> Chat content parts.
parts = []
for part in msg.content:
if isinstance(part, ResponsesInputTextPart):
parts.append(TextContentPart(type = "text", text = part.text))
elif isinstance(part, ResponsesInputImagePart):
parts.append(
ImageContentPart(
type = "image_url",
image_url = ImageUrl(url = part.image_url, detail = part.detail),
)
)
messages.append(ChatMessage(role = role, content = parts if parts else ""))
return messages
# =====================================================================
# Schema validation tests
# =====================================================================
class TestResponsesRequest:
"""Validate ResponsesRequest accepts the shapes the OpenAI SDK sends."""
def test_minimal_string_input(self):
req = ResponsesRequest(input = "Hello")
assert req.input == "Hello"
assert req.stream is False
assert req.model == "default"
def test_message_list_input(self):
req = ResponsesRequest(
input = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
],
)
assert len(req.input) == 2
assert req.input[0].role == "user"
assert req.input[0].content == "Hi"
def test_multimodal_input(self):
req = ResponsesRequest(
input = [
{
"role": "user",
"content": [
{"type": "input_text", "text": "What is in this image?"},
{
"type": "input_image",
"image_url": "https://example.com/img.png",
},
],
},
],
)
parts = req.input[0].content
assert len(parts) == 2
assert isinstance(parts[0], ResponsesInputTextPart)
assert isinstance(parts[1], ResponsesInputImagePart)
def test_instructions_field(self):
req = ResponsesRequest(
input = "test",
instructions = "You are a helpful assistant.",
)
assert req.instructions == "You are a helpful assistant."
def test_extra_fields_accepted(self):
"""OpenAI SDK may send unmodeled fields -- extra='allow' must pass."""
req = ResponsesRequest(
input = "test",
tools = [{"type": "web_search_preview"}],
store = True,
metadata = {"key": "value"},
previous_response_id = "resp_abc123",
)
assert req.tools == [{"type": "web_search_preview"}]
assert req.store is True
def test_stream_flag(self):
req = ResponsesRequest(input = "test", stream = True)
assert req.stream is True
def test_temperature_and_top_p(self):
req = ResponsesRequest(input = "test", temperature = 0.8, top_p = 0.9)
assert req.temperature == 0.8
assert req.top_p == 0.9
def test_max_output_tokens(self):
req = ResponsesRequest(input = "test", max_output_tokens = 512)
assert req.max_output_tokens == 512
def test_developer_role(self):
req = ResponsesRequest(
input = [{"role": "developer", "content": "System instructions"}],
)
assert req.input[0].role == "developer"
# =====================================================================
# Response model tests
# =====================================================================
class TestResponsesResponse:
"""Response models serialise correctly."""
def test_basic_response(self):
resp = ResponsesResponse(
model = "test-model",
output = [
ResponsesOutputMessage(content = [ResponsesOutputTextContent(text = "Hello!")]),
],
usage = ResponsesUsage(input_tokens = 10, output_tokens = 5, total_tokens = 15),
)
d = resp.model_dump()
assert d["object"] == "response"
assert d["status"] == "completed"
assert d["output"][0]["type"] == "message"
assert d["output"][0]["content"][0]["type"] == "output_text"
assert d["output"][0]["content"][0]["text"] == "Hello!"
assert d["usage"]["input_tokens"] == 10
assert d["usage"]["output_tokens"] == 5
assert d["usage"]["total_tokens"] == 15
# Must NOT have prompt_tokens / completion_tokens
assert "prompt_tokens" not in d["usage"]
assert "completion_tokens" not in d["usage"]
def test_id_format(self):
resp = ResponsesResponse()
assert resp.id.startswith("resp_")
def test_output_message_id_format(self):
msg = ResponsesOutputMessage()
assert msg.id.startswith("msg_")
def test_annotations_default_empty(self):
part = ResponsesOutputTextContent(text = "hi")
assert part.annotations == []
def test_response_json_roundtrip(self):
resp = ResponsesResponse(
model = "gpt-4",
output = [
ResponsesOutputMessage(
content = [ResponsesOutputTextContent(text = "ok")],
),
],
usage = ResponsesUsage(input_tokens = 1, output_tokens = 1, total_tokens = 2),
)
j = json.loads(resp.model_dump_json())
assert j["object"] == "response"
assert j["output"][0]["role"] == "assistant"
assert j["output"][0]["status"] == "completed"
# =====================================================================
# Input normalisation tests
# =====================================================================
class TestNormaliseResponsesInput:
"""_normalise_responses_input converts Responses input to ChatMessages."""
def test_string_input(self):
payload = ResponsesRequest(input = "Hello world")
msgs = _normalise_responses_input(payload)
assert len(msgs) == 1
assert msgs[0].role == "user"
assert msgs[0].content == "Hello world"
def test_instructions_become_system_message(self):
payload = ResponsesRequest(
input = "Hi",
instructions = "Be concise.",
)
msgs = _normalise_responses_input(payload)
assert len(msgs) == 2
assert msgs[0].role == "system"
assert msgs[0].content == "Be concise."
assert msgs[1].role == "user"
assert msgs[1].content == "Hi"
def test_message_list(self):
payload = ResponsesRequest(
input = [
{"role": "user", "content": "First"},
{"role": "assistant", "content": "Response"},
{"role": "user", "content": "Second"},
],
)
msgs = _normalise_responses_input(payload)
assert len(msgs) == 3
assert msgs[0].role == "user"
assert msgs[1].role == "assistant"
assert msgs[2].role == "user"
def test_developer_role_maps_to_system(self):
payload = ResponsesRequest(
input = [{"role": "developer", "content": "Instructions"}],
)
msgs = _normalise_responses_input(payload)
assert msgs[0].role == "system"
assert msgs[0].content == "Instructions"
def test_multimodal_parts(self):
payload = ResponsesRequest(
input = [
{
"role": "user",
"content": [
{"type": "input_text", "text": "Describe this:"},
{
"type": "input_image",
"image_url": "data:image/png;base64,abc",
},
],
},
],
)
msgs = _normalise_responses_input(payload)
assert len(msgs) == 1
content = msgs[0].content
assert isinstance(content, list)
assert len(content) == 2
assert isinstance(content[0], TextContentPart)
assert content[0].text == "Describe this:"
assert isinstance(content[1], ImageContentPart)
assert content[1].image_url.url == "data:image/png;base64,abc"
def test_empty_string_input(self):
payload = ResponsesRequest(input = "")
msgs = _normalise_responses_input(payload)
assert len(msgs) == 0
def test_empty_list_input(self):
payload = ResponsesRequest(input = [])
msgs = _normalise_responses_input(payload)
assert len(msgs) == 0
def test_instructions_only(self):
payload = ResponsesRequest(input = "", instructions = "System msg")
msgs = _normalise_responses_input(payload)
assert len(msgs) == 1
assert msgs[0].role == "system"
def test_instructions_plus_message_list(self):
payload = ResponsesRequest(
input = [{"role": "user", "content": "Hello"}],
instructions = "Be brief.",
)
msgs = _normalise_responses_input(payload)
assert len(msgs) == 2
assert msgs[0].role == "system"
assert msgs[0].content == "Be brief."
assert msgs[1].role == "user"
class TestResponsesReasoning:
"""`/v1/responses` parsed `reasoning` and dropped it, and never relayed
llama-server's `reasoning_content` back -- so Codex could neither turn
thinking on nor see it. Mirrors the /v1/messages fix."""
@staticmethod
def _chat_req(reasoning):
from routes.inference import _build_chat_request
payload = ResponsesRequest(input = "hi", reasoning = reasoning)
return _build_chat_request(payload, [ChatMessage(role = "user", content = "hi")], stream = False)
def test_effort_reaches_the_chat_request(self):
req = self._chat_req({"effort": "high"})
assert req.reasoning_effort == "high"
assert req.enable_thinking is True
def test_effort_none_disables_thinking(self):
"""enable_thinking-style templates have no dial, only a boolean."""
req = self._chat_req({"effort": "none"})
assert req.enable_thinking is False
def test_absent_reasoning_leaves_model_default(self):
req = self._chat_req(None)
assert req.reasoning_effort is None
assert req.enable_thinking is None
def test_malformed_reasoning_is_ignored_not_fatal(self):
"""Never 400 on a shape we don't recognise -- that regressed real
Claude Code traffic once already."""
for bad in ("high", {"effort": 3}, {}, {"summary": "auto"}, {"effort": "auto"}):
req = self._chat_req(bad)
assert req.enable_thinking is None
def test_reasoning_output_item_shape(self):
from models.inference import (
ResponsesOutputReasoning,
ResponsesOutputReasoningContent,
)
item = ResponsesOutputReasoning(
content = [ResponsesOutputReasoningContent(text = "because 2+2")]
).model_dump()
assert item["type"] == "reasoning"
assert item["id"].startswith("rs_")
assert item["content"] == [{"type": "reasoning_text", "text": "because 2+2"}]
if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])