mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 05:57:40 +02:00
fix(llm): integrate revised reasoning filtering from PR 3272
This commit is contained in:
@@ -17,6 +17,7 @@ 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"
|
||||
@@ -105,7 +106,7 @@ class _AstraflowBaseProvider(LLMProvider):
|
||||
}
|
||||
|
||||
return LLMOutput(
|
||||
content=choice.message.content or "",
|
||||
content=strip_reasoning(choice.message.content or ""),
|
||||
tool_calls=tool_calls,
|
||||
model=response.model,
|
||||
usage=usage,
|
||||
|
||||
@@ -17,6 +17,7 @@ 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"
|
||||
@@ -122,7 +123,7 @@ class AtlasProvider(LLMProvider):
|
||||
}
|
||||
|
||||
return LLMOutput(
|
||||
content=choice.message.content or "",
|
||||
content=strip_reasoning(choice.message.content or ""),
|
||||
tool_calls=tool_calls,
|
||||
model=response.model,
|
||||
usage=usage,
|
||||
|
||||
@@ -19,6 +19,7 @@ from llm.core.types import (
|
||||
ProviderType,
|
||||
ToolCall,
|
||||
)
|
||||
from llm.providers.reasoning import prefilled_reasoning_from_env, strip_reasoning
|
||||
|
||||
|
||||
class OllamaProvider(LLMProvider):
|
||||
@@ -28,7 +29,15 @@ class OllamaProvider(LLMProvider):
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
default_model: str | None = None,
|
||||
prefilled_reasoning: bool | None = None,
|
||||
) -> None:
|
||||
# Set for models whose chat template prefills <think>, so replies arrive
|
||||
# as "...</think>answer" without the opening tag.
|
||||
self.prefilled_reasoning = (
|
||||
prefilled_reasoning
|
||||
if prefilled_reasoning is not None
|
||||
else prefilled_reasoning_from_env("OLLAMA_PREFILLED_REASONING")
|
||||
)
|
||||
self.base_url = base_url or os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
self.default_model = default_model or os.environ.get("OLLAMA_MODEL", "llama3.2")
|
||||
self._models = [
|
||||
@@ -85,7 +94,9 @@ class OllamaProvider(LLMProvider):
|
||||
with urllib.request.urlopen(req, timeout=60) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
content = result.get("message", {}).get("content", "")
|
||||
content = strip_reasoning(
|
||||
result.get("message", {}).get("content", ""), prefilled=self.prefilled_reasoning
|
||||
)
|
||||
|
||||
tool_calls = None
|
||||
if result.get("message", {}).get("tool_calls"):
|
||||
|
||||
@@ -23,12 +23,25 @@ from llm.core.types import (
|
||||
)
|
||||
from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR
|
||||
from llm.providers.openai_compat import to_openai_message_param
|
||||
from llm.providers.reasoning import prefilled_reasoning_from_env, strip_reasoning
|
||||
|
||||
|
||||
class OpenAIProvider(LLMProvider):
|
||||
provider_type = ProviderType.OPENAI
|
||||
|
||||
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
prefilled_reasoning: bool | None = None,
|
||||
) -> None:
|
||||
# Set for self-hosted servers whose chat template prefills <think>, so
|
||||
# replies arrive as "...</think>answer" without the opening tag.
|
||||
self.prefilled_reasoning = (
|
||||
prefilled_reasoning
|
||||
if prefilled_reasoning is not None
|
||||
else prefilled_reasoning_from_env("OPENAI_PREFILLED_REASONING")
|
||||
)
|
||||
self.client = OpenAI(
|
||||
api_key=api_key or os.environ.get("OPENAI_API_KEY"),
|
||||
base_url=base_url,
|
||||
@@ -106,7 +119,7 @@ class OpenAIProvider(LLMProvider):
|
||||
}
|
||||
|
||||
return LLMOutput(
|
||||
content=choice.message.content or "",
|
||||
content=strip_reasoning(choice.message.content or "", prefilled=self.prefilled_reasoning),
|
||||
tool_calls=tool_calls,
|
||||
model=response.model,
|
||||
usage=usage,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Helpers for reasoning text that self-hosted models leave in message content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
THINK_OPEN = "<think>"
|
||||
THINK_CLOSE = "</think>"
|
||||
|
||||
_TRUTHY = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def prefilled_reasoning_from_env(name: str) -> bool:
|
||||
"""Read a ``*_PREFILLED_REASONING`` flag from the environment."""
|
||||
return os.environ.get(name, "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def strip_reasoning(content: str, *, 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``.
|
||||
|
||||
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).
|
||||
|
||||
``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.
|
||||
"""
|
||||
stripped = content.lstrip()
|
||||
if stripped.startswith(THINK_OPEN):
|
||||
close = stripped.find(THINK_CLOSE, len(THINK_OPEN))
|
||||
if close == -1:
|
||||
return content
|
||||
return stripped[close + len(THINK_CLOSE) :].lstrip()
|
||||
|
||||
if not prefilled:
|
||||
return content
|
||||
|
||||
close = content.find(THINK_CLOSE)
|
||||
if close == -1 or THINK_OPEN in content[:close] or _is_json_document(content):
|
||||
return content
|
||||
return content[close + len(THINK_CLOSE) :].lstrip()
|
||||
|
||||
|
||||
def _is_json_document(content: str) -> bool:
|
||||
"""Whether ``content`` is one complete JSON object or array.
|
||||
|
||||
Reasoning followed by a closing tag is never valid JSON, so a structured
|
||||
answer that carries the tag inside a string value is left whole. Input
|
||||
nested too deeply for the parser to finish is kept whole as well: reasoning
|
||||
does not open with that many brackets, so cutting it would only risk
|
||||
truncating a real answer.
|
||||
"""
|
||||
stripped = content.strip()
|
||||
if not stripped.startswith(("{", "[")):
|
||||
return False
|
||||
try:
|
||||
json.loads(stripped)
|
||||
except RecursionError:
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,168 @@
|
||||
import json
|
||||
import urllib.request
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
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.ollama import OllamaProvider
|
||||
from llm.providers.openai import OpenAIProvider
|
||||
from llm.providers.reasoning import strip_reasoning
|
||||
|
||||
# Answers that mention the tags without carrying reasoning. None of them may be
|
||||
# changed, whatever the deployment's reasoning format.
|
||||
LITERAL_ANSWERS = [
|
||||
"Wrap it in <think> and </think> tags.",
|
||||
"Close it with </think> and nothing else.",
|
||||
"</think> comes before <think> here.",
|
||||
'{"closing":"</think>","opening":"<think>"}',
|
||||
'{"note": "<think>x</think>"}',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "expected"),
|
||||
[
|
||||
("<think>17 * 3 = 51.</think>51", "51"),
|
||||
("\n<think>\nplan\n</think>\n\nanswer", "answer"),
|
||||
('<think>build the object</think>{"answer": 51}', '{"answer": 51}'),
|
||||
('<think>plan</think>{"closing":"</think>"}', '{"closing":"</think>"}'),
|
||||
("plain answer", "plain answer"),
|
||||
("", ""),
|
||||
("<think>cut off by max_tokens", "<think>cut off by max_tokens"),
|
||||
# Close-only output is ambiguous without a declared format, so it stays.
|
||||
("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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", LITERAL_ANSWERS)
|
||||
def test_strip_reasoning_default_preserves_literal_tags(content: str) -> None:
|
||||
assert strip_reasoning(content) == content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "expected"),
|
||||
[
|
||||
("17 * 3 = 51.</think>51", "51"),
|
||||
('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."),
|
||||
("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'),
|
||||
('["</think>", 1]', '["</think>", 1]'),
|
||||
],
|
||||
)
|
||||
def test_strip_reasoning_prefilled(content: str, expected: str) -> None:
|
||||
assert strip_reasoning(content, prefilled=True) == expected
|
||||
|
||||
|
||||
def test_strip_reasoning_prefilled_survives_deeply_nested_json() -> None:
|
||||
# Deep enough for json.loads to raise RecursionError on every supported Python.
|
||||
depth = 100_000
|
||||
content = "[" * depth + '"</think>"' + "]" * depth
|
||||
assert strip_reasoning(content, prefilled=True) == content
|
||||
|
||||
|
||||
def _openai_provider(content: str, **kwargs: Any) -> OpenAIProvider:
|
||||
provider = OpenAIProvider(api_key="test", **kwargs)
|
||||
message = SimpleNamespace(content=content, tool_calls=None)
|
||||
response = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=message, finish_reason="stop")],
|
||||
model="spark-x2.5",
|
||||
usage=None,
|
||||
)
|
||||
client: Any = SimpleNamespace(
|
||||
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))
|
||||
|
||||
|
||||
def test_openai_provider_strips_balanced_reasoning() -> None:
|
||||
assert _ask(_openai_provider("<think>17 * 3 = 51.</think>51")).content == "51"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", LITERAL_ANSWERS)
|
||||
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:
|
||||
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"
|
||||
|
||||
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:
|
||||
monkeypatch.setenv("OPENAI_PREFILLED_REASONING", "true")
|
||||
provider = AstraflowProvider(api_key="test")
|
||||
message = SimpleNamespace(content='{"closing":"</think>"}', tool_calls=None)
|
||||
response = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=message, finish_reason="stop")],
|
||||
model="deepseek-v3.2",
|
||||
usage=None,
|
||||
)
|
||||
client: Any = SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **params: response))
|
||||
)
|
||||
provider.client = client
|
||||
|
||||
assert _ask(provider).content == '{"closing":"</think>"}'
|
||||
|
||||
|
||||
def _fake_ollama(monkeypatch: pytest.MonkeyPatch, content: str) -> list[dict[str, Any]]:
|
||||
sent: list[dict[str, Any]] = []
|
||||
|
||||
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())
|
||||
|
||||
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:
|
||||
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:
|
||||
monkeypatch.delenv("OLLAMA_PREFILLED_REASONING", raising=False)
|
||||
_fake_ollama(monkeypatch, "17 * 3 = 51.</think>51")
|
||||
assert _ask(OllamaProvider()).content == "17 * 3 = 51.</think>51"
|
||||
assert _ask(OllamaProvider(prefilled_reasoning=True)).content == "51"
|
||||
|
||||
monkeypatch.setenv("OLLAMA_PREFILLED_REASONING", "1")
|
||||
assert _ask(OllamaProvider()).content == "51"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
assert sent[0]["stream"] is False
|
||||
assert output.content == '{"closing":"</think>"}'
|
||||
Reference in New Issue
Block a user