Files
Gérald LONLASandClaude Opus 4.8 f6a5cc1b02 fix(client): strip orphan </think> tags left by nested think blocks (#18)
The non-greedy `<think>.*?</think>` regex removes only up to the first
closing tag. When a model nests `<think>` inside its own reasoning (e.g.
`<think><think>inner</think></think>`), the outer `</think>` was never
consumed, causing it to appear verbatim in the visible output returned to
the caller.

Added a third pass (`_THINK_CLOSE_RE`) that removes any `</think>` tags
still present after the block and dangling-open passes; such tags can only
be orphaned closing tags at that point.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:30:48 +08:00

82 lines
2.9 KiB
Python

from __future__ import annotations
import pytest
import client
def test_normalize_server_url_trims_and_strips_v1() -> None:
assert client.normalize_server_url(" http://10.168.168.7:1234/v1/ ") == "http://10.168.168.7:1234"
@pytest.mark.parametrize(
"value,expected",
[
(None, None),
(42, 42),
(42.9, 42),
("17", 17),
(b"99", 99),
([123], 123),
((456,), 456),
],
)
def test_coerce_seed_supported_values(value, expected) -> None:
assert client.coerce_seed(value) == expected
@pytest.mark.parametrize("value", [[], [1, 2], "abc", float("nan")])
def test_coerce_seed_invalid_values(value) -> None:
with pytest.raises(ValueError):
client.coerce_seed(value)
def test_coerce_seed_empty_string_returns_none() -> None:
# Empty string is treated as "no seed provided" → None → random seed downstream.
assert client.coerce_seed("") is None
def test_resolve_request_seed_negative_one_generates_positive_seed() -> None:
resolved = client.resolve_request_seed(-1)
assert isinstance(resolved, int)
assert resolved > 0
def test_build_responses_input_text_shape() -> None:
payload = client.build_responses_input_text("hello")
assert payload[0]["role"] == "user"
assert payload[0]["content"][0]["type"] == "input_text"
assert payload[0]["content"][0]["text"] == "hello"
def test_build_chat_messages_handles_none_prompts() -> None:
messages = client.build_chat_messages(None, None)
assert messages == [{"role": "user", "content": ""}]
def test_strip_think_content_removes_reasoning_blocks() -> None:
# Use real newlines — as actual LLM output would contain.
text = "Final answer\n<think>internal chain of thought</think>\nVisible output"
assert client.strip_think_content(text) == "Final answer\n\nVisible output"
def test_strip_think_content_removes_dangling_start_tag() -> None:
# strip() removes the trailing real newline left after the dangling block is excised.
text = "Visible output\n<think>unfinished"
assert client.strip_think_content(text) == "Visible output"
def test_strip_think_content_nested_think_tags_no_orphan_close() -> None:
# When a think block contains nested <think> tags (e.g. a model quoting the tag
# in its own reasoning), the non-greedy regex matches up to the *first* </think>,
# which would leave the outer closing tag as an orphan in the visible output.
# The fix strips any remaining </think> tags after the block/dangling passes.
assert client.strip_think_content("<think><think>nested</think></think>") == ""
# Regression: visible text surrounding nested blocks must be preserved intact.
result = client.strip_think_content(
"Before <think>reasoning <think>deep</think></think> after"
)
assert "</think>" not in result
assert "Before" in result
assert "after" in result