Refactor thinking process, retry fallback, add rest api mode

This commit is contained in:
Jordan Thompson
2026-03-17 23:47:00 -07:00
parent 62880fe827
commit 8a2e56e4b7
4 changed files with 240 additions and 63 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
{
"temperature": 0.15,
"max_tokens": 768,
"max_tokens": -1,
"seed": 0,
"unload_after_use": true,
"default_model": "qwen/qwen2.5-vl-3b",
"image_max_sizes": [256, 512, 1024, 2048],
"default_image_max_size": 1024,
"allowed_root_directories": ["/mnt/k", "/mnt/m"]
"allowed_root_directories": ["/mnt/k", "/mnt/m", "/mnt/e", "/home/was/projects"]
}
+234 -58
View File
@@ -3,6 +3,8 @@ import json
import io
import uuid
import shutil
import base64
import requests as _requests
import numpy as np
@@ -85,6 +87,17 @@ def image_tensor_to_temp_png_path(image_tensor, max_edge: int = 1024) -> str:
return p
def resize_disk_image_to_temp(path: str, max_edge: int = 1024) -> str:
"""Resize an on-disk image to max_edge (keeping aspect ratio) and return a temp PNG path."""
img = Image.open(path)
if max(img.size) > max_edge:
img.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)
d = folder_paths.get_temp_directory()
out = os.path.join(d, f"{uuid.uuid4().hex}.png")
img.save(out, format="PNG")
return out
def listify(x):
"""Convert input to list if it's not already."""
if x is None:
@@ -105,6 +118,11 @@ def strip_thinking_tags(text: str) -> str:
# Remove thinking tags and everything between them (including newlines)
result = re.sub(r'<thinking>.*?</thinking>', '', text, flags=re.DOTALL)
result = re.sub(r'<think>.*?</think>', '', result, flags=re.DOTALL)
# Handle missing opening tag: strip everything before a lone </think> or </thinking>
result = re.sub(r'^.*?</think>', '', result, flags=re.DOTALL)
result = re.sub(r'^.*?</thinking>', '', result, flags=re.DOTALL)
# Handle SDK "Thinking Process:" prefix when context was exhausted before </think>
result = re.sub(r'^Thinking Process:.*', '', result, flags=re.DOTALL)
# Clean up any extra whitespace left behind
result = re.sub(r'\n\s*\n\s*\n', '\n\n', result) # Replace 3+ newlines with 2
return result.strip()
@@ -115,8 +133,13 @@ def map_lms_params(params: Dict[str, Any]) -> Dict[str, Any]:
for k, v in params.items():
if v is None:
continue
if k == "strip_thinking_tags":
continue
if k == "max_tokens":
out["maxTokens"] = v
if v != -1:
out["maxTokens"] = v
else:
print("[LMStudio] max_tokens=-1 → unlimited generation (maxTokens omitted from SDK config)")
elif k == "top_p":
out["topP"] = v
elif k == "top_k":
@@ -132,6 +155,157 @@ def map_lms_params(params: Dict[str, Any]) -> Dict[str, Any]:
return out
def encode_image_base64(image_path: str) -> str:
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def query_lmstudio_rest(
model_id: str,
messages: List[Dict[str, Any]],
params: Optional[Dict[str, Any]] = None,
base_url: str = "http://127.0.0.1:1234",
) -> Tuple[str, Optional[str], str]:
payload: Dict[str, Any] = {"model": model_id, "messages": messages}
if params:
for k, v in params.items():
if k in ("strip_thinking_tags",):
continue
if v is None:
continue
if k == "max_tokens":
if v != -1:
payload["max_tokens"] = v
elif k == "seed":
if v != 0:
payload["seed"] = v
elif k == "stop":
payload["stop"] = v
elif k == "repeat_penalty":
payload["repeat_penalty"] = v
else:
payload[k] = v
resp = _requests.post(f"{base_url}/v1/chat/completions", json=payload, timeout=600)
resp.raise_for_status()
data = resp.json()
choice = data["choices"][0]
msg = choice["message"]
content = msg.get("content", "") or ""
reasoning = msg.get("reasoning_content", None)
finish_reason = choice.get("finish_reason", "unknown")
return (content, reasoning, finish_reason)
def build_openai_messages(
system_prompt: str,
user_prompt: str,
image_paths: Optional[List[str]] = None,
history: Optional[List[Dict[str, str]]] = None,
) -> List[Dict[str, Any]]:
messages: List[Dict[str, Any]] = []
if history:
messages.extend(history)
elif system_prompt:
messages.append({"role": "system", "content": system_prompt})
if image_paths:
content_parts: List[Dict[str, Any]] = []
if user_prompt:
content_parts.append({"type": "text", "text": user_prompt})
for path in image_paths:
b64 = encode_image_base64(path)
content_parts.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
messages.append({"role": "user", "content": content_parts})
else:
messages.append({"role": "user", "content": user_prompt or ""})
return messages
def predict_text(
model_dict: Dict[str, Any],
system_prompt: str,
user_prompt: str,
image_paths: Optional[List[str]] = None,
params: Optional[Dict[str, Any]] = None,
model_handle: Any = None,
history: Optional[List[Dict[str, str]]] = None,
) -> str:
model_id = model_dict.get("model_id", "")
if model_dict.get("use_rest_api", False):
cfg = read_config()
base_url = cfg.get("base_url", "http://127.0.0.1:1234")
messages = build_openai_messages(system_prompt, user_prompt, image_paths, history)
content, reasoning, finish_reason = query_lmstudio_rest(model_id, messages, params, base_url)
print(f"[LMStudio REST] finish={finish_reason} content_len={len(content)}, reasoning_len={len(reasoning) if reasoning else 0}")
result = content.strip()
if not result:
# Model produced reasoning but no content — retry with /no_think to
# force a direct answer without the thinking phase.
nothink_prompt = ((user_prompt or "") + " /no_think").strip()
retry_messages = build_openai_messages(system_prompt, nothink_prompt, image_paths, history)
content2, _, finish2 = query_lmstudio_rest(model_id, retry_messages, params, base_url)
print(f"[LMStudio REST] retry /no_think: finish={finish2} content_len={len(content2)}")
result = content2.strip()
print(f"[LMStudio REST] result_len={len(result)}")
return result
else:
import lmstudio as lms
import tempfile
handle = model_handle or (lms.llm(model_id) if model_id else lms.llm())
strip = (params or {}).get("strip_thinking_tags", False)
prompt = user_prompt or ""
sdk_config = map_lms_params(params or {})
# When dealing with a thinking model (strip_thinking_tags=True) and
# max_tokens is unlimited (-1), cap generation to prevent the model
# from spending the entire context window on thinking.
if strip and "maxTokens" not in sdk_config:
sdk_config["maxTokens"] = 4096
print("[LMStudio SDK] Thinking model with unlimited max_tokens — capping generation at 4096 tokens")
# Retry loop: on context overflow, downscale images and retry
current_paths = list(image_paths) if image_paths else []
max_retries = 3
for attempt in range(max_retries + 1):
if history:
chat = lms.Chat.from_history({"messages": history})
else:
chat = lms.Chat(system_prompt) if system_prompt else lms.Chat()
if current_paths:
img_handles = [lms.prepare_image(p) for p in current_paths]
chat.add_user_message(prompt, images=img_handles)
else:
chat.add_user_message(prompt)
try:
pred = handle.respond(chat, config=sdk_config)
raw = str(pred)
has_think = "</think>" in raw or "</thinking>" in raw
print(f"[LMStudio SDK] raw_len={len(raw)} has_think_tags={has_think} strip_requested={strip}")
return raw
except Exception as e:
err_msg = str(e)
if "Context size" in err_msg and current_paths and attempt < max_retries:
# Downscale images by 75% and retry
new_paths = []
for p in current_paths:
img = Image.open(p)
w, h = img.size
nw, nh = int(w * 0.75), int(h * 0.75)
if nw < 64 or nh < 64:
print(f"[LMStudio SDK] Image too small to downscale further ({w}x{h})")
raise
img = img.resize((nw, nh), Image.LANCZOS)
tmp = os.path.join(tempfile.gettempdir(), f"lms_ctx_{attempt}_{os.path.basename(p)}")
img.save(tmp, "PNG")
new_paths.append(tmp)
current_paths = new_paths
print(f"[LMStudio SDK] Context overflow — downscaled images to ~{int(100 * 0.75**(attempt+1))}% and retrying (attempt {attempt+2}/{max_retries+1})")
else:
raise
def check_chat_fits_in_context(model_handle: Any, chat: Any) -> Tuple[bool, int, int]:
"""
Check if a chat conversation fits within the model's context window.
@@ -386,9 +560,9 @@ class WASLMStudioModel:
"INT",
{
"default": max_tokens_default,
"min": 1,
"min": -1,
"max": 32768,
"tooltip": "Maximum new tokens to generate for the assistant reply.",
"tooltip": "Maximum new tokens to generate for the assistant reply. Use -1 for unlimited (recommended for thinking models).",
},
),
"seed": (
@@ -407,6 +581,13 @@ class WASLMStudioModel:
"tooltip": "Maximum edge size for input images (keeps aspect ratio). Images larger than this are downscaled before encoding and sending to LM Studio.",
},
),
"use_rest_api": (
"BOOLEAN",
{
"default": False,
"tooltip": "Use the OpenAI-compatible REST API instead of the LM Studio SDK for predictions. Required for proper thinking model support (separates reasoning from content).",
},
),
},
"optional": {
},
@@ -426,6 +607,7 @@ class WASLMStudioModel:
max_tokens: int,
seed: int,
image_max_size: str,
use_rest_api: bool = False,
):
import lmstudio as lms
@@ -450,6 +632,7 @@ class WASLMStudioModel:
"max_tokens": int(max_tokens),
"seed": int(seed) if seed != 0 else None,
"image_max_size": int(selected_max),
"use_rest_api": bool(use_rest_api),
}
return (result,)
@@ -543,9 +726,6 @@ class WASLMStudioQuery:
for idx, img in enumerate(imgs):
path = image_tensor_to_temp_png_path(img, max_edge=max_edge)
tmp_img_paths.append(path)
img_handle = lms.prepare_image(path)
chat = lms.Chat(system_prompt) if system_prompt else lms.Chat()
chat.add_user_message(user_prompt or "", images=[img_handle])
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -558,18 +738,16 @@ class WASLMStudioQuery:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
out.append(str(pred))
text = predict_text(model, system_prompt, user_prompt or "", image_paths=[path], params=params, model_handle=model_handle)
out.append(text)
pbar.update_absolute(idx + 1)
responses_out = out
else:
img_handles = []
all_paths = []
for i in imgs:
path = image_tensor_to_temp_png_path(i, max_edge=max_edge)
tmp_img_paths.append(path)
img_handles.append(lms.prepare_image(path))
chat = lms.Chat(system_prompt) if system_prompt else lms.Chat()
chat.add_user_message(user_prompt or "", images=img_handles)
all_paths.append(path)
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -582,11 +760,9 @@ class WASLMStudioQuery:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
responses_out = [str(pred)]
text = predict_text(model, system_prompt, user_prompt or "", image_paths=all_paths, params=params, model_handle=model_handle)
responses_out = [text]
else:
chat = lms.Chat(system_prompt) if system_prompt else lms.Chat()
chat.add_user_message(user_prompt or "")
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -599,8 +775,8 @@ class WASLMStudioQuery:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
responses_out = [str(pred)]
text = predict_text(model, system_prompt, user_prompt or "", params=params, model_handle=model_handle)
responses_out = [text]
except Exception as e:
print(f"Error in run_query: {e}")
@@ -642,7 +818,7 @@ class WASLMStudioOptions:
),
"max_tokens": (
"INT",
{"default": 512, "min": 1, "max": 32768, "tooltip": "Maximum number of new tokens to generate for the response."},
{"default": 512, "min": -1, "max": 32768, "tooltip": "Maximum number of new tokens to generate for the response. Use -1 for unlimited (recommended for thinking models)."},
),
"seed": (
"INT",
@@ -899,9 +1075,6 @@ class WASLMStudioChat:
for idx, img in enumerate(imgs):
path = image_tensor_to_temp_png_path(img, max_edge=max_edge)
tmp_img_paths.append(path)
img_handle = lms.prepare_image(path)
chat = lms.Chat.from_history({"messages": msgs}) if msgs else (lms.Chat(system_prompt) if system_prompt else lms.Chat())
chat.add_user_message(user_prompt or "", images=[img_handle])
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -914,20 +1087,18 @@ class WASLMStudioChat:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
out.append(str(pred))
resp_text = predict_text(model, system_prompt, user_prompt or "", image_paths=[path], params=params, model_handle=model_handle, history=msgs if msgs else None)
out.append(resp_text)
msgs.append({"role": "user", "content": user_prompt or ""})
msgs.append({"role": "assistant", "content": str(pred)})
msgs.append({"role": "assistant", "content": resp_text})
pbar.update_absolute(idx + 1)
responses_out = out
else:
img_handles = []
all_paths = []
for i in imgs:
path = image_tensor_to_temp_png_path(i, max_edge=max_edge)
tmp_img_paths.append(path)
img_handles.append(lms.prepare_image(path))
chat = lms.Chat.from_history({"messages": msgs}) if msgs else (lms.Chat(system_prompt) if system_prompt else lms.Chat())
chat.add_user_message(user_prompt or "", images=img_handles)
all_paths.append(path)
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -940,13 +1111,11 @@ class WASLMStudioChat:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
responses_out = [str(pred)]
resp_text = predict_text(model, system_prompt, user_prompt or "", image_paths=all_paths, params=params, model_handle=model_handle, history=msgs if msgs else None)
responses_out = [resp_text]
msgs.append({"role": "user", "content": user_prompt or ""})
msgs.append({"role": "assistant", "content": str(pred)})
msgs.append({"role": "assistant", "content": resp_text})
else:
chat = lms.Chat.from_history({"messages": msgs}) if msgs else (lms.Chat(system_prompt) if system_prompt else lms.Chat())
chat.add_user_message(user_prompt or "")
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -959,10 +1128,10 @@ class WASLMStudioChat:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
responses_out = [str(pred)]
resp_text = predict_text(model, system_prompt, user_prompt or "", params=params, model_handle=model_handle, history=msgs if msgs else None)
responses_out = [resp_text]
msgs.append({"role": "user", "content": user_prompt or ""})
msgs.append({"role": "assistant", "content": str(pred)})
msgs.append({"role": "assistant", "content": resp_text})
except Exception as e:
print(f"Error in chat: {e}")
responses_out = [f"Error: {str(e)}"]
@@ -1101,9 +1270,6 @@ class WASLMStudioCaption:
for idx, img in enumerate(imgs):
path = image_tensor_to_temp_png_path(img, max_edge=max_edge)
tmp_img_paths.append(path)
img_handle = lms.prepare_image(path)
chat = lms.Chat(system_text) if system_text else lms.Chat()
chat.add_user_message(user_prompt or "", images=[img_handle])
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -1116,19 +1282,17 @@ class WASLMStudioCaption:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
out.append(str(pred))
text = predict_text(model, system_text, user_prompt or "", image_paths=[path], params=params, model_handle=model_handle)
out.append(text)
pbar.update_absolute(idx + 1)
result = string_list(out)
else:
img_handles = []
tmp_img_paths: List[str] = []
all_paths: List[str] = []
for i in imgs:
path = image_tensor_to_temp_png_path(i, max_edge=max_edge)
tmp_img_paths.append(path)
img_handles.append(lms.prepare_image(path))
chat = lms.Chat(system_text) if system_text else lms.Chat()
chat.add_user_message(user_prompt or "", images=img_handles)
all_paths.append(path)
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -1141,8 +1305,8 @@ class WASLMStudioCaption:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
result = string_list([str(pred)])
text = predict_text(model, system_text, user_prompt or "", image_paths=all_paths, params=params, model_handle=model_handle)
result = string_list([text])
except Exception as e:
print(f"Error in generate_captions: {e}")
@@ -1349,6 +1513,7 @@ class WASLMStudioCaptionDataset:
temperature = float(model.get("temperature", 0.2))
max_tokens = int(model.get("max_tokens", 512))
seed = model.get("seed", None)
max_edge = int(model.get("image_max_size", 1024))
unload = model.get("unload", False)
# Apply options overrides if provided
@@ -1398,9 +1563,7 @@ class WASLMStudioCaptionDataset:
skipped_count += 1
pbar.update_absolute(idx + 1)
continue
img_handle = lms.prepare_image(p)
chat = lms.Chat(system_text) if system_text else lms.Chat()
chat.add_user_message(user_prompt or "", images=[img_handle])
resized_path = resize_disk_image_to_temp(p, max_edge=max_edge)
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -1413,12 +1576,19 @@ class WASLMStudioCaptionDataset:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
text = str(pred).strip()
text = predict_text(model, system_text, user_prompt or "", image_paths=[resized_path], params=params, model_handle=model_handle)
print(f"[LMStudio DEBUG] after predict_text: len={len(text)} preview={text[:80]!r}")
# Apply thinking tag filtering if requested
if options and options.get("strip_thinking_tags", False):
text = strip_thinking_tags(text)
print(f"[LMStudio DEBUG] after strip_thinking: len={len(text)}")
if not text.strip():
print(f"[LMStudio WARNING] Empty result for {os.path.basename(p)} — model thinking may have filled the entire context. Increase context_length in LM Studio.")
failed_count += 1
pbar.update_absolute(idx + 1)
continue
if trigger_word_or_phrase.strip():
trigger = trigger_word_or_phrase.strip()
@@ -1575,6 +1745,7 @@ class WASLMStudioCaptionDatasetCustom:
temperature = float(model.get("temperature", 0.2))
max_tokens = int(model.get("max_tokens", 512))
seed = model.get("seed", None)
max_edge = int(model.get("image_max_size", 1024))
unload = model.get("unload", False)
# Apply options overrides if provided
@@ -1624,9 +1795,7 @@ class WASLMStudioCaptionDatasetCustom:
skipped_count += 1
pbar.update_absolute(idx + 1)
continue
img_handle = lms.prepare_image(p)
chat = lms.Chat(system_text) if system_text else lms.Chat()
chat.add_user_message(user_prompt or "", images=[img_handle])
resized_path = resize_disk_image_to_temp(p, max_edge=max_edge)
params: Dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
@@ -1639,12 +1808,19 @@ class WASLMStudioCaptionDatasetCustom:
params[k] = v
except Exception:
pass
pred = model_handle.respond(chat, config=map_lms_params(params))
text = str(pred).strip()
text = predict_text(model, system_text, user_prompt or "", image_paths=[resized_path], params=params, model_handle=model_handle)
print(f"[LMStudio DEBUG] after predict_text: len={len(text)} preview={text[:80]!r}")
# Apply thinking tag filtering if requested
if options and options.get("strip_thinking_tags", False):
text = strip_thinking_tags(text)
print(f"[LMStudio DEBUG] after strip_thinking: len={len(text)}")
if not text.strip():
print(f"[LMStudio WARNING] Empty result for {os.path.basename(p)} — model thinking may have filled the entire context. Increase context_length in LM Studio.")
failed_count += 1
pbar.update_absolute(idx + 1)
continue
if trigger_word_or_phrase.strip():
trigger = trigger_word_or_phrase.strip()
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "was-lmstudio-easyquery"
version = "0.2.0"
version = "0.1.1"
description = "LM Studio calls with image support and easy task directions."
requires-python = ">=3.10"
license = { file = "LICENSE" }
@@ -22,4 +22,4 @@ PublisherId = "was"
DisplayName = "WAS LMStudio Easy-Query"
# Icon = "https://raw.githubusercontent.com/WASasquatch/WAS_LMStudio_EasyQuery/main/icon.png"
# Banner = "https://raw.githubusercontent.com/WASasquatch/WAS_LMStudio_EasyQuery/main/banner.png"
requires-comfyui = ">=1.0.0"
requires-comfyui = ">=1.0.0"
+2 -1
View File
@@ -1,3 +1,4 @@
lmstudio>=1.5.0
pillow>=10.0.0
numpy>=1.23.0
numpy>=1.23.0
requests>=2.28.0