33Provides text generation using local LLM/VLM models via LM Studio server.
44"""
55import logging
6+ import re
67from typing import Optional , Tuple , List
78from tempfile import NamedTemporaryFile
89import numpy as np
5859 "Ultra (1536px)" : 1536 ,
5960}
6061
62+ # Reasoning extraction modes
63+ REASONING_MODE_OPTIONS = [
64+ "Auto-detect (recommended)" ,
65+ "Disabled" ,
66+ "Custom tags" ,
67+ ]
68+
69+ # Common reasoning tag patterns used by different models
70+ # Order matters - most common first for efficiency
71+ COMMON_REASONING_PATTERNS = [
72+ # DeepSeek R1, Qwen3, QwQ, GLM-4/Z1 - most common
73+ (r"<think>(.*?)</think>" , "<think>" , "</think>" ),
74+ # Alternative spelling
75+ (r"<thinking>(.*?)</thinking>" , "<thinking>" , "</thinking>" ),
76+ # Some models use this
77+ (r"<reasoning>(.*?)</reasoning>" , "<reasoning>" , "</reasoning>" ),
78+ # Occasionally seen
79+ (r"<reason>(.*?)</reason>" , "<reason>" , "</reason>" ),
80+ # GPT-OSS style (simplified pattern for the analysis channel)
81+ (r"<\|start\|>assistant<\|channel\|>analysis<\|message\|>(.*?)<\|end\|>" ,
82+ "<|start|>assistant<|channel|>analysis<|message|>" , "<|end|>" ),
83+ ]
84+
6185
6286class YANCLMStudio :
6387 """
6488 LM Studio integration node for ComfyUI.
6589 Queries local LM Studio server for text generation with LLM/VLM models.
90+
91+ Note: model.respond() automatically applies the model's chat template.
6692 """
6793
6894 CATEGORY = "YANC/LMStudio"
@@ -153,7 +179,14 @@ def INPUT_TYPES(cls):
153179 "min" : 0.0 ,
154180 "max" : 1.0 ,
155181 "step" : 0.05 ,
156- "tooltip" : "Nucleus sampling threshold. Lower values (0.1-0.9) = more focused. 1.0 = disabled."
182+ "tooltip" : "Nucleus sampling: only consider tokens with cumulative probability >= top_p. Lower = more focused. 1.0 = disabled."
183+ }),
184+ "top_k" : ("INT" , {
185+ "default" : 0 ,
186+ "min" : 0 ,
187+ "max" : 500 ,
188+ "step" : 1 ,
189+ "tooltip" : "Top-K sampling: only consider the K most likely tokens. Lower = more focused. 0 = disabled. Recommended: 20-40 for thinking models."
157190 }),
158191 "repeat_penalty" : ("FLOAT" , {
159192 "default" : 1.0 ,
@@ -162,11 +195,20 @@ def INPUT_TYPES(cls):
162195 "step" : 0.05 ,
163196 "tooltip" : "Penalizes repeated tokens. Higher values (1.1-1.3) reduce repetition. 1.0 = disabled."
164197 }),
165- # --- Extraction and management ---
166- "reasoning_tag" : ("STRING" , {
198+ # --- Reasoning extraction ---
199+ "reasoning_mode" : (REASONING_MODE_OPTIONS , {
200+ "default" : "Auto-detect (recommended)" ,
201+ "tooltip" : "How to extract reasoning/thinking from model output. Auto-detect works with DeepSeek, Qwen, QwQ, GLM, GPT-OSS and similar models."
202+ }),
203+ "custom_open_tag" : ("STRING" , {
167204 "default" : "<think>" ,
168- "tooltip" : "Opening tag to identify reasoning sections (e.g., '<think>' for DeepSeek R1). Reasoning is extracted to separate output ."
205+ "tooltip" : "Custom opening tag for reasoning extraction. Only used when reasoning_mode is 'Custom tags' ."
169206 }),
207+ "custom_close_tag" : ("STRING" , {
208+ "default" : "</think>" ,
209+ "tooltip" : "Custom closing tag for reasoning extraction. Only used when reasoning_mode is 'Custom tags'."
210+ }),
211+ # --- Management ---
170212 "unload_llm" : ("BOOLEAN" , {
171213 "default" : True ,
172214 "tooltip" : "Unload the LLM from LM Studio after generation. Recommended to free VRAM for image generation."
@@ -285,48 +327,64 @@ def _convert_image_to_pil(self, image_tensor, resize_option: str = "No Resize")
285327 logger .error (f"Failed to convert image: { e } " )
286328 return None
287329
288- def _extract_reasoning (self , text : str , opening_tag : str ) -> Tuple [str , str ]:
330+ def _extract_reasoning_auto (self , text : str ) -> Tuple [str , str , Optional [str ]]:
331+ """
332+ Auto-detect and extract reasoning using common patterns.
333+
334+ Args:
335+ text: Full response text
336+
337+ Returns:
338+ Tuple of (response_without_reasoning, reasoning_content, detected_pattern)
339+ detected_pattern is None if no pattern matched
340+ """
341+ for pattern , open_tag , close_tag in COMMON_REASONING_PATTERNS :
342+ # Use DOTALL to match across newlines
343+ matches = list (re .finditer (pattern , text , re .DOTALL ))
344+ if matches :
345+ reasoning_parts = [m .group (1 ) for m in matches ]
346+ # Remove all matched reasoning blocks from text
347+ clean_text = re .sub (pattern , "" , text , flags = re .DOTALL )
348+ return clean_text .strip (), "\n ---\n " .join (reasoning_parts ).strip (), open_tag
349+
350+ # No pattern matched
351+ return text , "" , None
352+
353+ def _extract_reasoning_custom (self , text : str , open_tag : str , close_tag : str ) -> Tuple [str , str ]:
289354 """
290- Extract reasoning content from response .
355+ Extract reasoning using custom tags .
291356
292357 Args:
293358 text: Full response text
294- opening_tag: Opening tag like "<think>"
359+ open_tag: Opening tag
360+ close_tag: Closing tag
295361
296362 Returns:
297363 Tuple of (response_without_reasoning, reasoning_content)
298364 """
299- if not opening_tag or opening_tag not in text :
365+ if not open_tag or open_tag not in text :
300366 return text , ""
301367
302- # Derive closing tag
303- if opening_tag .startswith ("<" ) and opening_tag .endswith (">" ):
304- tag_name = opening_tag [1 :- 1 ]
305- closing_tag = f"</{ tag_name } >"
306- else :
307- # Non-XML style tag - just use same tag as closer
308- closing_tag = opening_tag
309-
310368 reasoning_parts = []
311369 response_text = text
312370
313371 # Extract all reasoning blocks
314- while opening_tag in response_text :
315- start_idx = response_text .find (opening_tag )
316- end_idx = response_text .find (closing_tag , start_idx )
372+ while open_tag in response_text :
373+ start_idx = response_text .find (open_tag )
374+ end_idx = response_text .find (close_tag , start_idx + len ( open_tag ) )
317375
318376 if end_idx == - 1 :
319377 # No closing tag - take rest as reasoning
320- reasoning_parts .append (response_text [start_idx + len (opening_tag ):])
378+ reasoning_parts .append (response_text [start_idx + len (open_tag ):])
321379 response_text = response_text [:start_idx ]
322380 break
323381
324382 # Extract reasoning content
325- reasoning_content = response_text [start_idx + len (opening_tag ):end_idx ]
383+ reasoning_content = response_text [start_idx + len (open_tag ):end_idx ]
326384 reasoning_parts .append (reasoning_content )
327385
328386 # Remove from response
329- response_text = response_text [:start_idx ] + response_text [end_idx + len (closing_tag ):]
387+ response_text = response_text [:start_idx ] + response_text [end_idx + len (close_tag ):]
330388
331389 return response_text .strip (), "\n ---\n " .join (reasoning_parts ).strip ()
332390
@@ -347,8 +405,11 @@ def generate(
347405 draft_model_selection : str = CUSTOM_MODEL_OPTION ,
348406 custom_draft_model : str = "" ,
349407 top_p : float = 1.0 ,
408+ top_k : int = 0 ,
350409 repeat_penalty : float = 1.0 ,
351- reasoning_tag : str = "<think>" ,
410+ reasoning_mode : str = "Auto-detect (recommended)" ,
411+ custom_open_tag : str = "<think>" ,
412+ custom_close_tag : str = "</think>" ,
352413 unload_llm : bool = True ,
353414 unload_comfy_models : bool = False ,
354415 refresh_models : bool = False
@@ -475,28 +536,48 @@ def generate(
475536 }
476537
477538 # Add optional parameters if not at default
478- # Note: Parameter names per LM Studio SDK docs (topPSampling, not topP)
539+ # Note: Parameter names per LM Studio SDK docs
479540 if top_p < 1.0 :
480541 gen_config ["topPSampling" ] = top_p
542+ if top_k > 0 :
543+ gen_config ["topKSampling" ] = top_k
481544 if repeat_penalty != 1.0 :
482545 gen_config ["repeatPenalty" ] = repeat_penalty
483546 # Note: seed is not a valid inference-time parameter in LM Studio SDK
484547 if draft_model :
485548 gen_config ["draftModel" ] = draft_model
486549
487550 troubleshooting_lines .append (f"[INFO] Config: maxTokens={ max_tokens } , temp={ temperature } " )
551+ if top_k > 0 :
552+ troubleshooting_lines .append (f"[INFO] Sampling: top_k={ top_k } , top_p={ top_p } " )
488553 troubleshooting_lines .append ("[INFO] Generating..." )
489554
490555 # Generate response
491556 response = model .respond (chat , config = gen_config )
492557 response_text = str (response )
493558
494559 troubleshooting_lines .append ("[INFO] Generation complete" )
560+ troubleshooting_lines .append (f"[INFO] Raw response length: { len (response_text )} chars" )
561+
562+ # Extract reasoning based on mode
563+ final_response = response_text
564+ reasoning = ""
565+
566+ if reasoning_mode == "Auto-detect (recommended)" :
567+ final_response , reasoning , detected_pattern = self ._extract_reasoning_auto (response_text )
568+ if detected_pattern :
569+ troubleshooting_lines .append (f"[INFO] Auto-detected reasoning format: { detected_pattern } " )
570+ elif response_text != final_response :
571+ troubleshooting_lines .append ("[INFO] Reasoning extracted" )
572+ elif reasoning_mode == "Custom tags" :
573+ final_response , reasoning = self ._extract_reasoning_custom (
574+ response_text , custom_open_tag , custom_close_tag
575+ )
576+ # else: "Disabled" - no extraction
495577
496- # Extract reasoning if tag specified
497- final_response , reasoning = self ._extract_reasoning (response_text , reasoning_tag )
498578 if reasoning :
499- troubleshooting_lines .append (f"[INFO] Extracted reasoning ({ len (reasoning )} chars)" )
579+ troubleshooting_lines .append (f"[INFO] Extracted reasoning: { len (reasoning )} chars" )
580+ troubleshooting_lines .append (f"[INFO] Clean response: { len (final_response )} chars" )
500581
501582 # Unload LLM if requested
502583 if unload_llm :
0 commit comments