33Provides text generation using local LLM/VLM models via LM Studio server.
44"""
55import logging
6- import re
76from typing import Optional , Tuple , List
87import os
98import time
109from tempfile import NamedTemporaryFile
11- import numpy as np
1210from PIL import Image
1311
1412# LM Studio SDK
2927 get_cached_model_count ,
3028 CUSTOM_MODEL_OPTION ,
3129)
30+ from .lms_reasoning import (
31+ extract_reasoning_auto ,
32+ extract_reasoning_custom ,
33+ looks_like_leaked_thinking ,
34+ )
35+ from .lms_image import convert_image_to_pil
3236
3337# Setup logging
3438logger = logging .getLogger ("EA_LMStudio" )
@@ -128,76 +132,9 @@ def _quiet_dependency_logs() -> None:
128132 "Disabled" ,
129133]
130134
131- # Common reasoning tag patterns used by different models
132- # Order matters - most common first for efficiency
133- # Precompiled once at import; these run on every generation.
134- COMMON_REASONING_PATTERNS = [
135- # DeepSeek R1, Qwen3, QwQ, GLM-4/Z1 - most common
136- (re .compile (r"<think>(.*?)</think>" , re .DOTALL ), "<think>" , "</think>" ),
137- # Alternative spelling
138- (re .compile (r"<thinking>(.*?)</thinking>" , re .DOTALL ), "<thinking>" , "</thinking>" ),
139- # Some models use this
140- (re .compile (r"<reasoning>(.*?)</reasoning>" , re .DOTALL ), "<reasoning>" , "</reasoning>" ),
141- # Occasionally seen
142- (re .compile (r"<reason>(.*?)</reason>" , re .DOTALL ), "<reason>" , "</reason>" ),
143- ]
144-
145- # GPT-OSS models use OpenAI's "harmony" channel format:
146- # <|start|>assistant<|channel|>analysis<|message|>...<|end|>
147- # <|start|>assistant<|channel|>final<|message|>...<|return|>
148- # LM Studio usually parses this itself, but partial leaks are common: a final
149- # block with its markers still attached, an analysis block missing its <|end|>
150- # terminator, a trailing <|return|>, or (when special tokens get stripped
151- # during detokenization) a bare "analysis...assistantfinal..." string.
152- #
153- # Analysis/commentary segments end at <|end|> or at the start of the next
154- # message/channel header, or at end of text if truncated. The commentary
155- # channel carries preambles/tool chatter, so it is routed to reasoning too.
156- GPT_OSS_ANALYSIS_RE = re .compile (
157- r"<\|channel\|>(?:analysis|commentary)<\|message\|>(.*?)"
158- r"(?=<\|end\|>|<\|start\|>|<\|channel\|>|\Z)" ,
159- re .DOTALL ,
160- )
161- GPT_OSS_FINAL_RE = re .compile (r"<\|channel\|>final<\|message\|>(.*)\Z" , re .DOTALL )
162- # Sweep for any harmony markers left over after extraction. <|...|> tokens
163- # never appear in legitimate prose, so removing them is safe.
164- HARMONY_MARKER_RE = re .compile (
165- r"<\|start\|>(?:assistant|user|system|tool)?"
166- r"|<\|channel\|>(?:analysis|commentary|final)?"
167- r"|<\|message\|>|<\|end\|>|<\|return\|>|<\|constrain\|>"
168- )
169- # Detokenized-without-special-tokens variant: "analysisUser wants...assistantfinalAnswer".
170- # "assistantfinal" is the discriminator; it never occurs in normal prose.
171- GPT_OSS_STRIPPED_RE = re .compile (r"\s*analysis(.*?)assistantfinal(.*)\Z" , re .DOTALL )
172-
173- # Quick membership test for whether harmony markers are present at all
174- _HARMONY_TOKENS = ("<|channel|>" , "<|message|>" , "<|start|>" , "<|end|>" , "<|return|>" )
175-
176- # Plain-text headings that some models (often community finetunes/merges) emit when
177- # they "think" without wrapping it in tags. We only use these to DETECT likely leaked
178- # thinking for a better troubleshooting hint -- we never strip them from the response.
179- LEAKED_THINKING_MARKERS = (
180- "thinking process" ,
181- "thought process" ,
182- "reasoning process" ,
183- "let me think" ,
184- "let's think" ,
185- "step 1:" ,
186- "step 1." ,
187- )
188-
189-
190- def _looks_like_leaked_thinking (text : str ) -> bool :
191- """Heuristically detect tagless reasoning that leaked into the response.
192-
193- Checks only the start of the response (first ~200 chars) so that a marker
194- appearing mid-answer in a legitimate reply doesn't trigger a false positive.
195- Detection only -- callers must not strip anything based on this.
196- """
197- if not text :
198- return False
199- head = text [:200 ].lower ()
200- return any (marker in head for marker in LEAKED_THINKING_MARKERS )
135+ # Reasoning/thinking extraction (regexes + helpers) lives in lms_reasoning.py
136+ # so the pure text-processing logic stays importable and unit-testable without
137+ # pulling in the lmstudio SDK, ComfyUI, or the startup network fetch.
201138
202139
203140class EALMStudio :
@@ -399,210 +336,6 @@ def _resolve_model_identifier(
399336
400337 return model_id , None
401338
402- def _resize_image (self , pil_image : Image .Image , max_dimension : Optional [int ]) -> Image .Image :
403- """
404- Resize image to fit within max_dimension while preserving aspect ratio.
405-
406- Args:
407- pil_image: PIL Image to resize
408- max_dimension: Maximum size for longest edge, or None to skip resize
409-
410- Returns:
411- Resized PIL Image (or original if no resize needed)
412- """
413- if max_dimension is None :
414- return pil_image
415-
416- width , height = pil_image .size
417- max_current = max (width , height )
418-
419- # Only resize if image is larger than target
420- if max_current <= max_dimension :
421- return pil_image
422-
423- # Calculate new dimensions preserving aspect ratio
424- scale = max_dimension / max_current
425- new_width = int (width * scale )
426- new_height = int (height * scale )
427-
428- # Use LANCZOS for high-quality downscaling
429- return pil_image .resize ((new_width , new_height ), Image .LANCZOS )
430-
431- def _convert_image_to_pil (self , image_tensor , resize_option : str = "No Resize" ) -> Optional [Image .Image ]:
432- """
433- Convert ComfyUI image tensor to PIL Image, optionally resizing.
434-
435- Args:
436- image_tensor: ComfyUI image tensor
437- resize_option: Resize option from IMAGE_RESIZE_OPTIONS
438-
439- Returns:
440- PIL Image or None if conversion fails
441- """
442- try :
443- # ComfyUI images are [B, H, W, C] float tensors in 0-1 range
444- if image_tensor is None :
445- return None
446-
447- # Take first image if batch
448- if len (image_tensor .shape ) == 4 :
449- img_array = image_tensor [0 ].cpu ().numpy ()
450- else :
451- img_array = image_tensor .cpu ().numpy ()
452-
453- # Convert to uint8. Clip first: ComfyUI tensors can slightly
454- # exceed [0, 1] (VAE decode etc.), and out-of-range values would
455- # otherwise wrap around during the uint8 cast (1.02 -> 4).
456- img_array = np .clip (img_array * 255.0 , 0 , 255 ).astype (np .uint8 )
457-
458- # Create PIL Image
459- pil_image = Image .fromarray (img_array )
460-
461- # Apply resize if specified
462- max_dim = RESIZE_DIMENSIONS .get (resize_option )
463- if max_dim is not None :
464- pil_image = self ._resize_image (pil_image , max_dim )
465-
466- return pil_image
467-
468- except Exception as e :
469- logger .error (f"Failed to convert image: { e } " )
470- return None
471-
472- def _extract_reasoning_gpt_oss (self , text : str ) -> Optional [Tuple [str , str , str ]]:
473- """
474- Extract reasoning from GPT-OSS "harmony" channel output, including
475- partial leaks where only some markers survived LM Studio's own parsing.
476-
477- Args:
478- text: Full response text
479-
480- Returns:
481- Tuple of (response, reasoning, detected_pattern), or None if the
482- text contains no harmony markers at all.
483- """
484- if any (token in text for token in _HARMONY_TOKENS ):
485- reasoning_parts = [m .group (1 ).strip () for m in GPT_OSS_ANALYSIS_RE .finditer (text )]
486- reasoning_parts = [p for p in reasoning_parts if p ]
487- final_match = GPT_OSS_FINAL_RE .search (text )
488-
489- if final_match :
490- response = final_match .group (1 )
491- else :
492- response = GPT_OSS_ANALYSIS_RE .sub ("" , text )
493-
494- # Only stray terminators present (e.g. a bare <|end|> with no
495- # channel headers): treat text before the terminator as leaked
496- # reasoning, mirroring the missing-open-tag fallback below.
497- if not reasoning_parts and not final_match and "<|channel|>" not in text :
498- before , sep , after = text .partition ("<|end|>" )
499- if sep and before .strip () and after .strip ():
500- return (
501- HARMONY_MARKER_RE .sub ("" , after ).strip (),
502- before .strip (),
503- "<|end|> (stray terminator)" ,
504- )
505-
506- # Sweep any remaining markers (<|return|>, <|start|>assistant, ...)
507- response = HARMONY_MARKER_RE .sub ("" , response ).strip ()
508- return response , "\n ---\n " .join (reasoning_parts ), "<|channel|> (GPT-OSS harmony)"
509-
510- # Detokenized variant with special tokens stripped:
511- # "analysisUser wants a cat pic.assistantfinalHere is a cat."
512- stripped_match = GPT_OSS_STRIPPED_RE .match (text )
513- if stripped_match :
514- return (
515- stripped_match .group (2 ).strip (),
516- stripped_match .group (1 ).strip (),
517- "analysis...assistantfinal (stripped special tokens)" ,
518- )
519-
520- return None
521-
522- def _extract_reasoning_auto (self , text : str ) -> Tuple [str , str , Optional [str ]]:
523- """
524- Auto-detect and extract reasoning using common patterns.
525-
526- Args:
527- text: Full response text
528-
529- Returns:
530- Tuple of (response_without_reasoning, reasoning_content, detected_pattern)
531- detected_pattern is None if no pattern matched
532- """
533- # Check for GPT-OSS harmony/channel-based format first
534- gpt_oss_result = self ._extract_reasoning_gpt_oss (text )
535- if gpt_oss_result is not None :
536- return gpt_oss_result
537-
538- # Check standard tag-based patterns
539- for pattern , open_tag , close_tag in COMMON_REASONING_PATTERNS :
540- matches = list (pattern .finditer (text ))
541- if matches :
542- reasoning_parts = [m .group (1 ) for m in matches ]
543- # Remove all matched reasoning blocks from text
544- clean_text = pattern .sub ("" , text )
545- return clean_text .strip (), "\n ---\n " .join (reasoning_parts ).strip (), open_tag
546-
547- # Fallback: Check for closing tag without opening tag (model bug/edge case)
548- # Some models forget the opening <think> but include closing </think>
549- for _ , open_tag , close_tag in COMMON_REASONING_PATTERNS :
550- if close_tag in text and open_tag not in text :
551- # Split on closing tag - everything before is reasoning
552- parts = text .split (close_tag , 1 )
553- if len (parts ) == 2 :
554- reasoning = parts [0 ].strip ()
555- response = parts [1 ].strip ()
556- if reasoning and response :
557- return response , reasoning , f"{ close_tag } (missing open tag)"
558- if reasoning and not response :
559- # Model spent its whole budget thinking and never
560- # produced an answer (usually maxTokens truncation).
561- # Surface it as reasoning rather than passing the raw
562- # tagged text through as the response.
563- return "" , reasoning , f"{ close_tag } (missing open tag, response truncated)"
564-
565- # No pattern matched
566- return text , "" , None
567-
568- def _extract_reasoning_custom (self , text : str , open_tag : str , close_tag : str ) -> Tuple [str , str ]:
569- """
570- Extract reasoning using custom tags.
571-
572- Args:
573- text: Full response text
574- open_tag: Opening tag
575- close_tag: Closing tag
576-
577- Returns:
578- Tuple of (response_without_reasoning, reasoning_content)
579- """
580- if not open_tag or open_tag not in text :
581- return text , ""
582-
583- reasoning_parts = []
584- response_text = text
585-
586- # Extract all reasoning blocks
587- while open_tag in response_text :
588- start_idx = response_text .find (open_tag )
589- end_idx = response_text .find (close_tag , start_idx + len (open_tag ))
590-
591- if end_idx == - 1 :
592- # No closing tag - take rest as reasoning
593- reasoning_parts .append (response_text [start_idx + len (open_tag ):])
594- response_text = response_text [:start_idx ]
595- break
596-
597- # Extract reasoning content
598- reasoning_content = response_text [start_idx + len (open_tag ):end_idx ]
599- reasoning_parts .append (reasoning_content )
600-
601- # Remove from response
602- response_text = response_text [:start_idx ] + response_text [end_idx + len (close_tag ):]
603-
604- return response_text .strip (), "\n ---\n " .join (reasoning_parts ).strip ()
605-
606339 def generate (
607340 self ,
608341 system_message : str ,
@@ -703,7 +436,7 @@ def generate(
703436 troubleshooting_lines .append (
704437 f"[WARNING] Image { idx } : batch of { img_tensor .shape [0 ]} received; only the first image is used"
705438 )
706- pil_img = self . _convert_image_to_pil (img_tensor , image_resize )
439+ pil_img = convert_image_to_pil (img_tensor , RESIZE_DIMENSIONS . get ( image_resize ) )
707440 if pil_img :
708441 pil_images .append (pil_img )
709442 if image_resize != "No Resize" :
@@ -823,10 +556,10 @@ def generate(
823556 reasoning = ""
824557
825558 if reasoning_mode == "Auto-detect (recommended)" :
826- final_response , reasoning , detected_pattern = self . _extract_reasoning_auto (response_text )
559+ final_response , reasoning , detected_pattern = extract_reasoning_auto (response_text )
827560 if detected_pattern :
828561 troubleshooting_lines .append (f"[INFO] Auto-detected reasoning format: { detected_pattern } " )
829- elif _looks_like_leaked_thinking (response_text ):
562+ elif looks_like_leaked_thinking (response_text ):
830563 # The model thought, but in a tagless plain-text format that
831564 # neither LM Studio's parser nor our tag-based extractor caught,
832565 # so the reasoning leaked into the response output.
@@ -838,7 +571,7 @@ def generate(
838571 else :
839572 troubleshooting_lines .append ("[INFO] No reasoning tags detected (model may not have used thinking for this query)" )
840573 elif reasoning_mode == "Custom tags" :
841- final_response , reasoning = self . _extract_reasoning_custom (
574+ final_response , reasoning = extract_reasoning_custom (
842575 response_text , custom_open_tag , custom_close_tag
843576 )
844577 # else: "Disabled" - no extraction
0 commit comments