Skip to content

Commit 99b7982

Browse files
committed
fix analyzer content block responses
1 parent d2f677f commit 99b7982

3 files changed

Lines changed: 123 additions & 2 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
3232
- [新功能] 通知网关新增默认关闭的进程内降噪配置,支持去重、冷却、静默时段和最低严重级别,并将每日摘要开关标记为预留能力。
3333
- [文档] 恢复多语言 README 新闻源配置表中推荐项的加粗样式,统一相关项目章节层级,并精简顶部导航、联系文案和尾部展示。
3434
- [修复] Docker 挂载的 `logs` 目录不可写时启动日志自动降级到控制台输出,并补充非 root 容器目录权限说明。
35+
- [修复] 正式分析链路兼容 OpenAI-compatible `content_blocks` 响应,避免 `message.content=null` 时被误判为空回复。
3536

3637
## [3.16.0] - 2026-05-10
3738

src/analyzer.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1912,6 +1912,67 @@ def _get_value(key: str) -> int:
19121912
"total_tokens": _get_value("total_tokens"),
19131913
}
19141914

1915+
@staticmethod
1916+
def _get_response_field(obj: Any, key: str) -> Any:
1917+
"""Read a field from dict-like or object-like LiteLLM payloads."""
1918+
if isinstance(obj, dict):
1919+
return obj.get(key)
1920+
return getattr(obj, key, None)
1921+
1922+
def _extract_text_blocks(self, blocks: Any) -> str:
1923+
"""Extract text from OpenAI-compatible content block lists."""
1924+
if not blocks:
1925+
return ""
1926+
1927+
parts: List[str] = []
1928+
for block in blocks:
1929+
if isinstance(block, str):
1930+
parts.append(block)
1931+
continue
1932+
1933+
text = None
1934+
if isinstance(block, dict):
1935+
text = block.get("text")
1936+
if text is None:
1937+
text = block.get("content")
1938+
else:
1939+
text = getattr(block, "text", None)
1940+
if text is None:
1941+
text = getattr(block, "content", None)
1942+
1943+
if isinstance(text, str) and text:
1944+
parts.append(text)
1945+
1946+
return "".join(parts).strip()
1947+
1948+
def _extract_completion_text(self, response: Any) -> str:
1949+
"""Extract text from non-stream LiteLLM completion responses."""
1950+
choices = self._get_response_field(response, "choices")
1951+
if not choices:
1952+
return ""
1953+
1954+
choice = choices[0]
1955+
message = self._get_response_field(choice, "message")
1956+
1957+
content_blocks = self._get_response_field(choice, "content_blocks")
1958+
if content_blocks is None and message is not None:
1959+
content_blocks = self._get_response_field(message, "content_blocks")
1960+
block_text = self._extract_text_blocks(content_blocks)
1961+
if block_text:
1962+
return block_text
1963+
1964+
content = None
1965+
if message is not None:
1966+
content = self._get_response_field(message, "content")
1967+
if content is None:
1968+
content = self._get_response_field(choice, "content")
1969+
1970+
if isinstance(content, list):
1971+
return self._extract_text_blocks(content)
1972+
if isinstance(content, str):
1973+
return content.strip()
1974+
return str(content).strip() if content is not None else ""
1975+
19151976
def _extract_stream_text(self, chunk: Any) -> str:
19161977
"""Extract provider-agnostic text delta from a LiteLLM streaming chunk."""
19171978
choices = chunk.get("choices") if isinstance(chunk, dict) else getattr(chunk, "choices", None)
@@ -2120,8 +2181,8 @@ def _call_litellm(
21202181
router_model_names=router_model_names,
21212182
)
21222183

2123-
if response and response.choices and response.choices[0].message.content:
2124-
content = response.choices[0].message.content
2184+
content = self._extract_completion_text(response)
2185+
if content:
21252186
usage = self._normalize_usage(getattr(response, "usage", None))
21262187
last_response_text = content
21272188
last_model = model

tests/test_market_analyzer_generate_text.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,65 @@ def fake_dispatch(model, call_kwargs, **kwargs):
141141
assert dispatch_calls[0]["stream"] is True
142142
assert "stream" not in dispatch_calls[1]
143143

144+
def test_call_litellm_extracts_message_content_blocks_when_content_is_null(self):
145+
analyzer = self._make_analyzer()
146+
analyzer._config_override = SimpleNamespace(
147+
litellm_model="openai/cpa-compatible",
148+
litellm_fallback_models=[],
149+
llm_model_list=[],
150+
)
151+
response = SimpleNamespace(
152+
choices=[
153+
SimpleNamespace(
154+
message=SimpleNamespace(
155+
content=None,
156+
content_blocks=[
157+
{"type": "text", "text": "block "},
158+
{"type": "text", "text": "response"},
159+
],
160+
),
161+
)
162+
],
163+
usage=SimpleNamespace(prompt_tokens=2, completion_tokens=3, total_tokens=5),
164+
)
165+
166+
with patch.object(analyzer, "_dispatch_litellm_completion", return_value=response):
167+
text, model_used, usage = analyzer._call_litellm(
168+
"prompt",
169+
{"max_tokens": 128, "temperature": 0.2},
170+
)
171+
172+
assert text == "block response"
173+
assert model_used == "openai/cpa-compatible"
174+
assert usage == {"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5}
175+
176+
def test_call_litellm_falls_back_to_message_content_when_blocks_empty(self):
177+
analyzer = self._make_analyzer()
178+
analyzer._config_override = SimpleNamespace(
179+
litellm_model="openai/deepseek-chat",
180+
litellm_fallback_models=[],
181+
llm_model_list=[],
182+
)
183+
response = SimpleNamespace(
184+
choices=[
185+
SimpleNamespace(
186+
content_blocks=[],
187+
message=SimpleNamespace(content="message response"),
188+
)
189+
],
190+
usage=None,
191+
)
192+
193+
with patch.object(analyzer, "_dispatch_litellm_completion", return_value=response):
194+
text, model_used, usage = analyzer._call_litellm(
195+
"prompt",
196+
{"max_tokens": 128, "temperature": 0.2},
197+
)
198+
199+
assert text == "message response"
200+
assert model_used == "openai/deepseek-chat"
201+
assert usage == {}
202+
144203
def test_call_litellm_normalizes_kimi_k26_temperature(self):
145204
analyzer = self._make_analyzer()
146205
analyzer._config_override = SimpleNamespace(

0 commit comments

Comments
 (0)