fix: require explicit reasoning format before stripping content

This commit is contained in:
haelyra committed 2026-10-11 02:26:58 -04:00
1 parent 1bbebf61d9
commit f4f8beba01
6 files changed
+166 -33

No files matched your search

+1 -2
View File
@@ -17,7 +17,6 @@ from llm.core.interface import (
from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, ToolCall
from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR
from llm.providers.openai_compat import to_openai_message_param
from llm.providers.reasoning import strip_reasoning
ASTRAFLOW_BASE_URL = "https://api.umodelverse.ai/v1"
ASTRAFLOW_CN_BASE_URL = "https://api.modelverse.cn/v1"
@@ -106,7 +105,7 @@ class _AstraflowBaseProvider(LLMProvider):
}
return LLMOutput(
content=strip_reasoning(choice.message.content or ""),
content=choice.message.content or "",
tool_calls=tool_calls,
model=response.model,
usage=usage,
+1 -2
View File
@@ -17,7 +17,6 @@ from llm.core.interface import (
from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, ToolCall
from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR
from llm.providers.openai_compat import to_openai_message_param
from llm.providers.reasoning import strip_reasoning
ATLAS_BASE_URL = "https://api.atlascloud.ai/v1"
DEFAULT_ATLAS_MODEL = "deepseek-ai/deepseek-v4-pro"
@@ -123,7 +122,7 @@ class AtlasProvider(LLMProvider):
}
return LLMOutput(
content=strip_reasoning(choice.message.content or ""),
content=choice.message.content or "",
tool_calls=tool_calls,
model=response.model,
usage=usage,
+11 -1
View File
@@ -58,7 +58,15 @@ class OllamaProvider(LLMProvider):
base_url: str | None = None,
default_model: str | None = None,
prefilled_reasoning: bool | None = None,
leading_reasoning: bool | None = None,
) -> None:
# Enable only for a deployment that declares balanced leading reasoning.
# Generic responses can contain literal XML and stay intact by default.
self.leading_reasoning = (
leading_reasoning
if leading_reasoning is not None
else prefilled_reasoning_from_env("OLLAMA_LEADING_REASONING")
)
# Set for models whose chat template prefills <think>, so replies arrive
# as "...</think>answer" without the opening tag.
self.prefilled_reasoning = (
@@ -158,7 +166,9 @@ class OllamaProvider(LLMProvider):
result = json.loads(response.read().decode("utf-8"))
content = strip_reasoning(
result.get("message", {}).get("content", ""), prefilled=self.prefilled_reasoning
result.get("message", {}).get("content", ""),
leading=self.leading_reasoning,
prefilled=self.prefilled_reasoning
)
tool_calls = None
+13 -1
View File
@@ -34,7 +34,15 @@ class OpenAIProvider(LLMProvider):
api_key: str | None = None,
base_url: str | None = None,
prefilled_reasoning: bool | None = None,
leading_reasoning: bool | None = None,
) -> None:
# Enable only for a deployment that declares balanced leading reasoning.
# Generic responses can contain literal XML and stay intact by default.
self.leading_reasoning = (
leading_reasoning
if leading_reasoning is not None
else prefilled_reasoning_from_env("OPENAI_LEADING_REASONING")
)
# Set for self-hosted servers whose chat template prefills <think>, so
# replies arrive as "...</think>answer" without the opening tag.
self.prefilled_reasoning = (
@@ -119,7 +127,11 @@ class OpenAIProvider(LLMProvider):
}
return LLMOutput(
content=strip_reasoning(choice.message.content or "", prefilled=self.prefilled_reasoning),
content=strip_reasoning(
choice.message.content or "",
leading=self.leading_reasoning,
prefilled=self.prefilled_reasoning,
),
tool_calls=tool_calls,
model=response.model,
usage=usage,
+12 -10
View File
@@ -12,28 +12,30 @@ _TRUTHY = {"1", "true", "yes", "on"}
def prefilled_reasoning_from_env(name: str) -> bool:
"""Read a ``*_PREFILLED_REASONING`` flag from the environment."""
"""Read an explicit reasoning-format flag from the environment."""
return os.environ.get(name, "").strip().lower() in _TRUTHY
def strip_reasoning(content: str, *, prefilled: bool = False) -> str:
def strip_reasoning(
content: str, *, leading: bool = False, prefilled: bool = False
) -> str:
"""Drop a leading reasoning block from model output.
Hosted APIs return reasoning in a separate field, but self-hosted reasoning
models (vLLM or llama.cpp without a reasoning parser, Ollama models without
a thinking template) put it in ``content``.
Some deployments put reasoning in ``content`` instead of a separate field.
This helper makes no assumptions about a hosted API or model name.
By default only a balanced block that opens the output is removed:
``<think>...</think>answer``. Anything else is returned unchanged, so an
answer that merely mentions the tags (including a lone or reversed closing
tag) is never truncated, and neither is unterminated reasoning (for example
when ``max_tokens`` cut the model off before it reached an answer).
Content is preserved by default, including complete leading XML documents.
Enable ``leading=True`` only for a deployment whose declared format is
``<think>...</think>answer``. Unterminated reasoning remains unchanged.
``prefilled=True`` is for deployments whose chat template prefills the
opening tag in the generation prompt, so the model only ever emits
``...</think>answer``. Only enable it when the model is known to behave this
way: everything up to the first closing tag is then treated as reasoning.
"""
if not leading and not prefilled:
return content
stripped = content.lstrip()
if stripped.startswith(THINK_OPEN):
close = stripped.find(THINK_CLOSE, len(THINK_OPEN))
+128 -17
View File
@@ -9,6 +9,7 @@ import pytest
from llm.core.interface import LLMProvider
from llm.core.types import LLMInput, LLMOutput, Message, Role
from llm.providers.astraflow import AstraflowProvider
from llm.providers.atlas import AtlasProvider
from llm.providers.ollama import OllamaProvider
from llm.providers.openai import OpenAIProvider
from llm.providers.reasoning import strip_reasoning
@@ -38,8 +39,8 @@ LITERAL_ANSWERS = [
("17 * 3 = 51.</think>51", "17 * 3 = 51.</think>51"),
],
)
def test_strip_reasoning_default(content: str, expected: str) -> None:
assert strip_reasoning(content) == expected
def test_strip_reasoning_declared_leading(content: str, expected: str) -> None:
assert strip_reasoning(content, leading=True) == expected
@pytest.mark.parametrize("content", LITERAL_ANSWERS)
@@ -54,11 +55,17 @@ def test_strip_reasoning_default_preserves_literal_tags(content: str) -> None:
('build the object\n</think>\n{"answer": 51}', '{"answer": 51}'),
("<think>17 * 3 = 51.</think>51", "51"),
("plain answer", "plain answer"),
("Wrap it in <think> and </think> tags.", "Wrap it in <think> and </think> tags."),
(
"Wrap it in <think> and </think> tags.",
"Wrap it in <think> and </think> tags.",
),
("17 * 3 = 51.</think>", ""),
# A complete JSON answer is never reasoning plus a closing tag.
('{"closing":"</think>"}', '{"closing":"</think>"}'),
(' {"closing":"</think>","opening":"<think>"}\n', ' {"closing":"</think>","opening":"<think>"}\n'),
(
' {"closing":"</think>","opening":"<think>"}\n',
' {"closing":"</think>","opening":"<think>"}\n',
),
('["</think>", 1]', '["</think>", 1]'),
],
)
@@ -82,18 +89,27 @@ def _openai_provider(content: str, **kwargs: Any) -> OpenAIProvider:
usage=None,
)
client: Any = SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **params: response))
chat=SimpleNamespace(
completions=SimpleNamespace(create=lambda **params: response)
)
)
provider.client = client
return provider
def _ask(provider: LLMProvider, **kwargs: Any) -> LLMOutput:
return provider.generate(LLMInput(messages=[Message(role=Role.USER, content="17*3?")], **kwargs))
return provider.generate(
LLMInput(messages=[Message(role=Role.USER, content="17*3?")], **kwargs)
)
def test_openai_provider_strips_balanced_reasoning() -> None:
assert _ask(_openai_provider("<think>17 * 3 = 51.</think>51")).content == "51"
assert (
_ask(
_openai_provider("<think>17 * 3 = 51.</think>51", leading_reasoning=True)
).content
== "51"
)
@pytest.mark.parametrize("content", LITERAL_ANSWERS)
@@ -101,16 +117,28 @@ def test_openai_provider_keeps_literal_tags(content: str) -> None:
assert _ask(_openai_provider(content)).content == content
def test_openai_provider_close_only_needs_opt_in(monkeypatch: pytest.MonkeyPatch) -> None:
def test_openai_provider_close_only_needs_opt_in(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("OPENAI_PREFILLED_REASONING", raising=False)
assert _ask(_openai_provider("17 * 3 = 51.</think>51")).content == "17 * 3 = 51.</think>51"
assert _ask(_openai_provider("17 * 3 = 51.</think>51", prefilled_reasoning=True)).content == "51"
assert (
_ask(_openai_provider("17 * 3 = 51.</think>51")).content
== "17 * 3 = 51.</think>51"
)
assert (
_ask(
_openai_provider("17 * 3 = 51.</think>51", prefilled_reasoning=True)
).content
== "51"
)
monkeypatch.setenv("OPENAI_PREFILLED_REASONING", "true")
assert _ask(_openai_provider("17 * 3 = 51.</think>51")).content == "51"
def test_hosted_provider_never_strips_close_only(monkeypatch: pytest.MonkeyPatch) -> None:
def test_hosted_provider_never_strips_close_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OPENAI_PREFILLED_REASONING", "true")
provider = AstraflowProvider(api_key="test")
message = SimpleNamespace(content='{"closing":"</think>"}', tool_calls=None)
@@ -120,7 +148,9 @@ def test_hosted_provider_never_strips_close_only(monkeypatch: pytest.MonkeyPatch
usage=None,
)
client: Any = SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **params: response))
chat=SimpleNamespace(
completions=SimpleNamespace(create=lambda **params: response)
)
)
provider.client = client
@@ -132,21 +162,29 @@ def _fake_ollama(monkeypatch: pytest.MonkeyPatch, content: str) -> list[dict[str
def fake_urlopen(request: urllib.request.Request, timeout: float) -> BytesIO:
sent.append(json.loads(cast(bytes, request.data)))
return BytesIO(json.dumps({"message": {"content": content}, "done_reason": "stop"}).encode())
return BytesIO(
json.dumps(
{"message": {"content": content}, "done_reason": "stop"}
).encode()
)
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
return sent
@pytest.mark.parametrize("content", LITERAL_ANSWERS)
def test_ollama_provider_keeps_literal_tags(monkeypatch: pytest.MonkeyPatch, content: str) -> None:
def test_ollama_provider_keeps_literal_tags(
monkeypatch: pytest.MonkeyPatch, content: str
) -> None:
monkeypatch.delenv("OLLAMA_PREFILLED_REASONING", raising=False)
_fake_ollama(monkeypatch, content)
assert _ask(OllamaProvider()).content == content
def test_ollama_provider_strips_prefilled_reasoning_when_declared(monkeypatch: pytest.MonkeyPatch) -> None:
def test_ollama_provider_strips_prefilled_reasoning_when_declared(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("OLLAMA_PREFILLED_REASONING", raising=False)
_fake_ollama(monkeypatch, "17 * 3 = 51.</think>51")
assert _ask(OllamaProvider()).content == "17 * 3 = 51.</think>51"
@@ -156,13 +194,86 @@ def test_ollama_provider_strips_prefilled_reasoning_when_declared(monkeypatch: p
assert _ask(OllamaProvider()).content == "51"
def test_ollama_stream_flag_still_strips_complete_message(monkeypatch: pytest.MonkeyPatch) -> None:
def test_ollama_stream_flag_still_strips_complete_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The provider always requests a single complete message, even when the
# caller asks for streaming, so stripping sees the whole reply. If chunked
# streaming is added, this needs an incremental stripper instead.
sent = _fake_ollama(monkeypatch, '<think>plan</think>{"closing":"</think>"}')
output = _ask(OllamaProvider(), stream=True)
output = _ask(OllamaProvider(leading_reasoning=True), stream=True)
assert sent[0]["stream"] is False
assert output.content == '{"closing":"</think>"}'
@pytest.mark.parametrize(
"content",
[
"<think>display this XML literally</think>",
"<think>display this XML literally</think><answer>keep both</answer>",
],
)
def test_default_provider_preserves_complete_leading_xml(
monkeypatch: pytest.MonkeyPatch, content: str
) -> None:
monkeypatch.delenv("OPENAI_LEADING_REASONING", raising=False)
monkeypatch.delenv("OPENAI_PREFILLED_REASONING", raising=False)
monkeypatch.delenv("OLLAMA_LEADING_REASONING", raising=False)
monkeypatch.delenv("OLLAMA_PREFILLED_REASONING", raising=False)
assert _ask(_openai_provider(content)).content == content
_fake_ollama(monkeypatch, content)
assert _ask(OllamaProvider()).content == content
def test_balanced_leading_reasoning_requires_explicit_format(
monkeypatch: pytest.MonkeyPatch,
) -> None:
content = "<think>plan</think>answer"
assert _ask(_openai_provider(content, leading_reasoning=True)).content == "answer"
monkeypatch.setenv("OPENAI_LEADING_REASONING", "true")
assert _ask(_openai_provider(content)).content == "answer"
assert _ask(_openai_provider(content, leading_reasoning=False)).content == content
_fake_ollama(monkeypatch, content)
assert _ask(OllamaProvider(leading_reasoning=True)).content == "answer"
monkeypatch.setenv("OLLAMA_LEADING_REASONING", "true")
assert _ask(OllamaProvider()).content == "answer"
assert _ask(OllamaProvider(leading_reasoning=False)).content == content
@pytest.mark.parametrize("provider_type", [AtlasProvider, AstraflowProvider])
@pytest.mark.parametrize(
"content",
["<think>literal XML</think>", "<think>literal XML</think><answer>whole</answer>"],
)
def test_hosted_providers_preserve_leading_xml(
monkeypatch: pytest.MonkeyPatch, provider_type: Any, content: str
) -> None:
monkeypatch.setenv("OPENAI_LEADING_REASONING", "true")
monkeypatch.setenv("OPENAI_PREFILLED_REASONING", "true")
provider = provider_type(api_key="test")
response = SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content=content, tool_calls=None),
finish_reason="stop",
)
],
model="fixture",
usage=None,
)
provider.client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(create=lambda **params: response)
)
)
assert _ask(provider).content == content
@pytest.mark.parametrize(
"content",
["<think>literal XML</think>", "<think>literal XML</think><answer>whole</answer>"],
)
def test_helper_defaults_preserve_leading_xml(content: str) -> None:
assert strip_reasoning(content) == content