mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +00:00
* 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>
367 lines
13 KiB
Python
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"])
|