fix(llm): normalise Mistral structured content in llm_call_async

llm_call_async returned raw list content for Mistral thinking models,
breaking callers that expect a str (e.g. auto-title). Match the sync
and streaming parsers by running list content through
_normalize_mistral_content.

Fixes #5435
This commit is contained in:
michaelxer 2026-08-03 17:35:29 +07:00
parent 25c9e735ef
commit a7a27053f1
2 changed files with 82 additions and 1 deletions

View file

@ -2109,7 +2109,17 @@ async def llm_call_async(
response = _parse_ollama_response(data)
else:
msg = data["choices"][0]["message"]
response = msg.get("content") or msg.get("reasoning_content") or ""
content = msg.get("content")
if isinstance(content, list):
# Mistral structured content — extract thinking + text
# (same contract as llm_call / stream_llm; see #5435).
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
response = thinking_part + "\n\n" + (text_part or "")
else:
response = text_part or msg.get("reasoning_content") or ""
else:
response = content or msg.get("reasoning_content") or ""
_set_cached_response(cache_key, response)
return response
except Exception:

View file

@ -0,0 +1,71 @@
"""Integration regression test for #5435.
llm_call_async must normalise Mistral structured content to a plain string,
matching llm_call (sync) and stream_llm. Before the fix, the async
non-streaming parser returned the raw list when Mistral reasoning was enabled,
violating its -> str contract, leaking a non-string into callers such as
auto-title generation and memory extraction, and poisoning _response_cache
with a non-string value.
"""
import asyncio
import src.llm_core as llm_core
class _FakeResponse:
is_success = True
status_code = 200
text = ""
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _payload(content):
return {"choices": [{"message": {"role": "assistant", "content": content}}]}
def _call(monkeypatch, content):
async def fake_post(client, url, headers, **kwargs):
return _FakeResponse(_payload(content))
monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
llm_core._response_cache.clear()
return asyncio.run(llm_core.llm_call_async(
"http://mistral.test/v1/chat/completions",
"mistral-medium",
[{"role": "user", "content": "q"}],
))
def test_llm_call_async_normalizes_mistral_structured_content(monkeypatch):
out = _call(monkeypatch, [
{"type": "thinking",
"thinking": [{"type": "text", "text": "Let me work through this..."}],
"closed": True},
{"type": "text", "text": "The answer is 42."},
])
assert isinstance(out, str), f"expected str, got {type(out).__name__}"
assert "The answer is 42." in out
assert "Let me work through this..." in out
# The cache must hold the normalised string, not the raw list,
# otherwise repeat calls serve the poisoned value.
assert all(isinstance(v, str) for v in llm_core._response_cache.values())
def test_llm_call_async_thinking_only_still_returns_str(monkeypatch):
out = _call(monkeypatch, [
{"type": "thinking",
"thinking": [{"type": "text", "text": "still thinking"}],
"closed": True},
])
assert isinstance(out, str)
assert "still thinking" in out
def test_llm_call_async_plain_string_passthrough(monkeypatch):
out = _call(monkeypatch, "plain answer")
assert out == "plain answer"