-
-
Notifications
You must be signed in to change notification settings - Fork 6.3k
Studio: forward client reasoning_content to llama-server for opt-in preserve_thinking #7758
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
||
|
|
@@ -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] | ||
|
|
@@ -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 | ||
|
|
@@ -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. | ||
|
|
||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the upstream overflow was caused mostly by a huge preserved 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)) | ||
|
|
@@ -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: | ||
|
|
@@ -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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a prior assistant turn has 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 | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding this typed field makes
_openai_messages_for_passthrough()dumpreasoning_content, but that helper is also reused for the safetensors/MLX client-tool templating path atroutes/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 removereasoning_contentbefore templating.Useful? React with 👍 / 👎.