Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions studio/backend/models/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,26 @@ class ChatMessage(BaseModel):
"from assistant messages to replay text-part signatures."
),
)
reasoning_content: Optional[str] = Field(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scrub reasoning_content from safetensors client-tool path

Adding this typed field makes _openai_messages_for_passthrough() dump reasoning_content, but that helper is also reused for the safetensors/MLX client-tool templating path at routes/inference.py:11890-11893, not just for llama-server. In a non-GGUF request with client tools or tool history, prior assistant traces now reach the local template despite this change intending to defer that path, so either that path needs the same intentional preserve-thinking support or it should remove reasoning_content before templating.

Useful? React with 👍 / 👎.

None,
description = (
"Reasoning/thinking trace carried on assistant messages (DeepSeek-"
"style field, used by llama.cpp templates that branch on "
"`message.reasoning_content`). Forwarded verbatim to the backend so "
"the Jinja template can render it back into <think>...</think> "
"blocks when `preserve_thinking=true` is set. Without this field "
"the proxy silently drops the value (Pydantic default "
'extra="ignore"`), and multi-turn reasoning collapses to empty '
"thinking blocks."
),
)

@field_validator("reasoning_content", mode = "before")
@classmethod
def _ignore_non_string_reasoning(cls, value):
# Gateways emit structured reasoning too. That used to be dropped by extra="ignore";
# declaring the field would turn it into a 422, so keep dropping it instead.
return value if isinstance(value, str) else None

@model_validator(mode = "after")
def _validate_role_shape(self) -> "ChatMessage":
Expand Down
60 changes: 53 additions & 7 deletions studio/backend/routes/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,20 @@ def _estimate_message_tokens(msg: dict) -> int:
return 1


def _estimate_messages_tokens(messages: list) -> int:
"""Char-estimate of a whole message list.

Counts ``reasoning_content`` unconditionally. Whether a template renders prior
reasoning is not knowable here: Qwen3.5 gates on ``loop.index0 >
ns.last_query_index``, gemma-4 on that index ``or preserve_thinking``, and
Kimi-K2-Thinking splits at ``ns.last_non_tool_call_assistant_msg`` instead, while a
GGUF can carry any template at all. Under-counting leaves the retry above ``n_ctx``
and burns the retry budget, so the estimate stays conservative and the caller clips
reasoning first to shrink what over-counting would otherwise hold onto.
"""
return sum(_estimate_message_tokens(msg) for msg in messages)


def _truncate_middle_messages(messages: list, keep_ratio: float):
"""Drop whole turn-groups from the middle of an OpenAI message list.

Expand Down Expand Up @@ -527,7 +541,10 @@ def _truncate_middle_messages(messages: list, keep_ratio: float):
if len(groups) <= 1 + protected_tail:
return messages, 0

total_est = sum(_estimate_message_tokens(m) for m in messages)
# Size on what the template renders: an unrendered trace in a protected turn would
# otherwise be undroppable weight and force the whole middle out to hit the target.
est_of = {id(msg): _estimate_message_tokens(msg) for msg in messages}
total_est = sum(est_of.values())
target_est = int(total_est * keep_ratio)

anchor = groups[0]
Expand All @@ -541,7 +558,7 @@ def _truncate_middle_messages(messages: list, keep_ratio: float):
while kept_middle and current_est > target_est:
victim = kept_middle.pop(0)
dropped += len(victim)
current_est -= sum(_estimate_message_tokens(m) for m in victim)
current_est -= sum(est_of[id(m)] for m in victim)

if dropped == 0:
return messages, 0
Expand All @@ -559,6 +576,29 @@ def _truncate_middle_messages(messages: list, keep_ratio: float):
_CLIP_KEEP_CHARS = (1500, 400)


def _clip_reasoning_contents(messages: list, keep: int = _CLIP_KEEP_CHARS[-1]) -> int:
"""Clip every oversized assistant ``reasoning_content`` middle-out.

