release: v3.1.3
This commit is contained in:
+124
-3
@@ -76,11 +76,15 @@ from thinkingllm_core.model_access import (
|
||||
resolve_model_catalog_name,
|
||||
)
|
||||
from thinkingllm_core.prompt_contracts import (
|
||||
QWEN_MAX_REASONING_BUDGET_TOKENS,
|
||||
QWEN_MIN_FINAL_ANSWER_TOKENS,
|
||||
QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
VIDEO_DURATION_INPUT,
|
||||
apply_qwen_soft_thinking_directive,
|
||||
apply_video_duration_context,
|
||||
ensure_video_prompt_duration,
|
||||
estimate_qwen_text_tokens,
|
||||
resolve_qwen_output_token_plan,
|
||||
resolve_qwen_thinking_mode,
|
||||
resolve_video_duration,
|
||||
validate_minimax_reference_request,
|
||||
@@ -682,6 +686,35 @@ def _filter_kwargs_for_callable(fn, kwargs: dict) -> dict:
|
||||
return {k: v for k, v in kwargs.items() if k in allowed}
|
||||
|
||||
|
||||
def _supports_reasoning_budget(llm) -> bool:
|
||||
"""Return true only when llama-cpp exposes the full per-call reasoning budget API."""
|
||||
completion_fn = getattr(llm, "create_chat_completion", None)
|
||||
if not callable(completion_fn):
|
||||
return False
|
||||
try:
|
||||
parameters = inspect.signature(completion_fn).parameters
|
||||
except Exception:
|
||||
return False
|
||||
required = {
|
||||
"reasoning_budget",
|
||||
"reasoning_start",
|
||||
"reasoning_end",
|
||||
"reasoning_start_in_prompt",
|
||||
}
|
||||
return all(
|
||||
name in parameters
|
||||
and parameters[name].kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
||||
for name in required
|
||||
)
|
||||
|
||||
|
||||
def _require_nonempty_final_text(text: str, *, source: str) -> str:
|
||||
"""Reject empty final output before it can be persisted as a successful result."""
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise RuntimeError(f"[{source}] Generation produced no usable final text; empty output was not saved or cached.")
|
||||
return text
|
||||
|
||||
|
||||
def _unexpected_kwarg_names_from_type_error(exc: TypeError) -> list[str]:
|
||||
message = str(exc or "")
|
||||
matches = re.findall(r"unexpected keyword argument(?:\(s\))?\s+'([^']+)'", message)
|
||||
@@ -714,7 +747,9 @@ def _select_mmproj_handler_class(model_name: str, arch: str | None):
|
||||
|
||||
name = (model_name or "").lower()
|
||||
architecture = (arch or "").lower()
|
||||
if "gemma-4" in name or "gemma4" in name or architecture == "gemma4":
|
||||
if architecture in ("qwen35", "qwen35moe") or "qwen3.5-" in name:
|
||||
preferred = ("Qwen35ChatHandler", "Qwen3VLChatHandler")
|
||||
elif "gemma-4" in name or "gemma4" in name or architecture == "gemma4":
|
||||
preferred = ("Gemma4ChatHandler",)
|
||||
elif "qwen2.5" in name or "qwen25" in name or architecture in ("qwen2vl", "qwen25vl"):
|
||||
preferred = ("Qwen25VLChatHandler", "Qwen3VLChatHandler")
|
||||
@@ -732,6 +767,25 @@ def _select_mmproj_handler_class(model_name: str, arch: str | None):
|
||||
)
|
||||
|
||||
|
||||
def _set_chat_handler_thinking(chat_handler, enable_thinking: bool) -> None:
|
||||
"""Update the state read by llama-cpp-python's model-specific Jinja handler."""
|
||||
if chat_handler is None:
|
||||
return
|
||||
enabled = bool(enable_thinking)
|
||||
if hasattr(chat_handler, "enable_thinking"):
|
||||
try:
|
||||
chat_handler.enable_thinking = enabled
|
||||
except Exception:
|
||||
pass
|
||||
template_arguments = getattr(chat_handler, "extra_template_arguments", None)
|
||||
if isinstance(template_arguments, dict):
|
||||
template_arguments["enable_thinking"] = enabled
|
||||
for attr_name in ("chat_template_kwargs", "_chat_template_kwargs"):
|
||||
current = getattr(chat_handler, attr_name, None)
|
||||
if isinstance(current, dict):
|
||||
current["enable_thinking"] = enabled
|
||||
|
||||
|
||||
def _resample_mono_audio(samples, source_rate: int, target_rate: int = GGUF_AUDIO_TARGET_SAMPLE_RATE):
|
||||
samples = np.asarray(samples, dtype=np.float32).reshape(-1)
|
||||
if samples.size == 0 or source_rate == target_rate:
|
||||
@@ -1195,6 +1249,8 @@ class QwenVLGGUFBase:
|
||||
if self.llm is None or not self._uses_chat_template_thinking():
|
||||
return
|
||||
template_kwargs = {"enable_thinking": bool(enable_thinking)}
|
||||
_set_chat_handler_thinking(self.chat_handler, enable_thinking)
|
||||
_set_chat_handler_thinking(getattr(self.llm, "chat_handler", None), enable_thinking)
|
||||
for attr_name in ("chat_template_kwargs", "_chat_template_kwargs"):
|
||||
try:
|
||||
current = getattr(self.llm, attr_name, None)
|
||||
@@ -1426,6 +1482,7 @@ class QwenVLGGUFBase:
|
||||
arch = read_gguf_architecture(model_path)
|
||||
self.gguf_arch = arch
|
||||
is_qwen35 = arch in ("qwen35", "qwen35moe") if arch else "qwen3.5-" in model_name.lower()
|
||||
self.is_qwen35 = bool(is_qwen35)
|
||||
self.supports_qwen_soft_think = (
|
||||
bool(is_qwen35 or arch == "qwen3")
|
||||
if arch
|
||||
@@ -1496,6 +1553,8 @@ class QwenVLGGUFBase:
|
||||
"force_reasoning": False,
|
||||
"verbose": False,
|
||||
}
|
||||
if is_qwen35:
|
||||
mmproj_kwargs["enable_thinking"] = bool(enable_thinking)
|
||||
mmproj_kwargs = _filter_mmproj_handler_kwargs(handler_cls, mmproj_kwargs)
|
||||
if "image_max_tokens" not in mmproj_kwargs:
|
||||
print(
|
||||
@@ -1532,8 +1591,9 @@ class QwenVLGGUFBase:
|
||||
state_label = "enabled" if thinking_enabled else "disabled"
|
||||
print(f"[QwenVL] Qwen architecture detected (arch={arch}): Thinking {state_label} via chat template.")
|
||||
|
||||
if has_mmproj and self.chat_handler is not None:
|
||||
if self.chat_handler is not None:
|
||||
llm_kwargs["chat_handler"] = self.chat_handler
|
||||
if has_mmproj and self.chat_handler is not None:
|
||||
llm_kwargs["image_min_tokens"] = 1024
|
||||
llm_kwargs["image_max_tokens"] = img_max
|
||||
|
||||
@@ -1680,6 +1740,7 @@ class QwenVLGGUFBase:
|
||||
model_name: str = "",
|
||||
stream_to_terminal: bool = False,
|
||||
enable_thinking: bool = True,
|
||||
reasoning_budget: int = 0,
|
||||
auto_finalization_retry: bool = False,
|
||||
top_k: int | None = None,
|
||||
preset_name: str = "",
|
||||
@@ -1689,10 +1750,22 @@ class QwenVLGGUFBase:
|
||||
ensure_cuda_vram_headroom("QwenVL GGUF", min_free_gb=1.0, min_free_ratio=0.08)
|
||||
self._raise_if_unsafe_native_multimodal_path(images_b64, audio_b64 or [])
|
||||
supports_soft_think = getattr(self, "supports_qwen_soft_think", False)
|
||||
supports_reasoning_budget = supports_soft_think and _supports_reasoning_budget(self.llm)
|
||||
try:
|
||||
reasoning_budget = max(0, int(reasoning_budget or 0))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
reasoning_budget = 0
|
||||
if supports_soft_think:
|
||||
directive = "/think" if enable_thinking else "/no_think"
|
||||
if not stream_to_terminal:
|
||||
print(f"[QwenVL] Qwen3 GGUF detected: Thinking {'enabled' if enable_thinking else 'disabled'} via chat template and {directive}.")
|
||||
if enable_thinking and supports_reasoning_budget and reasoning_budget > 0:
|
||||
print(f"[QwenVL GGUF] Per-request reasoning budget enforced: {reasoning_budget} tokens.")
|
||||
elif enable_thinking:
|
||||
print(
|
||||
"[QwenVL GGUF] Backend has no explicit per-request reasoning budget; "
|
||||
"the shared preflight cutoff applies, but the estimated answer reserve is not a hard cap."
|
||||
)
|
||||
def _run_completion(
|
||||
system_prompt_text: str,
|
||||
user_prompt_text: str,
|
||||
@@ -1704,10 +1777,35 @@ class QwenVLGGUFBase:
|
||||
attempt_enable_thinking: bool | None = None,
|
||||
) -> tuple[str, str]:
|
||||
current_enable_thinking = enable_thinking if attempt_enable_thinking is None else bool(attempt_enable_thinking)
|
||||
handler = self.chat_handler or getattr(self.llm, "chat_handler", None)
|
||||
handler_controls_thinking = hasattr(handler, "enable_thinking")
|
||||
if (
|
||||
getattr(self, "is_qwen35", False)
|
||||
and not current_enable_thinking
|
||||
and not supports_reasoning_budget
|
||||
and not handler_controls_thinking
|
||||
):
|
||||
raise RuntimeError(
|
||||
"[QwenVL GGUF] Cannot reliably disable Qwen3.5 thinking for this request: "
|
||||
"the loaded llama.cpp backend exposes no reasoning-budget control and the active chat "
|
||||
"handler exposes no enable_thinking template switch. Upgrade llama-cpp-python or use "
|
||||
"a Qwen3.5 chat handler that supports enable_thinking."
|
||||
)
|
||||
reasoning_kwargs = {}
|
||||
if supports_reasoning_budget:
|
||||
handler_opens_reasoning = bool(
|
||||
current_enable_thinking and hasattr(handler, "enable_thinking")
|
||||
)
|
||||
reasoning_kwargs = {
|
||||
"reasoning_budget": reasoning_budget if current_enable_thinking else 0,
|
||||
"reasoning_start": "<think>",
|
||||
"reasoning_end": "</think>",
|
||||
"reasoning_start_in_prompt": handler_opens_reasoning,
|
||||
}
|
||||
effective_user_prompt = apply_qwen_soft_thinking_directive(
|
||||
user_prompt_text,
|
||||
current_enable_thinking,
|
||||
supports_soft_switch=supports_soft_think,
|
||||
supports_soft_switch=(supports_soft_think and not getattr(self, "is_qwen35", False)),
|
||||
)
|
||||
if self.llm is not None and hasattr(self.llm, "reset"):
|
||||
try:
|
||||
@@ -1776,6 +1874,7 @@ class QwenVLGGUFBase:
|
||||
stop=stop_tokens,
|
||||
stream=True,
|
||||
**sampler_kwargs,
|
||||
**reasoning_kwargs,
|
||||
)
|
||||
for chunk in result:
|
||||
throw_exception_if_processing_interrupted()
|
||||
@@ -1840,6 +1939,7 @@ class QwenVLGGUFBase:
|
||||
stop=stop_tokens,
|
||||
stream=True,
|
||||
**sampler_kwargs,
|
||||
**reasoning_kwargs,
|
||||
)
|
||||
for chunk in result:
|
||||
if abort_event.is_set():
|
||||
@@ -2068,6 +2168,7 @@ class QwenVLGGUFBase:
|
||||
offload_kqv=bool(offload_kqv),
|
||||
ctx_checkpoints=ctx_checkpoints,
|
||||
enable_thinking=bool(enable_thinking),
|
||||
thinking_budget_policy_version=QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
auto_finalization_retry=bool(auto_finalization_retry),
|
||||
mask_mode=mask_mode,
|
||||
max_tokens=max_tokens,
|
||||
@@ -2119,6 +2220,7 @@ class QwenVLGGUFBase:
|
||||
top_p=top_p,
|
||||
repetition_penalty=repetition_penalty,
|
||||
enable_thinking=enable_thinking,
|
||||
thinking_budget_policy_version=QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
effective_duration_seconds=effective_duration_seconds,
|
||||
video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None,
|
||||
mask_prompt_contract_version=MASK_PROMPT_CONTRACT_VERSION if mask_hash else None,
|
||||
@@ -2206,14 +2308,31 @@ class QwenVLGGUFBase:
|
||||
"You are a helpful vision-language assistant.",
|
||||
prompt,
|
||||
) + len(images_b64) * max(1024, int(attempt_image_max_tokens or 1024)) + len(audio_b64) * 2048
|
||||
available_output, answer_reserve, reasoning_budget = resolve_qwen_output_token_plan(
|
||||
max_tokens,
|
||||
prompt_tokens=estimated_prompt_tokens,
|
||||
context_window=context_window,
|
||||
min_answer_reserve_tokens=QWEN_MIN_FINAL_ANSWER_TOKENS,
|
||||
max_reasoning_tokens=QWEN_MAX_REASONING_BUDGET_TOKENS,
|
||||
)
|
||||
effective_thinking = resolve_qwen_thinking_mode(
|
||||
enable_thinking,
|
||||
max_tokens,
|
||||
label="QwenVL GGUF",
|
||||
prompt_tokens=estimated_prompt_tokens,
|
||||
context_window=context_window,
|
||||
answer_reserve_tokens=answer_reserve,
|
||||
quiet=stream_to_terminal,
|
||||
)
|
||||
if not effective_thinking:
|
||||
reasoning_budget = 0
|
||||
if not stream_to_terminal:
|
||||
print(
|
||||
f"[QwenVL GGUF] Token preflight: "
|
||||
f"available_output={available_output}, "
|
||||
f"reasoning_cap={reasoning_budget}, "
|
||||
f"final_answer_reserve={answer_reserve}."
|
||||
)
|
||||
print(f"[QwenVL GGUF DEBUG] Loading model...")
|
||||
self._load_model(
|
||||
model_name=model_name,
|
||||
@@ -2257,6 +2376,7 @@ class QwenVLGGUFBase:
|
||||
model_name=model_name,
|
||||
stream_to_terminal=stream_to_terminal,
|
||||
enable_thinking=effective_thinking,
|
||||
reasoning_budget=reasoning_budget,
|
||||
auto_finalization_retry=auto_finalization_retry,
|
||||
top_k=top_k,
|
||||
preset_name=preset_prompt,
|
||||
@@ -2292,6 +2412,7 @@ class QwenVLGGUFBase:
|
||||
duration_seconds,
|
||||
source_prompt_text=custom_prompt,
|
||||
)
|
||||
text = _require_nonempty_final_text(text, source="QwenVL GGUF")
|
||||
|
||||
print(f"[QwenVL GGUF DEBUG] Generation completed. Text length: {len(text) if text else 0}")
|
||||
print(f"[QwenVL GGUF DEBUG] Generated text: {text[:100] if text else 'EMPTY'}...")
|
||||
|
||||
@@ -51,11 +51,16 @@ from thinkingllm_core.model_access import (
|
||||
resolve_model_catalog_name,
|
||||
)
|
||||
from thinkingllm_core.prompt_contracts import (
|
||||
QWEN_MAX_REASONING_BUDGET_TOKENS,
|
||||
QWEN_MIN_FINAL_ANSWER_TOKENS,
|
||||
QWEN_MIN_REASONING_BUDGET_TOKENS,
|
||||
QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
VIDEO_DURATION_INPUT,
|
||||
apply_qwen_soft_thinking_directive,
|
||||
apply_video_duration_context,
|
||||
ensure_video_prompt_duration,
|
||||
estimate_qwen_text_tokens,
|
||||
resolve_qwen_output_token_plan,
|
||||
resolve_qwen_thinking_mode,
|
||||
resolve_video_duration,
|
||||
validate_minimax_source_duration,
|
||||
@@ -82,6 +87,7 @@ from AILab_QwenVL_GGUF import (
|
||||
_filter_kwargs_for_callable,
|
||||
_find_existing_local_file_in_dirs,
|
||||
_gguf_search_dirs,
|
||||
_supports_reasoning_budget,
|
||||
)
|
||||
|
||||
|
||||
@@ -90,7 +96,7 @@ GGUF_PROMPT_TOOLTIPS = {
|
||||
"prompt_text": "Type your scene idea or video description here; no separate text node is needed. The selected style transforms this text into a generation prompt. For image-to-video styles, include any source-frame details in your text because this node does not receive images.",
|
||||
"preset_system_prompt": "Preset enhancement style. Use Custom Only when you want custom_system_prompt to fully control the instruction.",
|
||||
"custom_system_prompt": "Optional extra instruction. Required when using Custom Only; otherwise it is prepended to the selected style.",
|
||||
"max_tokens": "Maximum new tokens for the enhanced prompt. Increase only when the model truncates useful detail.",
|
||||
"max_tokens": "Maximum new tokens for the enhanced prompt. The node dynamically caps Qwen reasoning to preserve at least two thirds of the available output for the final prompt.",
|
||||
"temperature": "Sampling randomness. Lower is more stable; higher is more varied.",
|
||||
"top_p": "Nucleus sampling cutoff. Lower values restrict token choice; 0.9 is a balanced default.",
|
||||
"repetition_penalty": "Values above 1.0 reduce repeated phrases in the enhanced prompt.",
|
||||
@@ -100,8 +106,8 @@ GGUF_PROMPT_TOOLTIPS = {
|
||||
"seed": "Sampling seed. Reusing it with identical inputs can reuse the saved prompt result.",
|
||||
"keep_last_prompt": "Return the saved per-node prompt instead of generating a new one when available.",
|
||||
"stream_tokens_to_terminal": "Show clean wrapped generated tokens in the ComfyUI terminal. Streaming bypasses fixed-seed reuse for a fresh run.",
|
||||
"enable_thinking": "Enable model reasoning/thinking when supported. The node still returns the cleaned final prompt, so reasoning may be hidden or empty.",
|
||||
"auto_finalization_retry": "If enabled, runs an extra LLM completion when the first output is empty or reasoning-only. Disabled by default so one node execution performs one generation pass.",
|
||||
"enable_thinking": "Request model reasoning/thinking when supported. The node calculates a reasoning cap before generation, reserves room for the final prompt, and disables thinking automatically when the available budget is too small or the backend cannot cap it.",
|
||||
"auto_finalization_retry": "If enabled, allows up to two finalization retries after an empty or reasoning-only response. The node automatically makes one direct-answer recovery attempt for reasoning-only output.",
|
||||
"hf_token": "Optional Hugging Face access token for private or gated GGUF downloads. It is passed only to the download call, never logged or cached, and the in-memory copy is dropped after the download attempt. Clear this field before saving or sharing workflows.",
|
||||
}
|
||||
|
||||
@@ -429,6 +435,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
def __init__(self):
|
||||
self.llm = None
|
||||
self.current_signature = None
|
||||
self.is_qwen35 = False
|
||||
self.gguf_models = self.load_gguf_models()
|
||||
self.styles = STYLES
|
||||
register_active_gguf_loader(self)
|
||||
@@ -789,10 +796,14 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
arch = read_gguf_architecture(resolved)
|
||||
self.gguf_arch = arch
|
||||
is_qwen = (arch and any(a in arch for a in ("qwen35", "qwen35moe", "qwen3", "qwen"))) or "qwen3.5-" in model_name.lower() or "qwen3-" in model_name.lower()
|
||||
normalized_arch = re.sub(r"[^a-z0-9]", "", str(arch or "").lower())
|
||||
model_name_lower = model_name.lower()
|
||||
is_qwen35 = normalized_arch in {"qwen35", "qwen35moe"} or "qwen3.5-" in model_name_lower
|
||||
self.is_qwen35 = bool(is_qwen35)
|
||||
self.supports_qwen_soft_think = (
|
||||
bool(arch and (arch == "qwen3" or arch.startswith("qwen35")))
|
||||
if arch
|
||||
else ("qwen3-" in model_name.lower() or "qwen3.5-" in model_name.lower())
|
||||
normalized_arch in {"qwen3", "qwen35", "qwen35moe", "qwen3vl"}
|
||||
or "qwen3-" in model_name_lower
|
||||
or "qwen3.5-" in model_name_lower
|
||||
)
|
||||
if is_qwen:
|
||||
thinking_state = bool(enable_thinking)
|
||||
@@ -831,6 +842,8 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
stream_to_terminal=False,
|
||||
initial_stage_label="INITIAL GENERATION",
|
||||
enable_thinking=True,
|
||||
reasoning_budget=0,
|
||||
answer_reserve_tokens=0,
|
||||
auto_finalization_retry=False,
|
||||
preset_name="",
|
||||
):
|
||||
@@ -838,10 +851,45 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
stream_display = TerminalStreamDisplay("QwenVL GGUF", suppress_planning=True, compact=True) if stream_to_terminal else None
|
||||
supports_soft_think = getattr(self, "supports_qwen_soft_think", False)
|
||||
is_video_preset = bool(resolve_video_duration(preset_name, 5.0))
|
||||
supports_reasoning_budget = supports_soft_think and _supports_reasoning_budget(self.llm)
|
||||
reasoning_budget = max(0, int(reasoning_budget or 0))
|
||||
if enable_thinking and supports_soft_think and reasoning_budget <= 0:
|
||||
enable_thinking = False
|
||||
print(
|
||||
"[QwenVL PromptEnhancer GGUF] The preflight left no safe reasoning budget; "
|
||||
"disabling thinking before generation."
|
||||
)
|
||||
elif (
|
||||
enable_thinking
|
||||
and supports_soft_think
|
||||
and not supports_reasoning_budget
|
||||
and reasoning_budget < QWEN_MIN_REASONING_BUDGET_TOKENS
|
||||
):
|
||||
enable_thinking = False
|
||||
reasoning_budget = 0
|
||||
print(
|
||||
"[QwenVL PromptEnhancer GGUF] The backend cannot enforce a per-request reasoning cap, "
|
||||
f"and the preflight allowance is below {QWEN_MIN_REASONING_BUDGET_TOKENS} tokens; "
|
||||
"disabling thinking to preserve answer space."
|
||||
)
|
||||
if supports_soft_think:
|
||||
directive = "/think" if enable_thinking else "/no_think"
|
||||
if not stream_to_terminal:
|
||||
print(f"[QwenVL] Qwen3 GGUF prompt enhancer: Thinking {'enabled' if enable_thinking else 'disabled'} via chat template and {directive}.")
|
||||
if enable_thinking:
|
||||
if supports_reasoning_budget:
|
||||
print(
|
||||
f"[QwenVL] Qwen GGUF prompt enhancer: Thinking enabled with an enforced "
|
||||
f"{reasoning_budget}-token cap and a {int(answer_reserve_tokens)}-token "
|
||||
f"final-answer reserve via chat template and {directive}."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[QwenVL] Qwen GGUF prompt enhancer: Thinking enabled with a preflight "
|
||||
f"allowance of {reasoning_budget} tokens and a {int(answer_reserve_tokens)}-token "
|
||||
"answer reserve. This backend cannot hard-cap reasoning; the reserve is a heuristic."
|
||||
)
|
||||
else:
|
||||
print(f"[QwenVL] Qwen GGUF prompt enhancer: Thinking disabled via chat template and {directive}.")
|
||||
|
||||
def _call(
|
||||
system: str,
|
||||
@@ -857,7 +905,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
effective_user_prompt = apply_qwen_soft_thinking_directive(
|
||||
user,
|
||||
current_enable_thinking,
|
||||
supports_soft_switch=supports_soft_think,
|
||||
supports_soft_switch=(supports_soft_think and not self.is_qwen35),
|
||||
)
|
||||
ensure_cuda_vram_headroom("QwenVL PromptEnhancer GGUF", min_free_gb=1.0, min_free_ratio=0.08)
|
||||
if self.llm is not None and hasattr(self.llm, "reset"):
|
||||
@@ -872,8 +920,11 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": effective_user_prompt},
|
||||
]
|
||||
if assistant_prefill:
|
||||
messages.append({"role": "assistant", "content": assistant_prefill})
|
||||
effective_assistant_prefill = assistant_prefill
|
||||
if supports_soft_think and self.is_qwen35 and not current_enable_thinking:
|
||||
effective_assistant_prefill = f"<think>\n\n</think>\n{effective_assistant_prefill}"
|
||||
if effective_assistant_prefill:
|
||||
messages.append({"role": "assistant", "content": effective_assistant_prefill})
|
||||
log_llm_input(
|
||||
"QwenVL PromptEnhancer GGUF",
|
||||
stage_label,
|
||||
@@ -912,12 +963,22 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
"seed": seed_val,
|
||||
"stop": ["<|im_end|>", "<|im_start|>"],
|
||||
"stream": True,
|
||||
"assistant_prefill": bool(effective_assistant_prefill),
|
||||
}
|
||||
if supports_soft_think:
|
||||
completion_kwargs["chat_template_kwargs"] = {
|
||||
"enable_thinking": current_enable_thinking,
|
||||
"thinking_mode": "enabled" if current_enable_thinking else "disabled",
|
||||
}
|
||||
if supports_soft_think and supports_reasoning_budget:
|
||||
completion_kwargs.update(
|
||||
{
|
||||
"reasoning_budget": reasoning_budget if current_enable_thinking else 0,
|
||||
"reasoning_start": "<think>",
|
||||
"reasoning_end": "</think>",
|
||||
"reasoning_start_in_prompt": False,
|
||||
}
|
||||
)
|
||||
completion_kwargs = _filter_kwargs_for_callable(getattr(llm, "create_chat_completion"), completion_kwargs)
|
||||
response = llm.create_chat_completion(**completion_kwargs)
|
||||
for chunk in response:
|
||||
@@ -1001,14 +1062,30 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
best_cleaned = cleaned.strip() if _preset_output_is_usable(cleaned.strip(), preset_name) else ""
|
||||
if best_cleaned:
|
||||
return best_cleaned, "\n\n".join(raw_trace_parts)
|
||||
if not (auto_finalization_retry or is_video_preset):
|
||||
reasoning_only = bool(
|
||||
not best_cleaned
|
||||
and re.match(r"^\s*<think\b", raw or "", flags=re.IGNORECASE)
|
||||
)
|
||||
should_retry = auto_finalization_retry or is_video_preset or reasoning_only
|
||||
if not should_retry:
|
||||
return "", "\n\n".join(raw_trace_parts)
|
||||
|
||||
for attempt_number in range(2, _PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS + 1):
|
||||
max_finalization_attempts = (
|
||||
_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS
|
||||
if auto_finalization_retry or is_video_preset
|
||||
else 2
|
||||
)
|
||||
if reasoning_only and not (auto_finalization_retry or is_video_preset) and not stream_to_terminal:
|
||||
print(
|
||||
"[QwenVL PromptEnhancer GGUF] Reasoning-only response detected; "
|
||||
"retrying once with thinking disabled."
|
||||
)
|
||||
|
||||
for attempt_number in range(2, max_finalization_attempts + 1):
|
||||
if stream_to_terminal:
|
||||
print(
|
||||
"[QwenVL PromptEnhancer GGUF] "
|
||||
f"Finalization attempt {attempt_number}/{_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS} "
|
||||
f"Finalization attempt {attempt_number}/{max_finalization_attempts} "
|
||||
"— prior output was empty or reasoning-only"
|
||||
)
|
||||
retry_system = (
|
||||
@@ -1021,7 +1098,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
"Return the final prompt for this original user request. Preserve supported user facts and verbatim dialogue:\n\n"
|
||||
f"{user_prompt}\n"
|
||||
)
|
||||
stage_label = f"FINALIZATION ATTEMPT {attempt_number}/{_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS}"
|
||||
stage_label = f"FINALIZATION ATTEMPT {attempt_number}/{max_finalization_attempts}"
|
||||
raw_retry = _call(
|
||||
retry_system,
|
||||
retry_user,
|
||||
@@ -1041,7 +1118,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
if stream_to_terminal:
|
||||
print(
|
||||
"[QwenVL PromptEnhancer GGUF] "
|
||||
f"Finalization limit reached ({_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS}/{_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS}) "
|
||||
f"Finalization limit reached ({max_finalization_attempts}/{max_finalization_attempts}) "
|
||||
"— returning the best cleaned prompt available"
|
||||
)
|
||||
return best_cleaned, "\n\n".join(raw_trace_parts)
|
||||
@@ -1090,6 +1167,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
enable_thinking=bool(enable_thinking),
|
||||
auto_finalization_retry=bool(auto_finalization_retry),
|
||||
output_cleaner_version=OUTPUT_CLEANER_VERSION,
|
||||
reasoning_budget_policy_version=QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
@@ -1119,9 +1197,10 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
custom_system_prompt.strip(),
|
||||
f"english_output={effective_english_output}",
|
||||
f"output_cleaner_version={OUTPUT_CLEANER_VERSION}",
|
||||
f"reasoning_budget_policy_version={QWEN_THINKING_BUDGET_POLICY_VERSION}",
|
||||
) if part
|
||||
)
|
||||
cache_key = get_cache_key(model_name, preset_system_prompt, cache_prompt, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking, effective_duration_seconds=effective_duration_seconds, video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None)
|
||||
cache_key = get_cache_key(model_name, preset_system_prompt, cache_prompt, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking, thinking_budget_policy_version=QWEN_THINKING_BUDGET_POLICY_VERSION, effective_duration_seconds=effective_duration_seconds, video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None)
|
||||
|
||||
# Check cache first (only for random mode)
|
||||
if cache_key in PROMPT_CACHE and not stream_tokens_to_terminal:
|
||||
@@ -1155,14 +1234,32 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
effective_context_window,
|
||||
min(int(context_window or 8192), 8192),
|
||||
)
|
||||
available_output_tokens, answer_reserve_tokens, reasoning_budget = resolve_qwen_output_token_plan(
|
||||
max_tokens,
|
||||
prompt_tokens=estimated_prompt_tokens,
|
||||
context_window=effective_context_window,
|
||||
min_answer_reserve_tokens=QWEN_MIN_FINAL_ANSWER_TOKENS,
|
||||
max_reasoning_tokens=QWEN_MAX_REASONING_BUDGET_TOKENS,
|
||||
)
|
||||
effective_thinking = resolve_qwen_thinking_mode(
|
||||
enable_thinking,
|
||||
max_tokens,
|
||||
label="QwenVL PromptEnhancer GGUF",
|
||||
prompt_tokens=estimated_prompt_tokens,
|
||||
context_window=effective_context_window,
|
||||
min_reasoning_tokens=QWEN_MIN_REASONING_BUDGET_TOKENS,
|
||||
answer_reserve_tokens=answer_reserve_tokens,
|
||||
quiet=stream_tokens_to_terminal,
|
||||
)
|
||||
if not effective_thinking:
|
||||
reasoning_budget = 0
|
||||
if not stream_tokens_to_terminal:
|
||||
print(
|
||||
f"[QwenVL PromptEnhancer GGUF] Token preflight: "
|
||||
f"available_output={available_output_tokens}, "
|
||||
f"reasoning_cap={reasoning_budget}, "
|
||||
f"final_answer_reserve={answer_reserve_tokens}."
|
||||
)
|
||||
self._load_model(
|
||||
model_name,
|
||||
device,
|
||||
@@ -1184,17 +1281,49 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
stream_to_terminal=stream_tokens_to_terminal,
|
||||
initial_stage_label="INITIAL GENERATION",
|
||||
enable_thinking=effective_thinking,
|
||||
reasoning_budget=reasoning_budget,
|
||||
answer_reserve_tokens=answer_reserve_tokens,
|
||||
auto_finalization_retry=auto_finalization_retry,
|
||||
preset_name=preset_system_prompt,
|
||||
)
|
||||
full_raw_trace = raw_trace
|
||||
if effective_english_output:
|
||||
translation_system_prompt = (
|
||||
PROMPT_CONFIG.get("translation_prompt")
|
||||
or "Return a single English paragraph (150-300 words). No prefixes, bullets, JSON, or </think>. "
|
||||
"Cover subject, environment, lighting, camera settings, composition, color/texture, and style. Output only the prompt."
|
||||
)
|
||||
translation_prompt_tokens = estimate_qwen_text_tokens(translation_system_prompt, enhanced)
|
||||
translation_available, translation_answer_reserve, translation_reasoning_budget = (
|
||||
resolve_qwen_output_token_plan(
|
||||
max_tokens,
|
||||
prompt_tokens=translation_prompt_tokens,
|
||||
context_window=effective_context_window,
|
||||
min_answer_reserve_tokens=QWEN_MIN_FINAL_ANSWER_TOKENS,
|
||||
max_reasoning_tokens=QWEN_MAX_REASONING_BUDGET_TOKENS,
|
||||
)
|
||||
)
|
||||
translation_effective_thinking = resolve_qwen_thinking_mode(
|
||||
effective_thinking,
|
||||
max_tokens,
|
||||
label="QwenVL PromptEnhancer GGUF translation",
|
||||
prompt_tokens=translation_prompt_tokens,
|
||||
context_window=effective_context_window,
|
||||
min_reasoning_tokens=QWEN_MIN_REASONING_BUDGET_TOKENS,
|
||||
answer_reserve_tokens=translation_answer_reserve,
|
||||
quiet=stream_tokens_to_terminal,
|
||||
)
|
||||
if not translation_effective_thinking:
|
||||
translation_reasoning_budget = 0
|
||||
if not stream_tokens_to_terminal:
|
||||
print(
|
||||
f"[QwenVL PromptEnhancer GGUF] Translation token preflight: "
|
||||
f"available_output={translation_available}, "
|
||||
f"reasoning_cap={translation_reasoning_budget}, "
|
||||
f"final_answer_reserve={translation_answer_reserve}."
|
||||
)
|
||||
translated, trans_trace = self._invoke_llama(
|
||||
system_prompt=(
|
||||
PROMPT_CONFIG.get("translation_prompt")
|
||||
or "Return a single English paragraph (150-300 words). No prefixes, bullets, JSON, or </think>. "
|
||||
"Cover subject, environment, lighting, camera settings, composition, color/texture, and style. Output only the prompt."
|
||||
),
|
||||
system_prompt=translation_system_prompt,
|
||||
user_prompt=enhanced,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.3,
|
||||
@@ -1203,7 +1332,9 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
seed=seed + 1,
|
||||
stream_to_terminal=stream_tokens_to_terminal,
|
||||
initial_stage_label="TRANSLATION STAGE",
|
||||
enable_thinking=effective_thinking,
|
||||
enable_thinking=translation_effective_thinking,
|
||||
reasoning_budget=translation_reasoning_budget,
|
||||
answer_reserve_tokens=translation_answer_reserve,
|
||||
auto_finalization_retry=auto_finalization_retry,
|
||||
preset_name=preset_system_prompt,
|
||||
)
|
||||
@@ -1218,6 +1349,12 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
source_prompt_text=prompt_text,
|
||||
)
|
||||
|
||||
if not final.strip():
|
||||
raise RuntimeError(
|
||||
"QwenVL GGUF PromptEnhancer produced no usable final prompt after finalization; "
|
||||
"the empty result was not cached. Check the model output and token budget."
|
||||
)
|
||||
|
||||
# Cache the generated text
|
||||
PROMPT_CACHE[cache_key] = {
|
||||
"text": final,
|
||||
|
||||
@@ -43,6 +43,7 @@ from thinkingllm_core.model_access import (
|
||||
)
|
||||
from thinkingllm_core.hf_trust import trust_remote_code_for_snapshot
|
||||
from thinkingllm_core.prompt_contracts import (
|
||||
QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
VIDEO_DURATION_INPUT,
|
||||
apply_qwen_soft_thinking_directive,
|
||||
apply_video_duration_context,
|
||||
@@ -253,6 +254,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
enhancement_style=enhancement_style,
|
||||
custom_system_prompt=custom_system_prompt,
|
||||
enable_thinking=bool(enable_thinking),
|
||||
thinking_budget_policy_version=QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
output_cleaner_version=OUTPUT_CLEANER_VERSION,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
@@ -341,6 +343,11 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
duration_seconds,
|
||||
source_prompt_text=prompt_text,
|
||||
)
|
||||
if not final.strip():
|
||||
raise RuntimeError(
|
||||
"[QwenVL PromptEnhancer HF] Model returned no usable prompt after removing reasoning. "
|
||||
"Try disabling thinking or increasing the output token limit."
|
||||
)
|
||||
|
||||
# Persist per-node for future keep_last_prompt=True
|
||||
set_node_saved_prompt(node_class, unique_id, extra_pnginfo, final, raw_trace=raw_trace, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
|
||||
@@ -566,10 +573,15 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
messages = [{"role": "user", "content": effective_prompt}]
|
||||
local_template_kwargs = dict(template_kwargs)
|
||||
if is_qwen35 or supports_soft_think:
|
||||
local_template_kwargs["chat_template_kwargs"] = {"enable_thinking": thinking_enabled}
|
||||
local_template_kwargs["enable_thinking"] = thinking_enabled
|
||||
try:
|
||||
formatted_prompt = self.text_tokenizer.apply_chat_template(messages, **local_template_kwargs)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
if is_qwen35 or supports_soft_think:
|
||||
raise RuntimeError(
|
||||
"[QwenVL PromptEnhancer HF] Could not apply the tokenizer chat template with "
|
||||
f"enable_thinking={thinking_enabled}; refusing to continue without the requested thinking control."
|
||||
) from exc
|
||||
formatted_prompt = effective_prompt
|
||||
inputs = self.text_tokenizer(formatted_prompt, return_tensors="pt").to(device_choice)
|
||||
return effective_prompt, formatted_prompt, inputs
|
||||
@@ -646,7 +658,9 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
if not full_streamed.strip():
|
||||
raise RuntimeError("[QwenVL] HF streaming returned empty response")
|
||||
raw_text = full_streamed.strip()
|
||||
result = clean_model_output(raw_text, OutputCleanConfig(mode="prompt")) or raw_text
|
||||
result = clean_model_output(raw_text, OutputCleanConfig(mode="prompt"))
|
||||
if not result.strip():
|
||||
raise RuntimeError("[QwenVL PromptEnhancer HF] Streaming produced no usable prompt after removing reasoning.")
|
||||
if not keep_model_loaded:
|
||||
self.text_model = None
|
||||
self.text_processor = None
|
||||
@@ -667,7 +681,9 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
input_length = inputs["input_ids"].shape[1]
|
||||
generated_tokens = outputs[0][input_length:]
|
||||
raw_text = self.text_tokenizer.decode(generated_tokens, skip_special_tokens=True).strip()
|
||||
result = clean_model_output(raw_text, OutputCleanConfig(mode="prompt")) or raw_text
|
||||
result = clean_model_output(raw_text, OutputCleanConfig(mode="prompt"))
|
||||
if not result.strip():
|
||||
raise RuntimeError("[QwenVL PromptEnhancer HF] Generation produced no usable prompt after removing reasoning.")
|
||||
|
||||
if not keep_model_loaded:
|
||||
self.text_model = None
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"name": "ComfyUI-ThinkingLLM",
|
||||
"description": "A multimodal ComfyUI AI node pack with Qwen, Gemma 4, GGUF, Whisper, image/text decision routing, typed decision fields, and live thinking output.",
|
||||
"author": "goodguy1963",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.3",
|
||||
"url": "https://github.com/goodguy1963/ComfyUI-ThinkingLLM",
|
||||
"category": "image"
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@
|
||||
"description": "A multimodal ComfyUI AI node pack with Qwen, Gemma 4, GGUF, Whisper, image and text decision routing, typed Boolean and Integer decision fields, and live thinking output.",
|
||||
"category": "image",
|
||||
"tags": ["vision", "language", "multimodal", "thinkingllm", "decision-router", "decision-fields", "qwen", "gguf", "flash_attention", "performance", "wan2.2", "video", "i2v", "t2v", "storyboard", "caching", "fork", "gpl-3.0"],
|
||||
"version": "3.1.2"
|
||||
"version": "3.1.3"
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
- Keep the same version mirrored in the `__init__.py` metadata block and `custom_nodes.json` so local ComfyUI metadata and manager-facing metadata stay aligned.
|
||||
- README still contains historical upstream links and changelog references beyond the top-level fork identity; that is acceptable for attribution, but a docs pass should normalize publish-facing instructions before release.
|
||||
|
||||
## ThinkingLLM 3.1.3 release record
|
||||
|
||||
Version metadata is synchronized at **3.1.3** in `pyproject.toml`, `__init__.py`, and `custom_nodes.json`. This release unifies Qwen thinking preflight, fixes Qwen3.5 template control, and prevents empty or reasoning-only results from being stored as successful output. See `update.md` for validation details.
|
||||
|
||||
## ThinkingLLM 3.1.1 release record
|
||||
|
||||
Version metadata is synchronized at **3.1.1** in `pyproject.toml`, `__init__.py`, and `custom_nodes.json`. This release expands audio, mask-focused image, and video prompt workflows, and adds security controls for audio paths and resource use, pinned Transformers remote code, and terminal output. See `update.md` for the change summary and focused validation.
|
||||
|
||||
@@ -21,6 +21,11 @@ from pathlib import Path
|
||||
|
||||
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output
|
||||
from AILab_StreamDisplay import compose_streamed_model_output, extract_stream_token, get_thinking_stream_display
|
||||
from thinkingllm_core.prompt_contracts import (
|
||||
QWEN_MIN_REASONING_BUDGET_TOKENS,
|
||||
resolve_qwen_output_token_plan,
|
||||
resolve_qwen_thinking_mode,
|
||||
)
|
||||
|
||||
PACKAGE_DIR = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_PROFILE_FILE = PACKAGE_DIR / "thinkingllm_api_profiles.json"
|
||||
@@ -513,6 +518,36 @@ def _validate_thinking_budget(profile: dict, value: int) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_api_qwen_thinking(profile: dict, max_tokens: int, enabled: bool, requested_budget: int):
|
||||
"""Apply the shared cutoff when the profile's output limit includes reasoning."""
|
||||
if (
|
||||
profile.get("thinking_mode") != "qwen"
|
||||
or profile.get("max_tokens_field") != "max_completion_tokens"
|
||||
):
|
||||
return bool(enabled), requested_budget, None
|
||||
|
||||
available_output, answer_reserve, reasoning_budget = resolve_qwen_output_token_plan(max_tokens)
|
||||
minimum_reasoning = QWEN_MIN_REASONING_BUDGET_TOKENS
|
||||
if requested_budget > 0:
|
||||
minimum_reasoning = min(minimum_reasoning, requested_budget)
|
||||
effective_thinking = resolve_qwen_thinking_mode(
|
||||
enabled,
|
||||
available_output,
|
||||
label="ThinkingLLM API",
|
||||
min_reasoning_tokens=minimum_reasoning,
|
||||
answer_reserve_tokens=answer_reserve,
|
||||
)
|
||||
if effective_thinking:
|
||||
effective_budget = (
|
||||
min(reasoning_budget, requested_budget)
|
||||
if requested_budget > 0
|
||||
else reasoning_budget
|
||||
)
|
||||
else:
|
||||
effective_budget = 0
|
||||
return effective_thinking, effective_budget, (available_output, answer_reserve)
|
||||
|
||||
|
||||
def _validate_input_text(profile: dict, system_prompt, prompt) -> tuple[str, str]:
|
||||
if system_prompt is not None and not isinstance(system_prompt, str):
|
||||
raise ValueError("system_prompt must be a string.")
|
||||
@@ -792,6 +827,21 @@ class ThinkingLLMOpenAICompatibleAPI:
|
||||
timeout = _validate_timeout(profile, timeout_seconds)
|
||||
temperature, top_p, seed, thinking_budget = _validate_sampling(temperature, top_p, seed, thinking_budget)
|
||||
thinking_budget = _validate_thinking_budget(profile, thinking_budget)
|
||||
effective_thinking, effective_thinking_budget, qwen_token_plan = _resolve_api_qwen_thinking(
|
||||
profile,
|
||||
max_out,
|
||||
enable_thinking,
|
||||
thinking_budget,
|
||||
)
|
||||
if qwen_token_plan is not None and (
|
||||
effective_thinking != enable_thinking or effective_thinking_budget != thinking_budget
|
||||
):
|
||||
available_output, answer_reserve = qwen_token_plan
|
||||
print(
|
||||
f"[ThinkingLLM API] Qwen output preflight: available_output={available_output}, "
|
||||
f"reasoning_budget={effective_thinking_budget}, answer_reserve={answer_reserve}, "
|
||||
f"thinking={'enabled' if effective_thinking else 'disabled'}."
|
||||
)
|
||||
system_text, prompt_text = _validate_input_text(profile, system_prompt, prompt)
|
||||
user_content, image_supplied = _build_user_content(profile, prompt_text, image=image, image_url=image_url)
|
||||
|
||||
@@ -810,9 +860,9 @@ class ThinkingLLMOpenAICompatibleAPI:
|
||||
if profile.get("send_seed"):
|
||||
request_body["seed"] = seed
|
||||
if profile.get("thinking_mode") == "qwen":
|
||||
request_body["enable_thinking"] = enable_thinking
|
||||
if thinking_budget > 0:
|
||||
request_body["thinking_budget"] = thinking_budget
|
||||
request_body["enable_thinking"] = effective_thinking
|
||||
if effective_thinking_budget > 0:
|
||||
request_body["thinking_budget"] = effective_thinking_budget
|
||||
request_body.update(_parse_extra_json(extra_body_json, profile.get("allowed_extra_body_fields", [])))
|
||||
request_body["stream"] = True
|
||||
|
||||
@@ -910,6 +960,12 @@ class ThinkingLLMOpenAICompatibleAPI:
|
||||
strip_json_wrappers=False, strip_leading_preamble=False, strip_planning=False,
|
||||
),
|
||||
)
|
||||
if not response_text.strip():
|
||||
raise RuntimeError(
|
||||
f"ThinkingLLM API profile {profile['name']!r} returned no final answer "
|
||||
"(empty or reasoning-only output). Check the provider's thinking settings "
|
||||
"and completion token limit before retrying."
|
||||
)
|
||||
return (response_text, raw_trace)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ComfyUI-ThinkingLLM"
|
||||
description = "A multimodal ComfyUI AI node pack with Qwen, Gemma 4, GGUF, Whisper, image/text decision routing, typed decision fields, and live thinking output."
|
||||
version = "3.1.2"
|
||||
version = "3.1.3"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["transformers>=5.2.0", "torch", "huggingface-hub", "hf_xet", "psutil", "numpy", "Pillow", "opencv-python", "bitsandbytes", "accelerate", "sentencepiece", "tiktoken", "faster-whisper", "av>=17,<19", "llama-cpp-python"]
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class HFThinkingControlTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.enhancer_source = (ROOT / "AILab_QwenVL_PromptEnhancer.py").read_text(encoding="utf-8")
|
||||
cls.enhancer_tree = ast.parse(cls.enhancer_source)
|
||||
cls.runtime_source = (ROOT / "thinkingllm_core" / "hf_runtime.py").read_text(encoding="utf-8")
|
||||
cls.runtime_tree = ast.parse(cls.runtime_source)
|
||||
|
||||
def test_hf_text_enhancer_passes_thinking_as_template_variable(self):
|
||||
calls = [node for node in ast.walk(self.enhancer_tree) if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute) and node.func.attr == "apply_chat_template"]
|
||||
self.assertTrue(calls, "HF text enhancer must call apply_chat_template")
|
||||
self.assertIn('local_template_kwargs["enable_thinking"] = thinking_enabled', self.enhancer_source)
|
||||
self.assertNotIn('"chat_template_kwargs"', self.enhancer_source)
|
||||
|
||||
def test_hf_text_enhancer_never_falls_back_to_raw_reasoning(self):
|
||||
self.assertNotIn("clean_model_output(raw_text, OutputCleanConfig(mode=\"prompt\")) or raw_text", self.enhancer_source)
|
||||
self.assertIn("Streaming produced no usable prompt after removing reasoning", self.enhancer_source)
|
||||
self.assertIn("Generation produced no usable prompt after removing reasoning", self.enhancer_source)
|
||||
self.assertIn("no usable prompt after removing reasoning", self.enhancer_source)
|
||||
|
||||
def test_hf_multimodal_runtime_passes_thinking_as_template_variable(self):
|
||||
calls = [node for node in ast.walk(self.runtime_tree) if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute) and node.func.attr == "apply_chat_template"]
|
||||
self.assertTrue(calls, "HF runtime must call apply_chat_template")
|
||||
self.assertIn('chat_kwargs["enable_thinking"] = thinking_enabled', self.runtime_source)
|
||||
|
||||
def test_hf_runtime_cleans_and_rejects_reasoning_only_before_cache(self):
|
||||
self.assertIn('mode="text",', self.runtime_source)
|
||||
self.assertIn('strip_code_fences=False,', self.runtime_source)
|
||||
self.assertIn('strip_role_prefixes=False,', self.runtime_source)
|
||||
self.assertIn('strip_json_wrappers=False,', self.runtime_source)
|
||||
self.assertIn('strip_leading_preamble=False,', self.runtime_source)
|
||||
self.assertIn('strip_planning=False,', self.runtime_source)
|
||||
self.assertIn("Generation returned no usable prompt after removing reasoning", self.runtime_source)
|
||||
text_config = OutputCleanConfig(
|
||||
mode="text",
|
||||
strip_think=True,
|
||||
strip_code_fences=False,
|
||||
strip_role_prefixes=False,
|
||||
strip_json_wrappers=False,
|
||||
strip_leading_preamble=False,
|
||||
strip_planning=False,
|
||||
)
|
||||
structured = '```json\n{"status":"ready","answer":42}\n```'
|
||||
with_reasoning = f"<think>private reasoning</think>\n{structured}"
|
||||
self.assertEqual(clean_model_output(with_reasoning, text_config), structured)
|
||||
self.assertEqual(clean_model_output("<think>reasoning only</think>", text_config), "")
|
||||
run_method = next(
|
||||
node for node in ast.walk(self.runtime_tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "run"
|
||||
)
|
||||
cleaner_line = next(
|
||||
node.lineno for node in ast.walk(run_method)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "clean_model_output"
|
||||
)
|
||||
cache_line = next(
|
||||
node.lineno for node in ast.walk(run_method)
|
||||
if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name)
|
||||
and node.value.id == "PROMPT_CACHE"
|
||||
)
|
||||
self.assertLess(cleaner_line, cache_line, "empty cleaned output must be rejected before caching")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+293
-2
@@ -1215,10 +1215,18 @@ class TestModelRecommendations(unittest.TestCase):
|
||||
module.get_cache_key("m", "p", "x", mask_prompt_contract_version=1),
|
||||
module.get_cache_key("m", "p", "x", mask_prompt_contract_version=2),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
module.get_cache_key("m", "p", "x", thinking_budget_policy_version=1),
|
||||
module.get_cache_key("m", "p", "x", thinking_budget_policy_version=2),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
module.build_node_input_signature(mask_prompt_contract_version=1),
|
||||
module.build_node_input_signature(mask_prompt_contract_version=2),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
module.build_node_input_signature(thinking_budget_policy_version=1),
|
||||
module.build_node_input_signature(thinking_budget_policy_version=2),
|
||||
)
|
||||
self.assertEqual(
|
||||
module.build_node_input_signature(effective_duration_seconds=five_seconds),
|
||||
module.build_node_input_signature(effective_duration_seconds=five_point_one_seconds),
|
||||
@@ -1475,7 +1483,9 @@ class TestModelRecommendations(unittest.TestCase):
|
||||
self.assertNotIn("MINIMAX STRUCTURE REPAIR", source)
|
||||
self.assertNotIn("_NON_THINKING_REASONING_CHAR_LIMIT", source)
|
||||
self.assertIn("final = ensure_video_prompt_duration(", source)
|
||||
self.assertIn("arch.startswith(\"qwen35\")", source)
|
||||
self.assertIn('normalized_arch = re.sub(r"[^a-z0-9]", "", str(arch or "").lower())', source)
|
||||
self.assertIn('normalized_arch in {"qwen3", "qwen35", "qwen35moe", "qwen3vl"}', source)
|
||||
self.assertIn('stage_label = f"FINALIZATION ATTEMPT {attempt_number}/{max_finalization_attempts}"', source)
|
||||
self.assertIn('completion_kwargs["chat_template_kwargs"]', source)
|
||||
self.assertIn('"thinking_mode": "enabled" if current_enable_thinking else "disabled"', source)
|
||||
for filename in (
|
||||
@@ -2135,7 +2145,7 @@ class TestSharedComfyUITextEncoders(unittest.TestCase):
|
||||
|
||||
self.assertEqual(("native answer", "native answer"), (text, raw))
|
||||
self.assertEqual([str(PKG / "qwen3vl_8b_fp8_scaled.safetensors")], calls["load"]["ckpt_paths"])
|
||||
self.assertTrue(calls["tokenize"][1]["thinking"])
|
||||
self.assertFalse(calls["tokenize"][1]["thinking"])
|
||||
self.assertEqual(7, calls["generate"][1]["seed"])
|
||||
calls["decoded_text"] = " "
|
||||
with self.assertRaisesRegex(RuntimeError, "without producing text"):
|
||||
@@ -4342,6 +4352,19 @@ class TestGGUFWindllRuntimeFallback(unittest.TestCase):
|
||||
self.assertEqual(captured_llama_kwargs[0]["n_ubatch"], 1120)
|
||||
self.assertEqual(captured_llama_kwargs[0]["image_max_tokens"], 1120)
|
||||
|
||||
def test_gguf_rejects_blank_final_text_before_cache_or_save(self):
|
||||
with mock.patch.dict(sys.modules, build_loader_test_stubs(), clear=False):
|
||||
module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF.py",
|
||||
"thinkingllm_gguf_blank_final_guard_test",
|
||||
)
|
||||
|
||||
self.assertEqual(module._require_nonempty_final_text(" final prompt ", source="QwenVL GGUF"), " final prompt ")
|
||||
for blank in ("", " \n\t"):
|
||||
with self.subTest(blank=repr(blank)):
|
||||
with self.assertRaisesRegex(RuntimeError, "empty output was not saved or cached"):
|
||||
module._require_nonempty_final_text(blank, source="QwenVL GGUF")
|
||||
|
||||
def test_gemma4_cpu_only_multimodal_guard_blocks_native_abort_risk(self):
|
||||
with mock.patch.dict(sys.modules, build_loader_test_stubs(), clear=False):
|
||||
module = load_module_from_file(
|
||||
@@ -4525,6 +4548,272 @@ class TestGGUFWindllRuntimeFallback(unittest.TestCase):
|
||||
self.assertEqual(loader.llm.chat_template_kwargs["enable_thinking"], False)
|
||||
self.assertEqual(captured_kwargs[0]["chat_template_kwargs"], {"enable_thinking": False})
|
||||
|
||||
def test_gguf_enforces_reasoning_budget_when_backend_exposes_it(self):
|
||||
with mock.patch.dict(sys.modules, build_loader_test_stubs(), clear=False):
|
||||
module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF.py",
|
||||
"thinkingllm_gguf_reasoning_budget_test",
|
||||
)
|
||||
|
||||
captured_kwargs = {}
|
||||
|
||||
class BudgetAwareLlama:
|
||||
def reset(self):
|
||||
return None
|
||||
|
||||
def create_chat_completion(
|
||||
self,
|
||||
*,
|
||||
reasoning_budget,
|
||||
reasoning_start,
|
||||
reasoning_end,
|
||||
reasoning_start_in_prompt,
|
||||
**kwargs,
|
||||
):
|
||||
captured_kwargs.update(kwargs)
|
||||
captured_kwargs.update({
|
||||
"reasoning_budget": reasoning_budget,
|
||||
"reasoning_start": reasoning_start,
|
||||
"reasoning_end": reasoning_end,
|
||||
"reasoning_start_in_prompt": reasoning_start_in_prompt,
|
||||
})
|
||||
return iter([{"choices": [{"delta": {"content": "answer"}}]}])
|
||||
|
||||
loader = module.QwenVLGGUFBase()
|
||||
loader.llm = BudgetAwareLlama()
|
||||
loader.chat_handler = types.SimpleNamespace(enable_thinking=True)
|
||||
loader.supports_qwen_soft_think = True
|
||||
text, _raw_trace = loader._invoke(
|
||||
system_prompt="system",
|
||||
user_prompt="user",
|
||||
images_b64=[],
|
||||
audio_b64=[],
|
||||
max_tokens=3000,
|
||||
temperature=0.0,
|
||||
top_p=1.0,
|
||||
repetition_penalty=1.0,
|
||||
seed=1,
|
||||
enable_thinking=True,
|
||||
reasoning_budget=1000,
|
||||
)
|
||||
|
||||
self.assertEqual(text, "answer")
|
||||
self.assertEqual(captured_kwargs["reasoning_budget"], 1000)
|
||||
self.assertEqual(captured_kwargs["reasoning_start"], "<think>")
|
||||
self.assertEqual(captured_kwargs["reasoning_end"], "</think>")
|
||||
self.assertTrue(captured_kwargs["reasoning_start_in_prompt"])
|
||||
|
||||
def test_qwen35_text_only_thinking_off_requires_effective_control(self):
|
||||
with mock.patch.dict(sys.modules, build_loader_test_stubs(), clear=False):
|
||||
module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF.py",
|
||||
"thinkingllm_gguf_qwen35_legacy_thinking_control_test",
|
||||
)
|
||||
|
||||
class LegacyLlama:
|
||||
def reset(self):
|
||||
return None
|
||||
|
||||
def create_chat_completion(self, **kwargs):
|
||||
return iter([{"choices": [{"delta": {"content": "answer"}}]}])
|
||||
|
||||
def make_loader(chat_handler=None):
|
||||
loader = module.QwenVLGGUFBase()
|
||||
loader.llm = LegacyLlama()
|
||||
loader.chat_handler = chat_handler
|
||||
loader.is_qwen35 = True
|
||||
loader.supports_qwen_soft_think = True
|
||||
return loader
|
||||
|
||||
invoke_kwargs = {
|
||||
"system_prompt": "system",
|
||||
"user_prompt": "user",
|
||||
"images_b64": [],
|
||||
"audio_b64": [],
|
||||
"max_tokens": 128,
|
||||
"temperature": 0.0,
|
||||
"top_p": 1.0,
|
||||
"repetition_penalty": 1.0,
|
||||
"seed": 1,
|
||||
"enable_thinking": False,
|
||||
}
|
||||
for media in (
|
||||
{"images_b64": ["test-image"]},
|
||||
{"audio_b64": ["test-audio"]},
|
||||
):
|
||||
with self.subTest(media=tuple(media)):
|
||||
with self.assertRaisesRegex(RuntimeError, "Cannot reliably disable Qwen3.5 thinking"):
|
||||
make_loader()._invoke(**{**invoke_kwargs, **media})
|
||||
with self.assertRaisesRegex(RuntimeError, "Cannot reliably disable Qwen3.5 thinking"):
|
||||
make_loader()._invoke(**invoke_kwargs)
|
||||
|
||||
supported_loader = make_loader(types.SimpleNamespace(enable_thinking=False))
|
||||
text, _raw_trace = supported_loader._invoke(**invoke_kwargs)
|
||||
self.assertEqual(text, "answer")
|
||||
|
||||
class BudgetAwareLegacyLlama(LegacyLlama):
|
||||
def create_chat_completion(
|
||||
self,
|
||||
*,
|
||||
reasoning_budget,
|
||||
reasoning_start,
|
||||
reasoning_end,
|
||||
reasoning_start_in_prompt,
|
||||
**kwargs,
|
||||
):
|
||||
self.used_reasoning_budget = reasoning_budget
|
||||
return iter([{"choices": [{"delta": {"content": "answer"}}]}])
|
||||
|
||||
budget_loader = make_loader()
|
||||
budget_loader.llm = BudgetAwareLegacyLlama()
|
||||
budget_loader._invoke(**invoke_kwargs)
|
||||
self.assertEqual(budget_loader.llm.used_reasoning_budget, 0)
|
||||
|
||||
def test_gguf_prompt_enhancer_cutoff_is_dynamic_without_backend_budget_api(self):
|
||||
stubs = build_loader_test_stubs()
|
||||
stubs["AILab_QwenVL_GGUF"] = build_stub_module(
|
||||
"AILab_QwenVL_GGUF",
|
||||
construct_llama_safely=lambda *args, **kwargs: None,
|
||||
read_gguf_architecture=lambda *args, **kwargs: "qwen35",
|
||||
_supports_reasoning_budget=lambda llm: False,
|
||||
register_active_gguf_loader=lambda *args, **kwargs: None,
|
||||
release_other_gguf_loaders=lambda *args, **kwargs: None,
|
||||
_filter_kwargs_for_callable=lambda func, kwargs: kwargs,
|
||||
_find_existing_local_file_in_dirs=lambda *args, **kwargs: None,
|
||||
_gguf_search_dirs=lambda *args, **kwargs: [],
|
||||
)
|
||||
with mock.patch.dict(sys.modules, stubs, clear=False):
|
||||
module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF_PromptEnhancer.py",
|
||||
"thinkingllm_gguf_prompt_enhancer_dynamic_cutoff_test",
|
||||
)
|
||||
|
||||
module.ensure_cuda_vram_headroom = lambda *args, **kwargs: None
|
||||
module.log_llm_input = lambda *args, **kwargs: None
|
||||
module.throw_exception_if_processing_interrupted = lambda: None
|
||||
module._install_llama_abort_callback = lambda *args, **kwargs: None
|
||||
module.extract_stream_token = lambda chunk: {
|
||||
"reasoning": "",
|
||||
"content": chunk["choices"][0]["delta"].get("content", ""),
|
||||
}
|
||||
module.compose_streamed_model_output = lambda reasoning, content: content
|
||||
module.clean_model_output = lambda text, config=None: text.strip()
|
||||
module.OutputCleanConfig = lambda **kwargs: kwargs
|
||||
module._preset_output_is_usable = lambda text, *args: bool(text)
|
||||
module.apply_qwen_soft_thinking_directive = lambda text, *args, **kwargs: text
|
||||
module.strip_degenerate_repetition = lambda text: text
|
||||
|
||||
class DummyLlama:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
def create_chat_completion(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return iter([{"choices": [{"delta": {"content": "final answer"}}]}])
|
||||
|
||||
for max_tokens, reasoning_budget, answer_reserve, expected_thinking in (
|
||||
(3000, 1000, 2000, False),
|
||||
(3072, 1024, 2048, True),
|
||||
):
|
||||
loader = module.ThinkingLLM_QwenVL_GGUF_PromptEnhancer()
|
||||
loader.llm = DummyLlama()
|
||||
loader.supports_qwen_soft_think = True
|
||||
text, _raw_trace = loader._invoke_llama(
|
||||
system_prompt="system",
|
||||
user_prompt="user",
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.5,
|
||||
top_p=0.9,
|
||||
repetition_penalty=1.1,
|
||||
seed=1,
|
||||
stream_to_terminal=False,
|
||||
enable_thinking=True,
|
||||
reasoning_budget=reasoning_budget,
|
||||
answer_reserve_tokens=answer_reserve,
|
||||
)
|
||||
self.assertEqual(text, "final answer")
|
||||
self.assertEqual(
|
||||
loader.llm.calls[0]["chat_template_kwargs"]["enable_thinking"],
|
||||
expected_thinking,
|
||||
)
|
||||
self.assertNotIn("reasoning_budget", loader.llm.calls[0])
|
||||
|
||||
module._supports_reasoning_budget = lambda llm: True
|
||||
loader = module.ThinkingLLM_QwenVL_GGUF_PromptEnhancer()
|
||||
loader.llm = DummyLlama()
|
||||
loader.supports_qwen_soft_think = True
|
||||
loader.is_qwen35 = True
|
||||
text, _raw_trace = loader._invoke_llama(
|
||||
system_prompt="system",
|
||||
user_prompt="user",
|
||||
max_tokens=1000,
|
||||
temperature=0.5,
|
||||
top_p=0.9,
|
||||
repetition_penalty=1.1,
|
||||
seed=1,
|
||||
stream_to_terminal=False,
|
||||
enable_thinking=False,
|
||||
reasoning_budget=0,
|
||||
answer_reserve_tokens=0,
|
||||
)
|
||||
self.assertEqual(text, "final answer")
|
||||
call = loader.llm.calls[0]
|
||||
self.assertEqual(call["reasoning_budget"], 0)
|
||||
self.assertFalse(call["reasoning_start_in_prompt"])
|
||||
self.assertTrue(call["assistant_prefill"])
|
||||
self.assertEqual(call["messages"][-1], {
|
||||
"role": "assistant",
|
||||
"content": "<think>\n\n</think>\n",
|
||||
})
|
||||
|
||||
class BudgetAwarePromptLlama(DummyLlama):
|
||||
def create_chat_completion(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return iter([{"choices": [{"delta": {"content": "final answer"}}]}])
|
||||
|
||||
for max_tokens, requested_reserve, expected_thinking, expected_budget in (
|
||||
(3000, 2000, False, 0),
|
||||
(3072, 2048, True, 1024),
|
||||
):
|
||||
available, answer_reserve, planned_budget = module.resolve_qwen_output_token_plan(
|
||||
max_tokens,
|
||||
answer_reserve_tokens=requested_reserve,
|
||||
max_reasoning_tokens=module.QWEN_MAX_REASONING_BUDGET_TOKENS,
|
||||
)
|
||||
effective_thinking = module.resolve_qwen_thinking_mode(
|
||||
True,
|
||||
max_tokens,
|
||||
label="GGUF preflight regression",
|
||||
min_reasoning_tokens=module.QWEN_MIN_REASONING_BUDGET_TOKENS,
|
||||
answer_reserve_tokens=answer_reserve,
|
||||
quiet=True,
|
||||
)
|
||||
budget_loader = module.ThinkingLLM_QwenVL_GGUF_PromptEnhancer()
|
||||
budget_loader.llm = BudgetAwarePromptLlama()
|
||||
budget_loader.supports_qwen_soft_think = True
|
||||
budget_loader.is_qwen35 = True
|
||||
budget_loader._invoke_llama(
|
||||
system_prompt="system",
|
||||
user_prompt="user",
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.5,
|
||||
top_p=0.9,
|
||||
repetition_penalty=1.1,
|
||||
seed=1,
|
||||
stream_to_terminal=False,
|
||||
enable_thinking=effective_thinking,
|
||||
reasoning_budget=planned_budget if effective_thinking else 0,
|
||||
answer_reserve_tokens=answer_reserve,
|
||||
)
|
||||
call = budget_loader.llm.calls[0]
|
||||
self.assertEqual(available, max_tokens)
|
||||
self.assertEqual(effective_thinking, expected_thinking)
|
||||
self.assertEqual(call["chat_template_kwargs"]["enable_thinking"], expected_thinking)
|
||||
self.assertEqual(call["reasoning_budget"], expected_budget)
|
||||
|
||||
def test_create_chat_completion_retries_without_unexpected_kwarg(self):
|
||||
with mock.patch.dict(sys.modules, build_loader_test_stubs(), clear=False):
|
||||
module = load_module_from_file(
|
||||
@@ -4670,6 +4959,7 @@ class TestGGUFWindllRuntimeFallback(unittest.TestCase):
|
||||
"AILab_QwenVL_GGUF",
|
||||
construct_llama_safely=lambda *args, **kwargs: None,
|
||||
read_gguf_architecture=lambda *args, **kwargs: "qwen35",
|
||||
_supports_reasoning_budget=lambda llm: False,
|
||||
register_active_gguf_loader=lambda *args, **kwargs: None,
|
||||
release_other_gguf_loaders=lambda *args, **kwargs: None,
|
||||
_filter_kwargs_for_callable=lambda func, kwargs: kwargs,
|
||||
@@ -4760,6 +5050,7 @@ class TestGGUFWindllRuntimeFallback(unittest.TestCase):
|
||||
"AILab_QwenVL_GGUF",
|
||||
construct_llama_safely=lambda *args, **kwargs: None,
|
||||
read_gguf_architecture=lambda *args, **kwargs: "qwen35",
|
||||
_supports_reasoning_budget=lambda llm: False,
|
||||
register_active_gguf_loader=lambda *args, **kwargs: None,
|
||||
release_other_gguf_loaders=lambda *args, **kwargs: None,
|
||||
_filter_kwargs_for_callable=lambda func, kwargs: kwargs,
|
||||
|
||||
@@ -180,6 +180,27 @@ class SecurityTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, 'exceeds server-side limit'):
|
||||
api_node._validate_thinking_budget(p, 4097)
|
||||
|
||||
def test_qwen_total_completion_budget_disables_thinking_below_cutoff(self):
|
||||
p = self.profile(thinking_mode='qwen', max_tokens_field='max_completion_tokens')
|
||||
effective, budget, plan = api_node._resolve_api_qwen_thinking(p, 3000, True, 8192)
|
||||
self.assertFalse(effective)
|
||||
self.assertEqual(budget, 0)
|
||||
self.assertEqual(plan, (3000, 2000))
|
||||
|
||||
def test_qwen_total_completion_budget_caps_thinking_and_preserves_answer_reserve(self):
|
||||
p = self.profile(thinking_mode='qwen', max_tokens_field='max_completion_tokens')
|
||||
effective, budget, plan = api_node._resolve_api_qwen_thinking(p, 3072, True, 8192)
|
||||
self.assertTrue(effective)
|
||||
self.assertEqual(budget, 1024)
|
||||
self.assertEqual(plan, (3072, 2048))
|
||||
|
||||
def test_qwen_answer_only_max_tokens_keeps_provider_budget_semantics(self):
|
||||
p = self.profile(thinking_mode='qwen')
|
||||
self.assertEqual(
|
||||
api_node._resolve_api_qwen_thinking(p, 3000, True, 8192),
|
||||
(True, 8192, None),
|
||||
)
|
||||
|
||||
def test_allowed_models_requires_strings(self):
|
||||
with self.assertRaisesRegex(ValueError, 'list of strings'):
|
||||
self.profile(allowed_models=[123])
|
||||
@@ -236,6 +257,26 @@ class SecurityTests(unittest.TestCase):
|
||||
def test_remote_node_always_reruns(self):
|
||||
self.assertTrue(math.isnan(api_node.ThinkingLLMOpenAICompatibleAPI.IS_CHANGED()))
|
||||
|
||||
def test_generation_rejects_reasoning_only_provider_responses(self):
|
||||
for message in (
|
||||
{'content': '', 'reasoning_content': 'still planning'},
|
||||
{'content': '<think>x</think>'},
|
||||
):
|
||||
with self.subTest(message=message):
|
||||
response = mock.MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.headers = {'Content-Type': 'application/json'}
|
||||
response.read.return_value = json.dumps({'choices': [{'message': message}]}).encode()
|
||||
with mock.patch.object(api_node, '_resolve_profile', return_value=self.profile()), \
|
||||
mock.patch.object(api_node, '_authorization_headers', return_value={}), \
|
||||
mock.patch.object(api_node, '_open_request', return_value=response):
|
||||
with self.assertRaisesRegex(RuntimeError, 'no final answer'):
|
||||
api_node.ThinkingLLMOpenAICompatibleAPI().generate(
|
||||
api_profile='Production', model_name='test-model', system_prompt='', prompt='test',
|
||||
max_tokens=1000, temperature=0.7, top_p=0.9, seed=1, enable_thinking=False,
|
||||
thinking_budget=0, stream_tokens_to_terminal=False, timeout_seconds=30,
|
||||
)
|
||||
|
||||
def test_image_support_is_fail_closed_for_custom_profiles(self):
|
||||
p = self.profile()
|
||||
self.assertFalse(p['allow_images'])
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import unittest
|
||||
|
||||
from thinkingllm_core.prompt_contracts import (
|
||||
QWEN_MAX_REASONING_BUDGET_TOKENS,
|
||||
resolve_qwen_output_token_plan,
|
||||
resolve_qwen_thinking_mode,
|
||||
)
|
||||
|
||||
|
||||
class QwenThinkingBudgetTests(unittest.TestCase):
|
||||
def test_default_plan_scales_answer_reserve_with_available_output(self):
|
||||
self.assertEqual(
|
||||
resolve_qwen_output_token_plan(3000),
|
||||
(3000, 2000, 1000),
|
||||
)
|
||||
|
||||
def test_plan_accounts_for_prompt_and_context_overhead(self):
|
||||
self.assertEqual(
|
||||
resolve_qwen_output_token_plan(
|
||||
4096,
|
||||
prompt_tokens=3000,
|
||||
context_window=3500,
|
||||
),
|
||||
(372, 512, 0),
|
||||
)
|
||||
|
||||
def test_plan_caps_reasoning_only_when_requested(self):
|
||||
available, answer_reserve, reasoning_budget = resolve_qwen_output_token_plan(
|
||||
8192,
|
||||
max_reasoning_tokens=QWEN_MAX_REASONING_BUDGET_TOKENS,
|
||||
)
|
||||
self.assertEqual((available, answer_reserve), (8192, 5462))
|
||||
self.assertEqual(reasoning_budget, QWEN_MAX_REASONING_BUDGET_TOKENS)
|
||||
|
||||
def test_default_thinking_gate_disables_at_3000_tokens(self):
|
||||
self.assertFalse(resolve_qwen_thinking_mode(True, 3000, quiet=True))
|
||||
|
||||
def test_default_thinking_gate_allows_budget_at_3072_tokens(self):
|
||||
self.assertTrue(resolve_qwen_thinking_mode(True, 3072, quiet=True))
|
||||
|
||||
def test_explicit_enforced_budget_can_use_shorter_reasoning(self):
|
||||
self.assertTrue(
|
||||
resolve_qwen_thinking_mode(
|
||||
True,
|
||||
3000,
|
||||
min_reasoning_tokens=512,
|
||||
answer_reserve_tokens=2000,
|
||||
quiet=True,
|
||||
)
|
||||
)
|
||||
|
||||
def test_nonpositive_output_budget_disables_thinking(self):
|
||||
self.assertFalse(resolve_qwen_thinking_mode(True, 0, quiet=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,7 @@ import time
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output
|
||||
try:
|
||||
from transformers import AutoProcessor, AutoTokenizer
|
||||
except ImportError:
|
||||
@@ -45,6 +46,7 @@ from thinkingllm_core.media import (
|
||||
from thinkingllm_core.model_access import resolve_model_catalog_name
|
||||
from thinkingllm_core.hf_trust import trust_remote_code_for_snapshot
|
||||
from thinkingllm_core.prompt_contracts import (
|
||||
QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
apply_qwen_soft_thinking_directive,
|
||||
apply_video_duration_context,
|
||||
ensure_video_prompt_duration,
|
||||
@@ -309,21 +311,34 @@ class QwenVLBase:
|
||||
if native_family == "gemma3" and video is not None:
|
||||
raise ValueError("[QwenVL] Gemma 3 shared text encoders support text and image input, not video")
|
||||
|
||||
tokenize_kwargs = {"skip_template": False, "thinking": bool(enable_thinking)}
|
||||
if native_family == "gemma3":
|
||||
tokenize_kwargs["image"] = image[:1] if image is not None else None
|
||||
else:
|
||||
images = []
|
||||
native_images = []
|
||||
if native_family != "gemma3":
|
||||
if image is not None:
|
||||
if image.dim() == 4 and image.shape[0] > 1:
|
||||
print(f"[QwenVL] IMAGE input contains {image.shape[0]} items; using the first item only. Use the video input for multi-frame analysis.")
|
||||
images.append(image[:1] if image.dim() == 4 else image)
|
||||
native_images.append(image[:1] if image.dim() == 4 else image)
|
||||
if video is not None:
|
||||
frame_indexes = list(range(video.shape[0]))
|
||||
if len(frame_indexes) > frame_count:
|
||||
frame_indexes = np.linspace(0, len(frame_indexes) - 1, frame_count, dtype=int).tolist()
|
||||
images.extend(video[index:index + 1] for index in frame_indexes)
|
||||
tokenize_kwargs["images"] = images
|
||||
native_images.extend(video[index:index + 1] for index in frame_indexes)
|
||||
|
||||
def _tokenize_native(thinking_enabled: bool):
|
||||
tokenize_kwargs = {"skip_template": False, "thinking": bool(thinking_enabled)}
|
||||
if native_family == "gemma3":
|
||||
tokenize_kwargs["image"] = image[:1] if image is not None else None
|
||||
else:
|
||||
tokenize_kwargs["images"] = native_images
|
||||
return self.model.tokenize(prompt_text, **tokenize_kwargs)
|
||||
|
||||
requested_thinking = bool(enable_thinking)
|
||||
effective_thinking = resolve_qwen_thinking_mode(
|
||||
requested_thinking,
|
||||
max_tokens,
|
||||
label="QwenVL ComfyUI Native",
|
||||
quiet=stream_to_terminal,
|
||||
)
|
||||
tokens = _tokenize_native(effective_thinking)
|
||||
|
||||
throw_exception_if_processing_interrupted()
|
||||
log_llm_input(
|
||||
@@ -333,10 +348,9 @@ class QwenVLBase:
|
||||
prompt_text,
|
||||
media={
|
||||
"image": 1 if image is not None else 0,
|
||||
"video_frames": max(0, len(tokenize_kwargs.get("images", [])) - (1 if image is not None else 0)),
|
||||
"video_frames": max(0, len(native_images) - (1 if image is not None else 0)),
|
||||
},
|
||||
)
|
||||
tokens = self.model.tokenize(prompt_text, **tokenize_kwargs)
|
||||
output_tokens = self.model.generate(
|
||||
tokens,
|
||||
do_sample=True,
|
||||
@@ -563,6 +577,7 @@ class QwenVLBase:
|
||||
use_torch_compile=bool(use_torch_compile),
|
||||
device=device,
|
||||
enable_thinking=bool(enable_thinking),
|
||||
thinking_budget_policy_version=QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
@@ -606,6 +621,7 @@ class QwenVLBase:
|
||||
top_p=top_p,
|
||||
repetition_penalty=repetition_penalty,
|
||||
enable_thinking=enable_thinking,
|
||||
thinking_budget_policy_version=QWEN_THINKING_BUDGET_POLICY_VERSION,
|
||||
effective_duration_seconds=effective_duration_seconds,
|
||||
video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None,
|
||||
mask_prompt_contract_version=MASK_PROMPT_CONTRACT_VERSION if mask_hash else None,
|
||||
@@ -655,6 +671,23 @@ class QwenVLBase:
|
||||
seed=seed,
|
||||
preset_name=llm_input_preset_name or preset_prompt,
|
||||
)
|
||||
text = clean_model_output(
|
||||
raw_text,
|
||||
OutputCleanConfig(
|
||||
mode="text",
|
||||
strip_think=True,
|
||||
strip_code_fences=False,
|
||||
strip_role_prefixes=False,
|
||||
strip_json_wrappers=False,
|
||||
strip_leading_preamble=False,
|
||||
strip_planning=False,
|
||||
),
|
||||
).strip()
|
||||
if not text:
|
||||
raise RuntimeError(
|
||||
"[QwenVL HF] Generation returned no usable prompt after removing reasoning; "
|
||||
"the result was not cached or saved. Try disabling thinking or increasing the output token limit."
|
||||
)
|
||||
text = ensure_video_prompt_duration(
|
||||
text,
|
||||
duration_preset_name,
|
||||
|
||||
@@ -5,7 +5,12 @@ import re
|
||||
|
||||
QWEN_MIN_REASONING_BUDGET_TOKENS = 1024
|
||||
QWEN_FINAL_ANSWER_RESERVE_TOKENS = 256
|
||||
QWEN_MIN_FINAL_ANSWER_TOKENS = 512
|
||||
QWEN_FINAL_ANSWER_RESERVE_NUMERATOR = 2
|
||||
QWEN_FINAL_ANSWER_RESERVE_DENOMINATOR = 3
|
||||
QWEN_MAX_REASONING_BUDGET_TOKENS = 2048
|
||||
QWEN_CONTEXT_OVERHEAD_TOKENS = 128
|
||||
QWEN_THINKING_BUDGET_POLICY_VERSION = 4
|
||||
_QWEN_SOFT_SWITCHES = {"/think", "/no_think", "/nothink"}
|
||||
MINIMAX_H3_REFERENCE_PRESET = "🖼️ MiniMax H3 Reference-to-Video"
|
||||
VIDEO_PROMPT_CONTRACT_VERSION = 10
|
||||
@@ -72,6 +77,69 @@ def resolve_qwen_context_window(config, fallback=32768):
|
||||
return int(fallback)
|
||||
|
||||
|
||||
def resolve_qwen_output_token_plan(
|
||||
max_tokens,
|
||||
*,
|
||||
prompt_tokens=None,
|
||||
context_window=None,
|
||||
answer_reserve_tokens=None,
|
||||
min_answer_reserve_tokens=QWEN_MIN_FINAL_ANSWER_TOKENS,
|
||||
max_reasoning_tokens=None,
|
||||
context_overhead_tokens=QWEN_CONTEXT_OVERHEAD_TOKENS,
|
||||
):
|
||||
"""Estimate usable output tokens and reserve answer space before generation.
|
||||
|
||||
When no explicit answer reserve is supplied, reserve at least two thirds of
|
||||
the available output for the final answer. This is a conservative planning
|
||||
heuristic; it cannot predict how many tokens a model will actually spend
|
||||
reasoning unless the backend enforces the returned reasoning budget.
|
||||
"""
|
||||
try:
|
||||
requested_output = max(0, int(max_tokens or 0))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
requested_output = 0
|
||||
try:
|
||||
prompt_token_count = max(0, int(prompt_tokens or 0))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
prompt_token_count = 0
|
||||
|
||||
available_output = requested_output
|
||||
if context_window is not None:
|
||||
try:
|
||||
context_limit = int(context_window)
|
||||
context_overhead = max(0, int(context_overhead_tokens))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
context_limit = 0
|
||||
context_overhead = 0
|
||||
if context_limit > 0:
|
||||
remaining_context = max(0, context_limit - prompt_token_count - context_overhead)
|
||||
available_output = min(available_output, remaining_context)
|
||||
|
||||
if answer_reserve_tokens is None:
|
||||
try:
|
||||
min_answer_reserve = max(0, int(min_answer_reserve_tokens))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
min_answer_reserve = QWEN_MIN_FINAL_ANSWER_TOKENS
|
||||
scaled_answer_reserve = (
|
||||
available_output * QWEN_FINAL_ANSWER_RESERVE_NUMERATOR
|
||||
+ QWEN_FINAL_ANSWER_RESERVE_DENOMINATOR - 1
|
||||
) // QWEN_FINAL_ANSWER_RESERVE_DENOMINATOR
|
||||
answer_reserve = max(min_answer_reserve, scaled_answer_reserve)
|
||||
else:
|
||||
try:
|
||||
answer_reserve = max(0, int(answer_reserve_tokens))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
answer_reserve = QWEN_MIN_FINAL_ANSWER_TOKENS
|
||||
|
||||
reasoning_budget = max(0, available_output - answer_reserve)
|
||||
if max_reasoning_tokens is not None:
|
||||
try:
|
||||
reasoning_budget = min(reasoning_budget, max(0, int(max_reasoning_tokens)))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
return available_output, answer_reserve, reasoning_budget
|
||||
|
||||
|
||||
def resolve_qwen_thinking_mode(
|
||||
requested_enable,
|
||||
max_tokens,
|
||||
@@ -80,24 +148,40 @@ def resolve_qwen_thinking_mode(
|
||||
prompt_tokens=None,
|
||||
context_window=None,
|
||||
min_reasoning_tokens=QWEN_MIN_REASONING_BUDGET_TOKENS,
|
||||
answer_reserve_tokens=QWEN_FINAL_ANSWER_RESERVE_TOKENS,
|
||||
answer_reserve_tokens=None,
|
||||
min_answer_reserve_tokens=QWEN_MIN_FINAL_ANSWER_TOKENS,
|
||||
context_overhead_tokens=QWEN_CONTEXT_OVERHEAD_TOKENS,
|
||||
quiet=False,
|
||||
):
|
||||
"""Enable thinking only when requested and enough generation budget remains."""
|
||||
if not bool(requested_enable):
|
||||
return False
|
||||
requested_output = max(0, int(max_tokens or 0))
|
||||
available_output = requested_output
|
||||
prompt_token_count = max(0, int(prompt_tokens or 0))
|
||||
context_limit = int(context_window) if context_window is not None else None
|
||||
|
||||
if context_limit is not None:
|
||||
remaining_context = max(0, context_limit - prompt_token_count - int(context_overhead_tokens))
|
||||
available_output = min(available_output, remaining_context) if available_output else remaining_context
|
||||
|
||||
minimum_required = int(min_reasoning_tokens) + int(answer_reserve_tokens)
|
||||
if available_output <= 0 or available_output < minimum_required:
|
||||
try:
|
||||
requested_output = max(0, int(max_tokens or 0))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
requested_output = 0
|
||||
try:
|
||||
prompt_token_count = max(0, int(prompt_tokens or 0))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
prompt_token_count = 0
|
||||
try:
|
||||
context_limit = int(context_window) if context_window is not None else None
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
context_limit = None
|
||||
available_output, answer_reserve, reasoning_budget = resolve_qwen_output_token_plan(
|
||||
requested_output,
|
||||
prompt_tokens=prompt_token_count,
|
||||
context_window=context_limit,
|
||||
answer_reserve_tokens=answer_reserve_tokens,
|
||||
min_answer_reserve_tokens=min_answer_reserve_tokens,
|
||||
context_overhead_tokens=context_overhead_tokens,
|
||||
)
|
||||
try:
|
||||
minimum_reasoning = max(0, int(min_reasoning_tokens))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
minimum_reasoning = QWEN_MIN_REASONING_BUDGET_TOKENS
|
||||
minimum_required = minimum_reasoning + answer_reserve
|
||||
if available_output <= 0 or reasoning_budget < minimum_reasoning:
|
||||
if context_limit is not None:
|
||||
budget_note = (
|
||||
f"max_tokens={requested_output}, prompt_tokens={prompt_token_count}, "
|
||||
@@ -108,7 +192,7 @@ def resolve_qwen_thinking_mode(
|
||||
if not quiet:
|
||||
print(
|
||||
f"[{label}] Thinking requested but {budget_note} is below the required {minimum_required} tokens "
|
||||
f"({int(min_reasoning_tokens)} reasoning + {int(answer_reserve_tokens)} answer reserve); "
|
||||
f"({minimum_reasoning} reasoning + {answer_reserve} answer reserve); "
|
||||
"forcing non-thinking mode to preserve answer space."
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -203,6 +203,7 @@ def get_cache_key(
|
||||
effective_duration_seconds=None,
|
||||
video_prompt_contract_version=None,
|
||||
mask_prompt_contract_version=None,
|
||||
thinking_budget_policy_version=None,
|
||||
):
|
||||
"""Generate cache key from inputs including all generation parameters.
|
||||
|
||||
@@ -222,6 +223,8 @@ def get_cache_key(
|
||||
"repetition_penalty": repetition_penalty if repetition_penalty is not None else None,
|
||||
"enable_thinking": bool(enable_thinking) if enable_thinking is not None else None,
|
||||
}
|
||||
if thinking_budget_policy_version is not None:
|
||||
key_data["thinking_budget_policy_version"] = thinking_budget_policy_version
|
||||
if effective_duration_seconds is not None:
|
||||
key_data["effective_duration_seconds"] = effective_duration_seconds
|
||||
if video_prompt_contract_version is not None:
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# ComfyUI-QwenVL Update Log
|
||||
|
||||
## Version 3.1.3 (2026/11/02) - Reliable Thinking Control and Final Output
|
||||
|
||||
### 3.1.3 Changes
|
||||
|
||||
- Unified the dynamic Qwen thinking preflight across HF, GGUF, and API paths. GGUF prompt enhancement now uses the shared minimum reasoning threshold and applies a per-request reasoning cap when supported by the backend.
|
||||
- Corrected Qwen3.5 thinking control for GGUF and HF templates; text-only legacy GGUF backends without a reliable control now fail clearly instead of silently consuming the output budget.
|
||||
- Prevented empty or reasoning-only results from being reported as successful or cached, while preserving structured HF answers such as JSON and code blocks.
|
||||
|
||||
### 3.1.3 Validation
|
||||
|
||||
- 162 GGUF/feature tests and 53 HF, API, and token-policy tests passed. Python compilation and `git diff --check` passed.
|
||||
- Live ComfyUI smoke tests with Qwen3.5-9B GGUF and `max_tokens=3000`, temperature 0.9, top-p 0.7, and repetition penalty 1.5 produced non-empty output; the automatic Thinking cutoff was confirmed with Thinking initially enabled.
|
||||
- Reviewed 12 parser traces and confirmed incomplete reasoning was removed while completed final answers were preserved.
|
||||
|
||||
## Version 3.1.2 (2026/11/01) - Preset and Mask Focus Improvements
|
||||
|
||||
### 3.1.2 Changes
|
||||
|
||||
Reference in New Issue
Block a user