Files
jundot--omlx/tests/test_output_parser.py
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

836 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Tests for protocol-specific output parser sessions."""
from __future__ import annotations
import json
import sys
import types
from types import SimpleNamespace
from omlx.adapter.gemma4 import Gemma4OutputParserSession
from omlx.adapter.harmony import load_harmony_gpt_oss_encoding
from omlx.adapter.output_parser import detect_output_parser
class FakeDetokenizer:
def __init__(self, decode_one):
self._decode_one = decode_one
self.last_segment = ""
def reset(self):
self.last_segment = ""
def add_token(self, token_id: int):
self.last_segment = self._decode_one(token_id)
def finalize(self):
self.last_segment = ""
class GemmaTokenizer:
def __init__(self, token_map: dict[int, str]):
self._token_map = token_map
@property
def detokenizer(self):
return FakeDetokenizer(lambda token_id: self._token_map[token_id])
def decode(self, token_ids, skip_special_tokens: bool = True):
return "".join(self._token_map[token_id] for token_id in token_ids)
class HarmonyTokenizer:
def __init__(self, encoding):
self._encoding = encoding
def convert_tokens_to_ids(self, token: str) -> int:
ids = self._encoding.encode(token, allowed_special="all")
return ids[0] if ids else -1
def decode(self, token_ids, skip_special_tokens: bool = True):
return self._encoding.decode(token_ids)
@property
def detokenizer(self):
return FakeDetokenizer(lambda token_id: self._encoding.decode([token_id]))
class CohereTokenizer:
def __init__(self, token_map: dict[int, str]):
self._token_map = token_map
@property
def detokenizer(self):
return FakeDetokenizer(lambda token_id: self._token_map[token_id])
def decode(self, token_ids, skip_special_tokens: bool = True):
return "".join(self._token_map[token_id] for token_id in token_ids)
class DeepSeekV4Tokenizer(CohereTokenizer):
has_tool_calling = True
tool_call_start = "<DSMLtool_calls>"
tool_call_end = "</DSMLtool_calls>"
def tool_parser(self, text: str, tools=None):
from omlx.patches.deepseek_v4.tool_parser_v4 import parse_tool_call
return parse_tool_call(text, tools)
class _FakeMelodyOptions:
def cmd4(self):
return self
def stream_tool_actions(self):
return self
class _FakeMelodyFilter:
def __init__(self, options):
self.options = options
def write_decoded(self, decoded_text: str):
if decoded_text.startswith("R:"):
return SimpleNamespace(
content=None,
reasoning=decoded_text[2:],
tool_calls=[],
)
if decoded_text.startswith("C:"):
return SimpleNamespace(
content=decoded_text[2:],
reasoning=None,
tool_calls=[],
)
if decoded_text.startswith("T1"):
tool_call = SimpleNamespace(
index=0,
id="call_",
name="look",
arguments='{"q"',
)
return SimpleNamespace(content=None, reasoning=None, tool_calls=[tool_call])
if decoded_text.startswith("T2"):
tool_call = SimpleNamespace(
index=0,
id="1",
name="up",
arguments=':"x"}',
)
return SimpleNamespace(content=None, reasoning=None, tool_calls=[tool_call])
return SimpleNamespace(content=None, reasoning=None, tool_calls=[])
def flush_partials(self):
return SimpleNamespace(content=None, reasoning=None, tool_calls=[])
def _install_fake_melody(monkeypatch):
module = types.ModuleType("cohere_melody")
module.PyFilter = _FakeMelodyFilter
module.PyFilterOptions = _FakeMelodyOptions
monkeypatch.setitem(sys.modules, "cohere_melody", module)
def _write_json(path, data):
path.write_text(json.dumps(data))
def _spm_decoder():
return {
"type": "Sequence",
"decoders": [
{
"type": "Replace",
"pattern": {"String": "\u2581"},
"content": " ",
},
{"type": "ByteFallback"},
{"type": "Fuse"},
{"type": "Strip", "content": " ", "start": 1, "stop": 0},
],
}
class ByteFallbackTokenizer:
clean_up_tokenization_spaces = False
vocab = {
"<pad>": 0,
"<0xEC>": 1,
"<0x9E>": 2,
"<0xA0>": 3,
}
def decode(self, token_ids, skip_special_tokens: bool = True):
table = {
0: b"",
1: bytes([0xEC]),
2: bytes([0x9E]),
3: bytes([0xA0]),
}
raw = b"".join(table[token_id] for token_id in token_ids)
if not raw:
return ""
if raw == bytes([0xEC, 0x9E, 0xA0]):
return "\uc7a0"
return "\ufffd" * sum(1 for token_id in token_ids if token_id != 0)
class TestCohere2MoeOutputParserSession:
def test_detects_cohere2_moe_from_model_config(self, monkeypatch):
_install_fake_melody(monkeypatch)
tokenizer = CohereTokenizer({1: "C:hello"})
factory = detect_output_parser(
"North-Mini-Code",
tokenizer,
{"model_type": "cohere2_moe"},
)
assert factory is not None
assert factory.kind == "cohere2_moe"
def test_streams_reasoning_as_think_block_and_visible_content(self, monkeypatch):
_install_fake_melody(monkeypatch)
tokenizer = CohereTokenizer(
{
1: "R:reasoning",
2: "C:answer",
}
)
factory = detect_output_parser(
"North-Mini-Code",
tokenizer,
{"model_type": "cohere2_moe"},
)
session = factory.create_session(tokenizer)
parts = []
visible = []
for token_id in [1, 2]:
result = session.process_token(token_id)
parts.append(result.stream_text)
visible.append(result.visible_text)
final = session.finalize()
parts.append(final.stream_text)
visible.append(final.visible_text)
assert "".join(parts) == "<think>\nreasoning</think>\nanswer"
assert "".join(visible) == "<think>\nreasoning</think>\nanswer"
assert final.tool_calls == []
assert final.finish_reason is None
def test_accumulates_streamed_tool_call_deltas(self, monkeypatch):
_install_fake_melody(monkeypatch)
tokenizer = CohereTokenizer({1: "T1", 2: "T2"})
factory = detect_output_parser(
"North-Mini-Code",
tokenizer,
{"model_type": "cohere2_moe"},
)
session = factory.create_session(tokenizer)
assert session.process_token(1).stream_text == ""
assert session.process_token(2).stream_text == ""
final = session.finalize()
assert final.tool_calls == [
{
"id": "call_1",
"name": "lookup",
"arguments": '{"q":"x"}',
}
]
assert final.finish_reason == "tool_calls"
def test_literal_newline_in_arguments_is_reescaped(self, monkeypatch):
"""Melody may stream literal control chars when the model emits them inside
JSON string values (e.g. newlines inside code arguments). finalize() must
re-serialize the accumulated arguments so they are valid JSON."""
# Build a fake Melody that returns arguments containing a literal newline
# (U+000A) inside the JSON string value, as the real model sometimes does.
literal_newline_args = '{"path":"f.py","code":"line1\nline2"}' # literal \n
class _FakeMelodyFilterLiteralNewline:
def __init__(self, options):
pass
def write_decoded(self, decoded_text: str):
if decoded_text == "TC":
tc = SimpleNamespace(
index=0,
id="call_1",
name="edit",
arguments=literal_newline_args,
)
return SimpleNamespace(
content=None, reasoning=None, tool_calls=[tc]
)
return SimpleNamespace(content=None, reasoning=None, tool_calls=[])
def flush_partials(self):
return SimpleNamespace(content=None, reasoning=None, tool_calls=[])
import types, json as _json
module = types.ModuleType("cohere_melody")
module.PyFilter = _FakeMelodyFilterLiteralNewline
module.PyFilterOptions = _FakeMelodyOptions
monkeypatch.setitem(__import__("sys").modules, "cohere_melody", module)
tokenizer = CohereTokenizer({"TC": "TC"})
from omlx.adapter.output_parser import Cohere2MoeOutputParserSession
session = Cohere2MoeOutputParserSession.__new__(Cohere2MoeOutputParserSession)
session._tokenizer = tokenizer
session._melody = _FakeMelodyFilterLiteralNewline(None)
session._detokenizer = None
session._thinking_started = False
session._thinking_closed = False
session._tool_calls = {}
session.process_token("TC")
final = session.finalize()
assert len(final.tool_calls) == 1
args_str = final.tool_calls[0]["arguments"]
# Must be valid strict JSON (no literal control characters)
parsed = _json.loads(args_str)
assert parsed["code"] == "line1\nline2"
# The literal newline must have been escaped
assert "\n" not in args_str or "\\n" in args_str
class TestGemma4OutputParserSession:
def test_normal_reasoning_block(self):
token_map = {
1: "<|channel>",
2: "thought\n",
3: "reasoning",
4: "<channel|>",
5: "final answer",
}
tokenizer = GemmaTokenizer(token_map)
session = Gemma4OutputParserSession(tokenizer)
stream = []
visible = []
for token_id in [1, 2, 3, 4, 5]:
result = session.process_token(token_id)
stream.append(result.stream_text)
visible.append(result.visible_text)
final = session.finalize()
stream.append(final.stream_text)
visible.append(final.visible_text)
full_stream = "".join(stream)
full_visible = "".join(visible)
assert full_stream == "<think>\nreasoning</think>\nfinal answer"
assert full_visible == full_stream
assert "<|channel>" not in full_stream
assert "<channel|>" not in full_stream
def test_empty_thought_block(self):
token_map = {
1: "<|channel>thought\n",
2: "<channel|>",
3: "answer",
}
tokenizer = GemmaTokenizer(token_map)
session = Gemma4OutputParserSession(tokenizer)
parts = []
for token_id in [1, 2, 3]:
parts.append(session.process_token(token_id).stream_text)
parts.append(session.finalize().stream_text)
assert "".join(parts) == "<think>\n</think>\nanswer"
def test_partial_marker_across_tokens(self):
token_map = {
1: "<|chan",
2: "nel>thought\nstep 1",
3: " and step 2<chan",
4: "nel|>",
5: "done",
}
tokenizer = GemmaTokenizer(token_map)
session = Gemma4OutputParserSession(tokenizer)
parts = []
for token_id in [1, 2, 3, 4, 5]:
parts.append(session.process_token(token_id).stream_text)
parts.append(session.finalize().stream_text)
text = "".join(parts)
assert text == "<think>\nstep 1 and step 2</think>\ndone"
assert "<|channel>thought" not in text
assert "<channel|>" not in text
def test_suppresses_turn_end_marker(self):
token_map = {
1: "<|channel>thought\n",
2: "reasoning",
3: "<channel|>",
4: "answer",
5: "<turn|>",
}
tokenizer = GemmaTokenizer(token_map)
session = Gemma4OutputParserSession(tokenizer)
parts = []
for token_id in [1, 2, 3, 4, 5]:
result = session.process_token(token_id)
parts.append(result.stream_text)
assert "<turn|>" not in result.stream_text
assert "<turn|>" not in result.visible_text
parts.append(session.finalize().stream_text)
text = "".join(parts)
assert text == "<think>\nreasoning</think>\nanswer"
assert "<turn|>" not in text
def test_stray_close_marker_outside_thought_dropped(self):
"""A bare ``<channel|>`` after the thought block already closed must
not leak into visible content. Models occasionally emit one in long
multi-turn contexts and the SDK rejects it as raw markup."""
token_map = {
1: "<|channel>thought\n",
2: "reasoning",
3: "<channel|>",
4: "answer",
5: "<channel|>",
6: "more",
}
tokenizer = GemmaTokenizer(token_map)
session = Gemma4OutputParserSession(tokenizer)
parts = []
for token_id in [1, 2, 3, 4, 5, 6]:
parts.append(session.process_token(token_id).stream_text)
parts.append(session.finalize().stream_text)
text = "".join(parts)
assert text == "<think>\nreasoning</think>\nanswermore"
assert "<channel|>" not in text
def test_stray_open_marker_inside_thought_dropped(self):
"""A nested ``<|channel>thought\\n`` while already inside a thought
block must not re-emit ``<think>``. The block stays open until the
first matching close marker."""
token_map = {
1: "<|channel>thought\n",
2: "step 1",
3: "<|channel>thought\n",
4: "step 2",
5: "<channel|>",
6: "answer",
}
tokenizer = GemmaTokenizer(token_map)
session = Gemma4OutputParserSession(tokenizer)
parts = []
for token_id in [1, 2, 3, 4, 5, 6]:
parts.append(session.process_token(token_id).stream_text)
parts.append(session.finalize().stream_text)
text = "".join(parts)
assert text == "<think>\nstep 1step 2</think>\nanswer"
assert text.count("<think>\n") == 1
assert text.count("</think>\n") == 1
def test_tool_call_markers_pass_through(self):
"""Tool-call markup must reach the buffered output text untouched so
``parse_tool_calls`` can extract the call. ``ToolCallStreamFilter``
downstream is responsible for removing it from stream deltas."""
token_map = {
1: "<|channel>thought\n",
2: "calling",
3: "<channel|>",
4: "<|tool_call>",
5: "call:bash{cmd:ls}",
6: "<tool_call|>",
7: "done",
}
tokenizer = GemmaTokenizer(token_map)
session = Gemma4OutputParserSession(tokenizer)
stream_parts = []
visible_parts = []
for token_id in [1, 2, 3, 4, 5, 6, 7]:
result = session.process_token(token_id)
stream_parts.append(result.stream_text)
visible_parts.append(result.visible_text)
final = session.finalize()
stream_parts.append(final.stream_text)
visible_parts.append(final.visible_text)
stream_text = "".join(stream_parts)
visible_text = "".join(visible_parts)
assert stream_text == visible_text
assert "<|tool_call>" in stream_text
assert "<tool_call|>" in stream_text
assert "call:bash{cmd:ls}" in stream_text
def test_spm_fallback_buffers_split_utf8(self, tmp_path):
_write_json(tmp_path / "tokenizer.json", {"decoder": _spm_decoder()})
session = Gemma4OutputParserSession(
ByteFallbackTokenizer(),
model_path=tmp_path,
)
parts = []
for token_id in [1, 2, 3]:
parts.append(session.process_token(token_id).stream_text)
parts.append(session.finalize().stream_text)
text = "".join(parts)
assert text == "\uc7a0"
assert "\ufffd" not in text
class TestOutputParserFactory:
def test_detects_deepseek_v4_by_config(self):
tokenizer = DeepSeekV4Tokenizer({1: "x"})
factory = detect_output_parser(
"DeepSeek-V4-Flash-oQ4e",
tokenizer,
{"model_type": "deepseek_v4"},
)
assert factory is not None
assert factory.kind == "deepseek_v4"
def test_deepseek_v4_stops_at_first_dsml_tool_block(self):
tokenizer = DeepSeekV4Tokenizer(
{
1: "Before ",
2: "<DSMLtool",
3: '_calls>\n<DSMLinvoke name="Bash">\n',
4: '<DSMLparameter name="command" string="true">ls</DSMLparameter>\n'
"</DSMLinvoke>\n",
5: "</DSMLtool_calls>",
}
)
factory = detect_output_parser(
"DeepSeek-V4-Flash-oQ4e",
tokenizer,
{"model_type": "deepseek_v4"},
)
session = factory.create_session(tokenizer)
stream = []
visible = []
stop_seen = False
for token_id in [1, 2, 3, 4, 5]:
result = session.process_token(token_id)
stream.append(result.stream_text)
visible.append(result.visible_text)
stop_seen = stop_seen or result.is_stop
assert result.record_token is True
final = session.finalize()
stream.append(final.stream_text)
visible.append(final.visible_text)
assert stop_seen is True
assert "".join(stream) == "Before "
assert "".join(visible) == "Before "
assert final.finish_reason == "tool_calls"
assert len(final.tool_calls) == 1
assert final.tool_calls[0]["name"] == "Bash"
assert json.loads(final.tool_calls[0]["arguments"]) == {"command": "ls"}
def test_deepseek_v4_drops_text_after_tool_end_in_same_token(self):
tokenizer = DeepSeekV4Tokenizer(
{
1: '<DSMLtool_calls>\n<DSMLinvoke name="Bash">\n',
2: '<DSMLparameter name="command" string="true">ls</DSMLparameter>\n'
"</DSMLinvoke>\n",
3: "</DSMLtool_calls>\n"
'<DSMLparameter name="command" string="true">pwd</DSMLparameter>',
}
)
factory = detect_output_parser(
"DeepSeek-V4-Flash-oQ4e",
tokenizer,
{"model_type": "deepseek_v4"},
)
session = factory.create_session(tokenizer)
stream = []
stop_seen = False
for token_id in [1, 2, 3]:
result = session.process_token(token_id)
stream.append(result.stream_text)
stop_seen = stop_seen or result.is_stop
final = session.finalize()
stream.append(final.stream_text)
assert stop_seen is True
assert "".join(stream) == ""
assert final.finish_reason == "tool_calls"
assert len(final.tool_calls) == 1
assert json.loads(final.tool_calls[0]["arguments"]) == {"command": "ls"}
def test_detects_minimax_m3_by_config(self):
tokenizer = CohereTokenizer({1: "x"})
factory = detect_output_parser(
"MiniMax-M3-4bit",
tokenizer,
{"model_type": "minimax_m3_vl"},
)
assert factory is not None
assert factory.kind == "minimax_m3"
def test_minimax_m3_parser_extracts_tool_calls(self, monkeypatch):
module = types.ModuleType("mlx_vlm.tool_parsers.minimax_m3")
def parse_tool_call(text):
assert "lookup" in text
return {"name": "lookup", "arguments": {"query": "mlx"}}
module.parse_tool_call = parse_tool_call
monkeypatch.setitem(sys.modules, "mlx_vlm.tool_parsers.minimax_m3", module)
start = "]<]minimax[>[<tool_call>"
end = "]<]minimax[>[</tool_call>"
tokenizer = CohereTokenizer(
{
1: "before ",
2: start,
3: ']<]minimax[>[<invoke name="lookup">',
4: "]<]minimax[>[</invoke>",
5: end,
6: " after",
}
)
factory = detect_output_parser(
"MiniMax-M3-4bit",
tokenizer,
{"model_type": "minimax_m3_vl"},
)
session = factory.create_session(tokenizer)
visible = []
stream = []
for token_id in [1, 2, 3, 4, 5, 6]:
result = session.process_token(token_id)
stream.append(result.stream_text)
visible.append(result.visible_text)
final = session.finalize()
assert "".join(stream) == "before after"
assert start not in "".join(stream)
assert "".join(visible) + final.visible_text == "before after"
assert final.tool_calls == [{"name": "lookup", "arguments": '{"query":"mlx"}'}]
assert final.finish_reason == "tool_calls"
def test_minimax_m3_parser_normalizes_thinking_and_strips_eos(self):
tokenizer = CohereTokenizer(
{
1: "<mm:think>",
2: "reasoning",
3: "</mm:think>",
4: "Answer",
5: "[e~[",
6: "]!d~[",
}
)
factory = detect_output_parser(
"MiniMax-M3-4bit",
tokenizer,
{"model_type": "minimax_m3_vl"},
)
session = factory.create_session(tokenizer)
stream = []
visible = []
stop_seen = False
record_flags = []
for token_id in [1, 2, 3, 4, 6, 5]:
result = session.process_token(token_id)
stream.append(result.stream_text)
visible.append(result.visible_text)
stop_seen = stop_seen or result.is_stop
record_flags.append(result.record_token)
final = session.finalize()
stream.append(final.stream_text)
visible.append(final.visible_text)
assert "".join(stream) == "<think>reasoning</think>Answer"
assert "".join(visible) == "<think>reasoning</think>Answer"
assert stop_seen is True
assert record_flags[-1] is False
def test_minimax_m3_factory_exposes_native_thinking_markers(self):
tokenizer = CohereTokenizer({})
tokenizer.convert_tokens_to_ids = lambda text: {
"[e~[": 200020,
"<mm:think>": 200059,
"</mm:think>": 200060,
}.get(text, -1)
tokenizer.unk_token_id = -1
factory = detect_output_parser(
"MiniMax-M3-4bit",
tokenizer,
{"model_type": "minimax_m3_vl"},
)
assert factory.thinking_start_text == "<mm:think>"
assert factory.thinking_start_output_text == "<think>\n"
assert factory.thinking_end_text == "</mm:think>"
assert factory.stop_token_ids == {200020}
def test_detects_gemma4(self):
tokenizer = GemmaTokenizer({1: "x"})
factory = detect_output_parser(
"google/gemma-4b",
tokenizer,
{"model_type": "gemma4"},
)
assert factory is not None
assert factory.kind == "gemma4"
def test_session_receives_model_path_when_provided(self, monkeypatch):
"""Since #2178 the scheduler's model_name is a display id, so the
filesystem path must reach parser sessions via model_path."""
import omlx.adapter.output_parser as output_parser_module
seen = {}
class RecordingSession:
def __init__(self, tokenizer, model_path=None):
seen["model_path"] = model_path
monkeypatch.setattr(
output_parser_module, "MiniMaxM3OutputParserSession", RecordingSession
)
tokenizer = CohereTokenizer({})
tokenizer.convert_tokens_to_ids = lambda text: -1
tokenizer.unk_token_id = -1
factory = detect_output_parser(
"MiniMax-M3-4bit",
tokenizer,
{"model_type": "minimax_m3_vl"},
model_path="/models/minimax-m3",
)
factory.create_session(tokenizer)
assert seen["model_path"] == "/models/minimax-m3"
def test_session_falls_back_to_model_name_without_model_path(self, monkeypatch):
"""dflash/vlm engines pass their filesystem path as model_name and no
model_path, so the session fallback must keep using model_name."""
import omlx.adapter.output_parser as output_parser_module
seen = {}
class RecordingSession:
def __init__(self, tokenizer, model_path=None):
seen["model_path"] = model_path
monkeypatch.setattr(
output_parser_module, "MiniMaxM3OutputParserSession", RecordingSession
)
tokenizer = CohereTokenizer({})
tokenizer.convert_tokens_to_ids = lambda text: -1
tokenizer.unk_token_id = -1
factory = detect_output_parser(
"/models/MiniMax-M3-4bit",
tokenizer,
{"model_type": "minimax_m3_vl"},
)
factory.create_session(tokenizer)
assert seen["model_path"] == "/models/MiniMax-M3-4bit"
def test_detects_gemma4_unified_by_config(self):
tokenizer = GemmaTokenizer({1: "x"})
factory = detect_output_parser(
"some-model",
tokenizer,
{"model_type": "gemma4_unified"},
)
assert factory is not None
assert factory.kind == "gemma4"
def test_harmony_wrapper_regression(self):
encoding = load_harmony_gpt_oss_encoding()
tokenizer = HarmonyTokenizer(encoding)
factory = detect_output_parser(
"gpt-oss-20b",
tokenizer,
{"model_type": "gpt_oss"},
)
assert factory is not None
assert factory.kind == "harmony"
session = factory.create_session(tokenizer)
tokens = encoding.encode(
"<|channel|>analysis<|message|>thinking<|end|>"
"<|start|>assistant<|channel|>final<|message|>Answer<|return|>",
allowed_special="all",
)
stream = []
visible = []
saw_stop = False
for token in tokens:
result = session.process_token(token)
stream.append(result.stream_text)
visible.append(result.visible_text)
saw_stop = saw_stop or result.is_stop
final = session.finalize()
stream.append(final.stream_text)
visible.append(final.visible_text)
assert saw_stop is True
assert "<think>\n" in "".join(stream)
assert "</think>\n" in "".join(stream)
assert "".join(visible) == "Answer"
def test_harmony_non_streaming_preserves_reasoning(self):
"""Non-streaming output_text retains analysis-channel reasoning."""
from omlx.api.thinking import extract_thinking
encoding = load_harmony_gpt_oss_encoding()
tokenizer = HarmonyTokenizer(encoding)
factory = detect_output_parser(
"gpt-oss-20b",
tokenizer,
{"model_type": "gpt_oss"},
)
session = factory.create_session(tokenizer)
tokens = encoding.encode(
"<|channel|>analysis<|message|>Let me think about this<|end|>"
"<|start|>assistant<|channel|>final<|message|>Four<|return|>",
allowed_special="all",
)
visible_parts = []
for token in tokens:
result = session.process_token(token)
visible_parts.append(result.visible_text)
final = session.finalize()
visible_parts.append(final.visible_text)
# Mirror scheduler aggregation: prepend any parser-provided prefix
# to the accumulated visible_text before exposing as output_text.
prefix = getattr(final, "output_text_prefix", "")
output_text = prefix + "".join(visible_parts)
thinking, content = extract_thinking(output_text)
assert thinking == "Let me think about this"
assert content == "Four"