Runs before the prompt is sized, and unconditionally, for two reasons. Unlike
group-dropping it can shrink a protected turn, so a huge trace in the anchor or tail
stops forcing the whole middle out. And the overflow target is a fraction of the
estimate, so a giant trace inflates the target as well as the total and a
target-gated clip would stop while the trace still outweighed the conversation it is
competing with. Reasoning is the most expendable content on overflow, so on this
recovery path it goes to the tight budget straight away.
"""
clipped = 0
for msg in messages:
if msg.get("role") != "assistant":
continue
rc = msg.get("reasoning_content")
if not isinstance(rc, str) or len(rc) <= 2 * keep + len(_CLIP_MARKER):
continue
msg["reasoning_content"] = rc[:keep] + _CLIP_MARKER + rc[-keep:]
clipped += 1
return clipped


def _clip_long_contents(messages: list, target_est: int) -> int:
"""Clip oversized string contents middle-out until ``target_est`` is met.

Expand Down Expand Up @@ -592,7 +632,9 @@ def _apply_overflow_truncation(body: dict, err_text: str) -> bool:
to the generation headroom. Returns False when nothing could shrink."""
counts = _parse_overflow_counts(err_text)
messages = body.get("messages") or []
total_est = sum(_estimate_message_tokens(m) for m in messages)
# Before sizing: see _clip_reasoning_contents for why this cannot wait for a target.
clipped = _clip_reasoning_contents(messages)
total_est = _estimate_messages_tokens(messages)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid dropping history after clipping reasoning

When the upstream overflow was caused mostly by a huge preserved reasoning_content, this new pre-sizing clip can already shrink the retry body below the usable context window, but total_est is then multiplied by a keep_ratio that is still based on the original pre-clip n_prompt_tokens. In that case a modest conversation with a 40k-character trace gets clipped and still has most middle turns dropped even though the clipped prompt estimate would fit, defeating the goal of clipping traces before evicting history; recompute/short-circuit the drop target after the clip instead of applying the stale original overflow ratio.

Useful? React with 👍 / 👎.

if counts:
n_prompt, n_ctx = counts
keep_ratio = min(0.95, (_OVERFLOW_PROMPT_TARGET_FRACTION * n_ctx) / max(1, n_prompt))
Expand All @@ -605,9 +647,8 @@ def _apply_overflow_truncation(body: dict, err_text: str) -> bool:
new_messages, dropped = _truncate_middle_messages(messages, keep_ratio)
if dropped:
body["messages"] = new_messages
clipped = 0
if sum(_estimate_message_tokens(m) for m in body.get("messages") or []) > target_est:
clipped = _clip_long_contents(body.get("messages") or [], target_est)
if _estimate_messages_tokens(body.get("messages") or []) > target_est:
clipped += _clip_long_contents(body.get("messages") or [], target_est)
if not dropped and not clipped:
return False
if n_ctx:
Expand Down Expand Up @@ -16998,8 +17039,13 @@ def _drop_empty_assistant_sentinels(messages: list[dict]) -> list[dict]:
if m.get("role") == "assistant":
has_content = bool(m.get("content"))
has_tool_calls = bool(m.get("tool_calls"))
if not has_content and not has_tool_calls:
has_reasoning = bool(m.get("reasoning_content"))
if not has_content and not has_tool_calls and not has_reasoning:
continue
if not has_content and not has_tool_calls:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve reasoning after synthetic tool-call scrubbing

When a prior assistant turn has reasoning_content plus only provider-synthetic builtin tool_calls (the _server_tool / Gemini native_part cases handled by _strip_provider_synthetic_tool_history), this padding path does not run because has_tool_calls is true; the scrubber then removes all of those tool calls and its existing empty-turn check at routes/inference.py:16987 drops the whole message because it still ignores reasoning_content. That loses the preserved reasoning exactly when switching such a history to local GGUF, so the scrubber also needs to treat reasoning_content as content and add the empty content key after removing synthetic calls.

Useful? React with 👍 / 👎.

# llama.cpp requires a content or tool_calls key and only reads
# reasoning_content after that check, so keep the turn but give it one.
m = {**m, "content": ""}
out.append(m)
return out

Expand Down
61 changes: 61 additions & 0 deletions studio/backend/tests/test_context_overflow_truncation.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,64 @@ def test_v1_models_exposes_real_context_window(monkeypatch):
# The REAL (post /props readback) window, not the requested one.
assert entry["context_length"] == 67584
assert entry["max_context_length"] == 262144


def _conversation_with_big_trace(trace_chars = 40000):
"""A normal turn list whose last assistant turn carries a huge reasoning trace.

