Skip to content

Commit ea75785

Browse files
Merge pull request #17 from EnragedAntelope/claude/repo-audit-2z2zv2
Audit fixes: robustness, packaging cleanup, and test suite
2 parents 24dfe17 + 991c007 commit ea75785

15 files changed

Lines changed: 754 additions & 316 deletions

.github/workflows/publish_action.yml

Lines changed: 0 additions & 33 deletions
This file was deleted.

LMStudio.py

Lines changed: 13 additions & 280 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@
33
Provides text generation using local LLM/VLM models via LM Studio server.
44
"""
55
import logging
6-
import re
76
from typing import Optional, Tuple, List
87
import os
98
import time
109
from tempfile import NamedTemporaryFile
11-
import numpy as np
1210
from PIL import Image
1311

1412
# LM Studio SDK
@@ -29,6 +27,12 @@
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
3438
logger = 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

203140
class 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

lms_config/models.txt

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)