From 8b02267d8a3d2d529a66a54241e35539c8d34564 Mon Sep 17 00:00:00 2001 From: 2233admin <1497479966@qq.com> Date: Thu, 26 Mar 2026 03:43:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=1B[38;5;8m=20=20=201=1B[0m=20=1B[37mfix:?= =?UTF-8?q?=20fall=20back=20to=20reasoning=5Fcontent=20for=20reasoning=20m?= =?UTF-8?q?odels=1B[0m=20=1B[38;5;8m=20=20=202=1B[0m=20=1B[38;5;8m=20=20?= =?UTF-8?q?=203=1B[0m=20=1B[37mReasoning=20models=20(e.g.=20MiniMax-M2.7,?= =?UTF-8?q?=20DeepSeek-R1)=20return=20their=20output=1B[0m=20=1B[38;5;8m?= =?UTF-8?q?=20=20=204=1B[0m=20=1B[37min=20`reasoning=5Fcontent`=20instead?= =?UTF-8?q?=20of=20`content`.=20Previously=20all=20content=1B[0m=20=1B[38;?= =?UTF-8?q?5;8m=20=20=205=1B[0m=20=1B[37mextraction=20sites=20assumed=20`c?= =?UTF-8?q?ontent`=20was=20populated,=20returning=20an=20empty=1B[0m=20=1B?= =?UTF-8?q?[38;5;8m=20=20=206=1B[0m=20=1B[37mstring=20for=20these=20models?= =?UTF-8?q?.=1B[0m=20=1B[38;5;8m=20=20=207=1B[0m=20=1B[38;5;8m=20=20=208?= =?UTF-8?q?=1B[0m=20=1B[37mAdd=20`=5Fextract=5Fcontent`=20/=20`=5Fextract?= =?UTF-8?q?=5Fcontent=5Ffrom=5Fdict`=20helpers=20that=1B[0m=20=1B[38;5;8m?= =?UTF-8?q?=20=20=209=1B[0m=20=1B[37mcheck=20`reasoning=5Fcontent`=20when?= =?UTF-8?q?=20`content`=20is=20empty/None.=20Applied=20to:=1B[0m=20=1B[38;?= =?UTF-8?q?5;8m=20=2010=1B[0m=20=1B[37m-=20openai=5Fsdk.py:=20chat(),=20su?= =?UTF-8?q?mmarize(),=20vision()=1B[0m=20=1B[38;5;8m=20=2011=1B[0m=20=1B[3?= =?UTF-8?q?7m-=20HTTP=20backends:=20openai,=20doubao,=20openrouter=20parse?= =?UTF-8?q?=5Fsummary=5Fresponse()=1B[0m=20=1B[38;5;8m=20=2012=1B[0m=20=1B?= =?UTF-8?q?[38;5;8m=20=2013=1B[0m=20=1B[37mCo-Authored-By:=20Claude=20Opus?= =?UTF-8?q?=204.6=20(1M=20context)=20=1B[0m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/memu/llm/backends/base.py | 13 +++++++++++++ src/memu/llm/backends/doubao.py | 6 +++--- src/memu/llm/backends/openai.py | 6 +++--- src/memu/llm/backends/openrouter.py | 6 +++--- src/memu/llm/openai_sdk.py | 25 +++++++++++++++++++------ 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/memu/llm/backends/base.py b/src/memu/llm/backends/base.py index 8f76330a..9bfe43a8 100644 --- a/src/memu/llm/backends/base.py +++ b/src/memu/llm/backends/base.py @@ -3,6 +3,19 @@ from typing import Any +def _extract_content_from_dict(data: dict[str, Any]) -> str: + """Extract text content from a raw API response dict. + + Falls back to ``reasoning_content`` for reasoning models (e.g. MiniMax-M2.7, + DeepSeek-R1) that put their output there instead of ``content``. + """ + msg = data["choices"][0]["message"] + content = msg.get("content") + if not content: + content = msg.get("reasoning_content") + return content or "" + + class LLMBackend: """Defines how to talk to a specific HTTP LLM provider.""" diff --git a/src/memu/llm/backends/doubao.py b/src/memu/llm/backends/doubao.py index 9dd7012a..fcf5b9a6 100644 --- a/src/memu/llm/backends/doubao.py +++ b/src/memu/llm/backends/doubao.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any -from memu.llm.backends.base import LLMBackend +from memu.llm.backends.base import LLMBackend, _extract_content_from_dict class DoubaoLLMBackend(LLMBackend): @@ -29,7 +29,7 @@ def build_summary_payload( return payload def parse_summary_response(self, data: dict[str, Any]) -> str: - return cast(str, data["choices"][0]["message"]["content"]) + return _extract_content_from_dict(data) def build_vision_payload( self, diff --git a/src/memu/llm/backends/openai.py b/src/memu/llm/backends/openai.py index aef24fc6..dd8f98d1 100644 --- a/src/memu/llm/backends/openai.py +++ b/src/memu/llm/backends/openai.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any -from memu.llm.backends.base import LLMBackend +from memu.llm.backends.base import LLMBackend, _extract_content_from_dict class OpenAILLMBackend(LLMBackend): @@ -26,7 +26,7 @@ def build_summary_payload( } def parse_summary_response(self, data: dict[str, Any]) -> str: - return cast(str, data["choices"][0]["message"]["content"]) + return _extract_content_from_dict(data) def build_vision_payload( self, diff --git a/src/memu/llm/backends/openrouter.py b/src/memu/llm/backends/openrouter.py index 1a8cdeef..8ef00bd0 100644 --- a/src/memu/llm/backends/openrouter.py +++ b/src/memu/llm/backends/openrouter.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any -from memu.llm.backends.base import LLMBackend +from memu.llm.backends.base import LLMBackend, _extract_content_from_dict class OpenRouterLLMBackend(LLMBackend): @@ -30,7 +30,7 @@ def build_summary_payload( def parse_summary_response(self, data: dict[str, Any]) -> str: """Parse OpenRouter response (OpenAI-compatible format).""" - return cast(str, data["choices"][0]["message"]["content"]) + return _extract_content_from_dict(data) def build_vision_payload( self, diff --git a/src/memu/llm/openai_sdk.py b/src/memu/llm/openai_sdk.py index 38c6c8bb..8d78d0c5 100644 --- a/src/memu/llm/openai_sdk.py +++ b/src/memu/llm/openai_sdk.py @@ -17,6 +17,19 @@ logger = logging.getLogger(__name__) +def _extract_content(response: ChatCompletion) -> str: + """Extract text content from a chat completion, with fallback for reasoning models. + + Some reasoning models (e.g. MiniMax-M2.7, DeepSeek-R1) return their output in + ``reasoning_content`` instead of ``content``. This helper checks both fields. + """ + msg = response.choices[0].message + content = msg.content + if not content: + content = getattr(msg, "reasoning_content", None) + return content or "" + + class OpenAISDKClient: """OpenAI LLM client that relies on the official Python SDK.""" @@ -59,9 +72,9 @@ async def chat( temperature=temperature, max_tokens=max_tokens, ) - content = response.choices[0].message.content + content = _extract_content(response) logger.debug("OpenAI chat response: %s", response) - return content or "", response + return content, response async def summarize( self, @@ -82,9 +95,9 @@ async def summarize( temperature=1, max_tokens=max_tokens, ) - content = response.choices[0].message.content + content = _extract_content(response) logger.debug("OpenAI summarize response: %s", response) - return content or "", response + return content, response async def vision( self, @@ -148,9 +161,9 @@ async def vision( temperature=1, max_tokens=max_tokens, ) - content = response.choices[0].message.content + content = _extract_content(response) logger.debug("OpenAI vision response: %s", response) - return content or "", response + return content, response async def embed(self, inputs: list[str]) -> tuple[list[list[float]], CreateEmbeddingResponse | None]: """Create text embeddings via the official SDK.""" From af51588819a0b871e73b707b9792d22b72ed8780 Mon Sep 17 00:00:00 2001 From: 2233admin <1497479966@qq.com> Date: Thu, 26 Mar 2026 06:36:05 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=1B[38;5;8m=20=20=201=1B[0m=20=1B[37mtest:?= =?UTF-8?q?=20add=20unit=20tests=20for=20reasoning=5Fcontent=20extraction?= =?UTF-8?q?=20helpers=1B[0m=20=1B[38;5;8m=20=20=202=1B[0m=20=1B[38;5;8m=20?= =?UTF-8?q?=20=203=1B[0m=20=1B[37mCover=20both=20SDK=20path=20(=5Fextract?= =?UTF-8?q?=5Fcontent)=20and=20HTTP=20dict=20path=1B[0m=20=1B[38;5;8m=20?= =?UTF-8?q?=20=204=1B[0m=20=1B[37m(=5Fextract=5Fcontent=5Ffrom=5Fdict)=20w?= =?UTF-8?q?ith=2011=20test=20cases:=1B[0m=20=1B[38;5;8m=20=20=205=1B[0m=20?= =?UTF-8?q?=1B[37m-=20normal=20content,=20reasoning=20fallback,=20empty=20?= =?UTF-8?q?string=20fallback,=1B[0m=20=1B[38;5;8m=20=20=206=1B[0m=20=1B[37?= =?UTF-8?q?m=20=20both=20missing,=20content=20preferred=20over=20reasoning?= =?UTF-8?q?.=1B[0m=20=1B[38;5;8m=20=20=207=1B[0m=20=1B[38;5;8m=20=20=208?= =?UTF-8?q?=1B[0m=20=1B[37mCo-Authored-By:=20Claude=20Opus=204.6=20(1M=20c?= =?UTF-8?q?ontext)=20=1B[0m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/llm/test_extract_content.py | 87 +++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/llm/test_extract_content.py diff --git a/tests/llm/test_extract_content.py b/tests/llm/test_extract_content.py new file mode 100644 index 00000000..15511970 --- /dev/null +++ b/tests/llm/test_extract_content.py @@ -0,0 +1,87 @@ +"""Tests for reasoning_content fallback in content extraction helpers.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from memu.llm.backends.base import _extract_content_from_dict +from memu.llm.openai_sdk import _extract_content + + +# -- _extract_content (SDK path, ChatCompletion objects) -- + + +def _fake_completion(content=None, reasoning_content=None): + """Build a minimal ChatCompletion-like object.""" + msg = MagicMock() + msg.content = content + # reasoning_content is an extra attr on some providers + if reasoning_content is not None: + msg.reasoning_content = reasoning_content + else: + # simulate the attr not existing at all + del msg.reasoning_content + choice = SimpleNamespace(message=msg) + return SimpleNamespace(choices=[choice]) + + +class TestExtractContent: + def test_normal_content(self): + resp = _fake_completion(content="hello world") + assert _extract_content(resp) == "hello world" + + def test_reasoning_content_fallback(self): + resp = _fake_completion(content=None, reasoning_content="thought result") + assert _extract_content(resp) == "thought result" + + def test_empty_string_content_falls_back(self): + resp = _fake_completion(content="", reasoning_content="fallback") + assert _extract_content(resp) == "fallback" + + def test_both_none_returns_empty(self): + resp = _fake_completion(content=None) + assert _extract_content(resp) == "" + + def test_content_preferred_over_reasoning(self): + resp = _fake_completion(content="real answer", reasoning_content="thinking") + assert _extract_content(resp) == "real answer" + + +# -- _extract_content_from_dict (HTTP path, raw dicts) -- + + +def _fake_dict_response(content=None, reasoning_content=None): + """Build a minimal raw API response dict.""" + msg = {} + if content is not None: + msg["content"] = content + if reasoning_content is not None: + msg["reasoning_content"] = reasoning_content + return {"choices": [{"message": msg}]} + + +class TestExtractContentFromDict: + def test_normal_content(self): + data = _fake_dict_response(content="hello") + assert _extract_content_from_dict(data) == "hello" + + def test_reasoning_content_fallback(self): + data = _fake_dict_response(reasoning_content="thought") + assert _extract_content_from_dict(data) == "thought" + + def test_empty_string_content_falls_back(self): + data = _fake_dict_response(content="", reasoning_content="fb") + assert _extract_content_from_dict(data) == "fb" + + def test_both_missing_returns_empty(self): + data = _fake_dict_response() + assert _extract_content_from_dict(data) == "" + + def test_content_preferred_over_reasoning(self): + data = _fake_dict_response(content="answer", reasoning_content="thinking") + assert _extract_content_from_dict(data) == "answer" + + def test_none_content_with_reasoning(self): + data = _fake_dict_response(content=None, reasoning_content="result") + assert _extract_content_from_dict(data) == "result"