That turn is inside the protected tail, so group-dropping can never shrink it.
"""
msgs = [{"role": "system", "content": "sys"}]
for i in range(8):
msgs.append({"role": "user", "content": f"question {i} " + "u" * 200})
msgs.append({"role": "assistant", "content": f"answer {i} " + "a" * 200})
msgs[-1]["reasoning_content"] = "t" * trace_chars
msgs.append({"role": "user", "content": "final question"})
return msgs


def test_estimate_counts_reasoning_conservatively():
# Templates disagree on when prior reasoning renders (Qwen3.5 gates on the last user
# turn, Kimi-K2-Thinking on the last non-tool-call assistant turn), and a GGUF can
# carry any template, so the estimate must never assume a trace is free.
msgs = _conversation_with_big_trace()
assert routes_mod._estimate_messages_tokens(msgs) == sum(
_estimate_message_tokens(m) for m in msgs
)


def test_reasoning_clip_shrinks_a_protected_turn():
# The trace sits in the protected tail, which group-dropping can never reach.
msgs = _conversation_with_big_trace()
before = routes_mod._estimate_messages_tokens(msgs)
assert routes_mod._clip_reasoning_contents(msgs) == 1
assert _CLIP_MARKER in msgs[-2]["reasoning_content"]
assert routes_mod._estimate_messages_tokens(msgs) < before / 5


def test_reasoning_clip_leaves_short_traces_alone():
msgs = [{"role": "assistant", "content": "a", "reasoning_content": "short"}]
assert routes_mod._clip_reasoning_contents(msgs) == 0
assert msgs[0]["reasoning_content"] == "short"


def test_big_trace_no_longer_evicts_the_whole_middle():
# Regression: the trace sat in a protected turn, so the drop loop could never shed its
# weight and evicted extra history chasing a target the trace itself had inflated.
# Clipping reasoning first makes the outcome independent of the trace.
err = (
"the request exceeds the available context size. try increasing the context size "
"or enable context shift: n_prompt_tokens = 9000, n_ctx = 4096"
)
with_trace = {"messages": _conversation_with_big_trace()}
without_trace = {"messages": _conversation_with_big_trace(trace_chars = 0)}
without_trace["messages"][-2].pop("reasoning_content", None)

assert routes_mod._apply_overflow_truncation(with_trace, err) is True
assert routes_mod._apply_overflow_truncation(without_trace, err) is True
# Within one turn-group of the trace-free run: the trace is no longer what drives
# eviction. It was 6 groups worse before the clip moved ahead of the sizing.
assert abs(len(with_trace["messages"]) - len(without_trace["messages"])) <= 2
# The trace was clipped rather than paid for by dropping conversation.
traces = [m for m in with_trace["messages"] if m.get("reasoning_content")]
assert traces and _CLIP_MARKER in traces[0]["reasoning_content"]
86 changes: 86 additions & 0 deletions studio/backend/tests/test_openai_tool_passthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -1864,6 +1864,92 @@ def test_openai_messages_for_passthrough_drops_sentinel(self):
for m in out:
assert m.get("content"), m

def test_keeps_reasoning_only_assistant_and_pads_content(self):
"""A reasoning-only assistant turn (no content/tool_calls, reasoning_content set)
is preserved and gains ``content=\"\"`` so llama-server sees the key it requires.
A truly empty assistant turn (no content/tool_calls/reasoning_content) is still
dropped."""
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "reasoning_content": "I am thinking..."},
{"role": "assistant"},
]
out = _drop_empty_assistant_sentinels(msgs)
assert len(out) == 2
# Kept turn got content="" so llama-server has the required key.
assert out[0]["role"] == "user"
assert out[1]["role"] == "assistant"
assert out[1]["reasoning_content"] == "I am thinking..."
assert out[1]["content"] == ""
# Truly empty sentinel was still dropped.
assert all(
m["role"] != "assistant"
or m.get("content")
or m.get("tool_calls")
or m.get("reasoning_content")
for m in out
)

def test_openai_messages_for_passthrough_forwards_reasoning_content(self):
"""``reasoning_content`` on an assistant message must reach the wire
byte-identical: the Jinja template branches on it when
``preserve_thinking=true``."""
req = ChatCompletionRequest(
model = "default",
messages = [
ChatMessage(role = "user", content = "hi"),
ChatMessage(
role = "assistant",
content = "answer",
reasoning_content = "step-by-step trace \u00e9",
),
],
)
out = _openai_messages_for_passthrough(req)
assistant = [m for m in out if m["role"] == "assistant"][0]
assert assistant["reasoning_content"] == "step-by-step trace \u00e9"
assert assistant["content"] == "answer"

def test_openai_messages_for_gguf_chat_forwards_reasoning_content(self):
"""GGUF chat path must also forward ``reasoning_content`` verbatim."""
req = ChatCompletionRequest(
model = "default",
messages = [
ChatMessage(role = "user", content = "hi"),
ChatMessage(
role = "assistant",
content = "answer",
reasoning_content = "long thinking \u00e9",
),
],
)
messages, _has_image = _openai_messages_for_gguf_chat(req, is_vision = False)
assistant = [m for m in messages if m["role"] == "assistant"][0]
assert assistant["reasoning_content"] == "long thinking \u00e9"
assert assistant["content"] == "answer"

def test_strip_provider_synthetic_tool_history_preserves_reasoning_content(self):
"""``reasoning_content`` is a real field, not Gemini-synthetic garbage:
``_strip_provider_synthetic_tool_history`` must NOT scrub it the way
it scrubs ``extra_content``."""
from routes.inference import _strip_provider_synthetic_tool_history

msgs = [
{
"role": "assistant",
"content": "ok",
"reasoning_content": "i thought about it",
"extra_content": {"google": {"thought_signature": "deadbeef"}},
}
]
out = _strip_provider_synthetic_tool_history(msgs)
assert len(out) == 1
kept = out[0]
# Asymmetry lock: reasoning_content is real and survives, extra_content is
# Gemini-synthetic and is the actual target of the scrubber.
assert kept.get("reasoning_content") == "i thought about it"
assert "extra_content" not in kept


class TestGgufVisionMessages:
_PNG_B64 = (
Expand Down
45 changes: 45 additions & 0 deletions studio/backend/tests/test_tool_message_empty_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,48 @@ def test_user_message_still_requires_content():
def test_assistant_empty_content_still_collapses_to_none():
msg = ChatMessage(role = "assistant", content = "")
assert msg.content is None


# ---------------------------------------------------------------------------
# reasoning_content: parse-level contract for the multi-turn thinking passthrough
# ---------------------------------------------------------------------------


def test_assistant_reasoning_content_round_trips_and_survives_dump():
"""A string ``reasoning_content`` on an assistant turn must survive Pydantic
parsing and ``model_dump(exclude_none=True)`` so the proxy can forward it
to llama-server for ``preserve_thinking=true`` rendering."""
msg = ChatMessage(
role = "assistant",
content = "answer",
reasoning_content = "x",
)
assert msg.reasoning_content == "x"
dumped = msg.model_dump(exclude_none = True)
assert dumped["reasoning_content"] == "x"


def test_assistant_non_string_reasoning_becomes_none_not_validation_error():
"""Gateways emit structured reasoning too. The before-validator must drop it
to None instead of raising — declaring the field used to turn previously
ignored payloads into 422s."""
structured = [{"type": "reasoning", "text": "x"}]
msg = ChatMessage(role = "assistant", content = "ok", reasoning_content = structured)
assert msg.reasoning_content is None
msg = ChatMessage(role = "assistant", content = "ok", reasoning_content = {"text": "x"})
assert msg.reasoning_content is None
msg = ChatMessage(role = "assistant", content = "ok", reasoning_content = 42)
assert msg.reasoning_content is None


def test_assistant_reasoning_content_absent_from_dump_when_unset():
"""An assistant turn without ``reasoning_content`` must not gain a null
key on dump: ``exclude_none=True`` already drops it, but ``model_dump``
without ``exclude_none`` should also leave it absent (the field's default
is None, and the validator returns None on unknown, not a sentinel)."""
msg = ChatMessage(role = "assistant", content = "ok")
assert msg.reasoning_content is None
dumped = msg.model_dump()
assert "reasoning_content" not in dumped or dumped["reasoning_content"] is None
dumped_excl = msg.model_dump(exclude_none = True)
assert "reasoning_content" not in dumped_excl
Loading