Files
Eric W. Burns 7a80437eb2 LMStudio doesn't care about other projects that interface with them so
they make backwards incompatible breaking changes all the time for the rest of us to clean up
for them. Here I am doing it again. Thanks guys for the reminder that I
should switch this project to ollama.
2026-02-22 00:25:46 -05:00

659 lines
28 KiB
Python

import subprocess
import sys
import re
import os
import comfy.model_management as model_management
import gc
import torch
import requests
import time
import random
import json
from urllib.parse import urlparse
class EbuLMStudioLoadModel:
"""
Updated for LM Studio 0.4.x:
- `lms ps` output format changed; use `lms ps --json`
- OpenAI-compatible endpoints expect a required `model` field; load with `--identifier`
"""
ERROR_NO_MODEL_FOUND = "no model by that name found"
ERROR_CANT_CONNECT = "can't connect to lmstudio"
_currently_loaded_model_key = None
_currently_loaded_model_path = None
_currently_loaded_identifier = None
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_string": ("STRING",),
"model_search_string": ("STRING", {"default": "llama"}),
"context_length": ("INT", {"default": 4096, "min": 1000, "max": 200000}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"unload_image_models_first": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING",)
RETURN_NAMES = ("export_string", "model_selected", "models_found",)
FUNCTION = "load_model"
CATEGORY = "LMStudio"
def run_command(self, command, timeout=180):
try:
process = subprocess.run(
[str(c) for c in command],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
shell=False
)
if process.returncode != 0:
print(f"[lms-node] Command failed: {command}", file=sys.stderr)
if process.stdout:
print(f"[lms-node] STDOUT:\n{process.stdout}", file=sys.stderr)
if process.stderr:
print(f"[lms-node] STDERR:\n{process.stderr}", file=sys.stderr)
return None
return process
except FileNotFoundError:
print("Error: 'lms' command not found. Make sure LM Studio is installed and in PATH.", file=sys.stderr)
return None
except Exception as e:
print(f"[lms-node] Unexpected error running {command}: {e}", file=sys.stderr)
return None
def _list_models_json(self):
"""
Return list[dict] from `lms ls --llm --json` (preferred) or `lms ls --json` as fallback.
"""
for cmd in (['lms', 'ls', '--llm', '--json'], ['lms', 'ls', '--json'], ['lms', 'models', 'ls', '--json']):
p = self.run_command(cmd)
if not (p and p.stdout and p.stdout.strip().startswith(('[', '{'))):
continue
try:
data = json.loads(p.stdout)
except Exception:
continue
items = data
if isinstance(items, dict):
items = items.get('models') or items.get('data') or items.get('items') or []
if isinstance(items, dict):
items = items.get('models') or items.get('data') or []
if not isinstance(items, list):
continue
llms = [m for m in items if isinstance(m, dict) and (m.get("type") in (None, "llm", "LLM") or m.get("isEmbedding") is False)]
return llms or [m for m in items if isinstance(m, dict)]
return []
def _model_info_for_key(self, key):
for m in self._list_models_json():
if not isinstance(m, dict):
continue
if m.get('modelKey') == key or m.get('key') == key or m.get('identifier') == key:
return m
return None
def _path_for_model_key(self, key):
m = self._model_info_for_key(key) or {}
return m.get('path')
def _ps_models(self):
"""
Return a list of dicts describing loaded models, normalized across versions.
Prefer `lms ps --json` (0.4+).
"""
p = self.run_command(['lms', 'ps', '--json'])
if p and p.stdout and p.stdout.strip().startswith(('[', '{')):
try:
data = json.loads(p.stdout)
items = data
if isinstance(items, dict):
items = items.get('models') or items.get('data') or items.get('items') or []
if isinstance(items, dict):
items = items.get('models') or items.get('data') or []
if not isinstance(items, list):
items = []
except Exception:
items = []
out = []
for m in items:
if not isinstance(m, dict):
continue
out.append({
"identifier": m.get("identifier") or m.get("id") or m.get("model") or m.get("modelKey"),
"modelKey": m.get("modelKey") or m.get("model_key") or m.get("key") or m.get("id"),
"path": m.get("path"),
"type": m.get("type"),
"contextLength": m.get("contextLength") or m.get("context_length") or m.get("context"),
"maxContextLength": m.get("maxContextLength") or m.get("max_context_length"),
"sizeBytes": m.get("sizeBytes") or m.get("size_bytes"),
"status": m.get("status") or m.get("generationStatus") or m.get("state"),
"raw": m,
})
return out
# Fallback: parse human output (Identifier/Path/etc.)
p = self.run_command(['lms', 'ps'])
txt = p.stdout if p else ""
if not txt:
return []
models = []
current = {}
for ln in txt.splitlines():
line = ln.strip()
if not line:
continue
m = re.match(r'^Identifier:\s*(.+)$', line, flags=re.IGNORECASE)
if m:
if current:
models.append(current)
current = {"identifier": m.group(1).strip()}
continue
m = re.match(r'^[•\-\*]\s*Type:\s*(.+)$', line, flags=re.IGNORECASE)
if m:
current["type"] = m.group(1).strip()
continue
m = re.match(r'^[•\-\*]\s*Path:\s*(.+)$', line, flags=re.IGNORECASE)
if m:
current["path"] = m.group(1).strip()
continue
if current:
models.append(current)
for m in models:
m.setdefault("modelKey", m.get("identifier"))
return models
def check_models_loaded(self):
return bool(self._ps_models())
def unload_image_models(self):
print("Unload Image Models:")
print(" - Unloading all image models...")
model_management.unload_all_models()
model_management.soft_empty_cache(True)
try:
print(" - Clearing Cache...")
gc.collect()
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
except Exception:
print(" - Unable to clear cache")
def find_matching_model(self, search_term):
"""
Returns (chosen_model_key, raw_listing_json_text).
Spaces act like wildcards (tokens may appear anywhere).
"""
try:
if '|' in (search_term or ''):
options = [s.strip() for s in search_term.split('|') if s.strip()]
if options:
search_term = random.choice(options)
print(f"Multiple search strings provided. Randomly selected: '{search_term}'")
models = self._list_models_json()
listing = json.dumps(models, indent=2)
tokens = [t for t in re.split(r'\s+', (search_term or '').strip().lower()) if t]
if not tokens:
return self.ERROR_NO_MODEL_FOUND, listing
candidates = []
for m in models:
if not isinstance(m, dict):
continue
key = (m.get('modelKey') or m.get('key') or m.get('name') or
m.get('identifier') or m.get('displayName') or "")
disp = m.get('displayName', "") or ""
path = m.get('path', "") or ""
arch = m.get('architecture', "") or ""
params = m.get('paramsString', "") or ""
q = m.get('quantization') or {}
qname = (q.get('name') if isinstance(q, dict) else "") or ""
qbits = (str(q.get('bits')) if isinstance(q, dict) and q.get('bits') is not None else "")
hay = " ".join([str(key), disp, path, arch, params, qname, qbits]).lower()
if all(t in hay for t in tokens):
in_order = bool(re.search(".*".join(map(re.escape, tokens)), hay))
candidates.append({
"key": str(m.get('modelKey', key)),
"in_order": in_order,
"key_len": len(str(m.get('modelKey', key)) or ""),
"hay_len": len(hay),
})
if not candidates:
print(f"No models found matching '{search_term}'.")
return self.ERROR_NO_MODEL_FOUND, listing
candidates.sort(key=lambda c: (c["in_order"], c["key_len"], -c["hay_len"]), reverse=True)
return candidates[0]["key"], listing
except Exception as e:
print(f"[lms-node] Unexpected error in find_matching_model: {e}", file=sys.stderr)
return self.ERROR_CANT_CONNECT, ""
def _safe_identifier_for_key(self, model_key: str) -> str:
ident = (model_key or "comfyui-model").strip()
ident = re.sub(r'[^A-Za-z0-9._\-]+', '-', ident)
ident = re.sub(r'-{2,}', '-', ident).strip('-')
return ident[:64] if len(ident) > 64 else ident
def _build_loaded_metadata(self, model_key, chosen_path, context_length):
info = self._model_info_for_key(model_key) or {}
identifier = self._currently_loaded_identifier
ps = self._ps_models()
match = None
for r in ps:
if r.get("modelKey") == model_key or r.get("identifier") == identifier or r.get("identifier") == model_key:
match = r
break
return {
"modelKey": model_key,
"identifier": (match or {}).get("identifier") or identifier,
"displayName": info.get("displayName"),
"path": info.get("path") or chosen_path,
"architecture": info.get("architecture"),
"paramsString": info.get("paramsString"),
"quantization": info.get("quantization"),
"maxContextLength": info.get("maxContextLength"),
"loadedContextLength": int(context_length),
"loadedVia": "path" if chosen_path else "key",
"status": (match or {}).get("status"),
}
def load_model(self, input_string, model_search_string, context_length, seed, unload_image_models_first):
try:
model_key_to_load, models_found = self.find_matching_model(model_search_string)
if model_key_to_load in [self.ERROR_NO_MODEL_FOUND, self.ERROR_CANT_CONNECT]:
return (input_string, model_key_to_load, models_found)
desired_identifier = self._safe_identifier_for_key(model_key_to_load)
# Already loaded?
for r in self._ps_models():
if r.get("modelKey") == model_key_to_load or r.get("identifier") in (model_key_to_load, desired_identifier):
print(f"Model already loaded: {model_key_to_load} (identifier={r.get('identifier')})")
self._currently_loaded_model_key = model_key_to_load
self._currently_loaded_model_path = r.get("path")
self._currently_loaded_identifier = r.get("identifier") or desired_identifier
meta = self._build_loaded_metadata(model_key_to_load, None, context_length)
print("[lms-node] Loaded Model Info:\n" + json.dumps(meta, indent=2))
short_name = (meta.get("path") or model_key_to_load).replace("\\", "/").split("/")[-1]
return (input_string, short_name, models_found)
if self.check_models_loaded():
print("Unloading LLMs in memory...")
self.run_command(['lms', 'unload', '--all'])
if unload_image_models_first:
print("Unloading ComfyUI image models to save memory (as requested)")
self.unload_image_models()
print(f"Loading model by key: {model_key_to_load} (identifier={desired_identifier})")
chosen_path = None
key_cmd = [
'lms', 'load', model_key_to_load,
'-y',
'--identifier', desired_identifier,
'--context-length', str(int(context_length)),
'--gpu', 'max',
]
key_proc = self.run_command(key_cmd, timeout=600)
# Fallback: load by path if key load fails
if key_proc is None:
chosen_path = self._path_for_model_key(model_key_to_load)
if chosen_path:
print(f"[lms-node] Key load failed. Attempting path load: {chosen_path}")
path_cmd = [
'lms', 'load', chosen_path,
'-y',
'--identifier', desired_identifier,
'--context-length', str(int(context_length)),
'--gpu', 'max',
]
key_proc = self.run_command(path_cmd, timeout=600)
if key_proc is None:
print(f"Warning: Issue loading model: {model_key_to_load}")
return (input_string, self.ERROR_NO_MODEL_FOUND, models_found)
ps_after = self._ps_models()
match = None
for r in ps_after:
if r.get("identifier") == desired_identifier or r.get("modelKey") == model_key_to_load or r.get("identifier") == model_key_to_load:
match = r
break
self._currently_loaded_model_key = model_key_to_load
self._currently_loaded_model_path = (match or {}).get("path") or chosen_path
self._currently_loaded_identifier = (match or {}).get("identifier") or desired_identifier
meta = self._build_loaded_metadata(model_key_to_load, chosen_path, context_length)
print("[lms-node] Loaded Model Info:\n" + json.dumps(meta, indent=2))
short_name = (meta.get("path") or model_key_to_load).replace("\\", "/").split("/")[-1]
return (input_string, short_name, models_found)
except Exception as e:
print(f"Error: {str(e)}", file=sys.stderr)
return (input_string, self.ERROR_CANT_CONNECT, str(e))
class EbuLMStudioUnload:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_string": ("STRING", {"default": ""}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("export_string",)
FUNCTION = "unload_all"
CATEGORY = "LMStudio"
def unload_all(self, input_string, seed):
try:
print("Calling lms unload --all")
result = subprocess.run(['lms', 'unload', '--all'], capture_output=True, text=True, check=True)
print("Command output:", result.stdout)
if result.stderr:
print("", result.stderr, file=sys.stderr)
EbuLMStudioLoadModel._currently_loaded_model_key = None
EbuLMStudioLoadModel._currently_loaded_model_path = None
EbuLMStudioLoadModel._currently_loaded_identifier = None
return (input_string,)
except Exception as e:
print(f"[lms-node] unload_all failed: {e}", file=sys.stderr)
return (input_string,)
class EbuLMStudioMakeRequest:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"prompt": ("STRING", { "multiline": True, "default": "" }),
"system_message": ("STRING", { "multiline": True, "default": "You are an assistant designed to craft AI image prompts for an AI image generator that uses natural language prompts. Follow the instructions you are given, or use the guidelines, to create a detailed prompt that includes creativity and amazing visual details for an unforgetable image. Respond with just your new prompt." }),
"url": ("STRING", { "multiline": False, "default": "http://127.0.0.1:1234/v1/chat/completions" }),
"context_length": ("INT", { "default": 4096, "min": 512, "max": 65536, "display": "slider" }),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"max_tokens": ("INT", {"default": 300, "min": 10, "max": 100000}),
"temp": ("FLOAT", {"default": 0.7, "min": 0, "max": 1, "step": 0.05, "display": "number"}),
"top_p": ("FLOAT", {"default": 0.95, "min": 0, "max": 1, "step": 0.01, "display": "number"}),
"utf8_safe_replace": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("generated_text",)
FUNCTION = "generateText"
OUTPUT_NODE = False
CATEGORY = "LMStudio"
def sanitize_utf8(self, text):
print("Sanitizing the response into UTF-8 safe characters as requested...")
try:
from unicodedata import normalize
return normalize('NFKD', text).encode('ascii', 'ignore').decode('ascii')
except Exception as e:
print(f"Warning: UTF-8 sanitization failed: {str(e)}")
return text.encode('ascii', 'replace').decode('ascii')
def _auth_headers(self):
token = (os.environ.get("LMSTUDIO_API_KEY") or
os.environ.get("LM_API_TOKEN") or
os.environ.get("LM_API_KEY"))
if token:
return {"Authorization": f"Bearer {token}"}
return {}
def _base_v1_from_url(self, url: str) -> str:
if not url:
return "http://127.0.0.1:1234/v1"
idx = url.find("/v1/")
if idx != -1:
return url[:idx + 3]
if url.endswith("/v1"):
return url
p = urlparse(url)
return f"{p.scheme}://{p.netloc}/v1"
def _pick_model_id(self, url: str):
ident = EbuLMStudioLoadModel._currently_loaded_identifier
if ident:
return ident
base_v1 = self._base_v1_from_url(url)
models_url = f"{base_v1}/models"
try:
r = requests.get(models_url, headers=self._auth_headers(), timeout=10)
if r.status_code != 200:
return None
data = r.json()
items = data.get("data") if isinstance(data, dict) else data
if isinstance(items, list) and items:
first = items[0]
if isinstance(first, dict):
return first.get("id") or first.get("model") or first.get("identifier")
if isinstance(first, str):
return first
except Exception:
return None
return None
def generateText(self, prompt, system_message, url, context_length, seed, max_tokens, temp, top_p, utf8_safe_replace):
return (self.call_api(prompt, system_message, url, context_length, seed, max_tokens, temp, top_p, utf8_safe_replace),)
def call_api(self, prompt_text, system_message, url, context_length, seed, max_tokens, temp, top_p, utf8_safe_replace):
payload = {
"messages": [
{ "role": "system", "content": f"{system_message}\n" },
{ "role": "user", "content": f"{prompt_text}\n" }
],
"max_tokens": max_tokens,
"temperature": temp,
"top_p": top_p,
"seed": seed
}
model_id = self._pick_model_id(url)
if model_id:
payload["model"] = model_id
start_time = time.time()
try:
response = requests.post(url, json=payload, headers=self._auth_headers(), timeout=600)
if response.status_code == 200:
result_json = response.json()
content = None
try:
content = result_json["choices"][0]["message"]["content"]
except Exception:
content = None
if isinstance(content, list):
content = "".join([c.get("text", "") if isinstance(c, dict) else str(c) for c in content])
generatedText = content if isinstance(content, str) else json.dumps(result_json, indent=2)
if utf8_safe_replace:
generatedText = self.sanitize_utf8(generatedText)
elapsed_time = time.time() - start_time
print(f"\n[Response generation time: {elapsed_time:.2f} seconds]\n")
return generatedText
err = f"Error {response.status_code}: {response.text}"
print(err, file=sys.stderr)
# Back-compat: if server is older and doesn't require `model`
if "model" in payload:
payload2 = dict(payload)
payload2.pop("model", None)
try:
response2 = requests.post(url, json=payload2, headers=self._auth_headers(), timeout=600)
if response2.status_code == 200:
rj = response2.json()
txt = rj["choices"][0]["message"]["content"]
return txt if isinstance(txt, str) else json.dumps(rj, indent=2)
except Exception:
pass
return err
except requests.exceptions.RequestException as e:
error_message = f"Request failed: {str(e)}"
print(error_message, file=sys.stderr)
return error_message
class EbuLMStudioUnloadGuider:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"guider": ("GUIDER",),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
}
RETURN_TYPES = ("GUIDER",)
RETURN_NAMES = ("guider",)
FUNCTION = "unload_all"
CATEGORY = "LMStudio"
def unload_all(self, guider, seed):
try:
print("Calling lms unload --all")
result = subprocess.run(['lms', 'unload', '--all'], capture_output=True, text=True, check=True)
print("Command output:", result.stdout)
if result.stderr:
print("", result.stderr, file=sys.stderr)
EbuLMStudioLoadModel._currently_loaded_model_key = None
EbuLMStudioLoadModel._currently_loaded_model_path = None
EbuLMStudioLoadModel._currently_loaded_identifier = None
return (guider,)
except Exception as e:
print(f"[lms-node] unload_all (guider) failed: {e}", file=sys.stderr)
return (guider,)
class EbuLMStudioBrainstormer:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"topic": ("STRING", {"default": "Fantasy Setting Magic Spells"}),
"for_each_idea": ("STRING", {"default": "A magic spell that a wizard or sorceress might cast in a fantasy novel. I want the name of the spell and a brief description of what it does.", "multiline": True}),
"raw_list_size": ("INT", {"default": 20, "min": 1}),
"return_list_size": ("INT", {"default": 10, "min": 0}),
"ignore_the_first": ("INT", {"default": 0, "min": 0}),
"additional_notes_1": ("STRING", {"default": "", "multiline": True}),
"additional_notes_2": ("STRING", {"default": "", "multiline": True}),
"url": ("STRING", {"multiline": False, "default": "http://127.0.0.1:1234/v1/chat/completions"}),
"context_length": ("INT", {"default": 4096, "min": 512, "max": 65536}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"max_tokens": ("INT", {"default": 300, "min": 10, "max": 100000}),
"temp": ("FLOAT", {"default": 0.70, "min": 0.00, "max": 3.00, "step": 0.01}),
"top_p": ("FLOAT", {"default": 0.95, "min": 0.00, "max": 1.00, "step": 0.01}),
"utf8_safe_replace": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("result", "full_list")
FUNCTION = "brainstorm"
CATEGORY = "LMStudio"
def brainstorm(self, topic, for_each_idea, raw_list_size, return_list_size, ignore_the_first,
additional_notes_1, additional_notes_2,
url, context_length, seed, max_tokens, temp, top_p, utf8_safe_replace):
system_message = (
"You are an expert brainstorming assistant whose sole job is to produce numbered lists of suggestions. "
"Respond with the list only—no titles, no commentary, no extra text. Each suggestion must be on one line."
)
prompt = (
f"Generate exactly {raw_list_size} ideas on the topic '{topic}'.\n\n"
f"For each idea, provide exactly this information and no more: {for_each_idea}.\n"
"Do not include any additional context, background, or commentary beyond that.\n"
f"Return exactly {raw_list_size} lines. Each line must begin with '1. ', '2. ', etc., followed by the suggestion. "
"There must be a real line break after each item, and no literal '\\n' sequences."
)
notes = []
if additional_notes_1.strip() or additional_notes_2.strip():
notes.append("=== ADDITIONAL NOTES ===")
if additional_notes_1.strip():
notes.append(additional_notes_1.strip())
if additional_notes_2.strip():
notes.append(additional_notes_2.strip())
prompt += "\n\n" + "\n".join(notes)
make_request = EbuLMStudioMakeRequest()
full_response = make_request.generateText(
prompt, system_message,
url, context_length, seed,
max_tokens, temp, top_p,
utf8_safe_replace
)[0]
full_list = full_response.strip()
lines = [line for line in full_list.splitlines() if line.strip()]
if ignore_the_first > 0:
if ignore_the_first >= len(lines):
raise ValueError(f"ignore_the_first ({ignore_the_first}) must be less than the total number of ideas ({len(lines)})")
lines = lines[ignore_the_first:]
if return_list_size > len(lines):
raise ValueError(f"return_list_size ({return_list_size}) cannot be greater than available ideas ({len(lines)}) after ignoring the first {ignore_the_first}")
if return_list_size == 0:
result = ""
elif return_list_size == 1:
choice = random.choice(lines)
result = re.sub(r'^\s*\d+[\.|\)]\s*', '', choice).strip()
else:
sampled = random.sample(lines, return_list_size)
stripped = [re.sub(r'^\s*\d+[\.|\)]\s*', '', l).strip() for l in sampled]
result_lines = [f"{i+1}. {stripped[i]}" for i in range(len(stripped))]
result = "\n".join(result_lines)
return (result, full_list)
NODE_CLASS_MAPPINGS = {
"EbuLMStudioLoadModel": EbuLMStudioLoadModel,
"EbuLMStudioUnload": EbuLMStudioUnload,
"EbuLMStudioMakeRequest": EbuLMStudioMakeRequest,
"EbuLMStudioUnloadGuider": EbuLMStudioUnloadGuider,
"EbuLMStudioBrainstormer": EbuLMStudioBrainstormer,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EbuLMStudioLoadModel": "EBU LMStudio Load",
"EbuLMStudioUnload": "EBU LMStudio Unload All",
"EbuLMStudioMakeRequest": "EBU LMStudio Make Request",
"EbuLMStudioUnloadGuider": "EBU LMStudio Unload (Guider)",
"EbuLMStudioBrainstormer": "EBU LMStudio Brainstormer",
}