Files
ausboss-ComfyUI-AusBoss/tests/test_lmstudio_helpers.py
T
ausboss 139082a9d9 Fix LoRA mismatch detection, LM Studio reasoning, Compare height; add pad stitcher
Six fixes found while testing every node against real workflows.

LoRA Loader: the "wrong base model" warning never fired on a modern stack.
It matched comfy's model class name against a table covering 8 of the 81
classes comfy ships, so Krea 2, Qwen, WAN, LTXV, Z-Image and friends read as
"unknown" and skipped the check entirely - an SD 1.5 LoRA on Krea 2 patched
nothing, changed nothing, and said nothing. It now measures the applied-patch
count across model and CLIP before and after each row and warns when a row
patched nothing, which needs no table, no LoRA metadata, and works on every
model family. base_model_family also stopped mining ss_sd_model_name: that is
the trainer's source filename, and reading substrings out of it labelled any
..._v1.safetensors as SD1.5 and anything containing xl as SDXL.

LM Studio Chat: reasoning returned in the server's own reasoning_content field
now reaches the thinking output. Only inline <think> tags were read, so with
gemma/qwen-style hybrids the whole reply landed in a field the node never
looked at and text came back blank. An empty answer now explains itself,
naming the max_tokens budget when the model ran out of room mid-thought.

Compare: the stage follows the node's height, not only its width. Its panel
declared a computeSize, and the widget layout gives any widget that defines one
a fixed height and leaves it out of the leftover-space split, so dragging the
node taller only added dead space under the image.

Pad Image / Load Image + Pad: a stitcher output, so an outpaint can put the
source back exactly. Padding now builds the same stitcher shape Crop For
Inpaint emits - the crop is simply the whole canvas - so the existing Stitch
Inpaint node serves both and no second stitch node has to exist. Appended
last, so saved workflows keep their links.

Settings menus: editing one row no longer reverts the others. Each save handed
back a value set rebuilt from stored defaults, silently replacing every other
row on a node whose workflow differed from those defaults.

LoRA templates popover: Save was clipped off the panel edge and each saved
row's delete x was stretched across the whole row - one blanket
.ausboss-lora-menu button rule outranked the specialized buttons inside it.
2026-08-17 20:47:27 -05:00

486 lines
19 KiB
Python

"""Offline tests for the LM Studio chat node.
Everything except the real POST runs here; the POST itself is exercised
against a mocked urlopen, including every error path a local server can
produce. No test touches the network.
"""
from __future__ import annotations
import asyncio
import base64
import io
import json
import sys
import unittest
import urllib.error
from pathlib import Path
from unittest.mock import patch
import torch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes._lmstudio_helpers import (
DEFAULT_ENDPOINT,
build_chat_payload,
chat_completions_url,
history_with_turn,
image_data_url,
models_url,
normalize_history,
describe_empty_reply,
parse_chat_text,
parse_finish_reason,
parse_reasoning_content,
parse_response_schema,
request_chat,
split_reasoning,
)
from nodes.node_lmstudio_chat import AusBossLmStudioChat
class ChatUrlTests(unittest.TestCase):
def test_every_reasonable_spelling_lands_on_the_route(self):
expected = "http://127.0.0.1:1234/v1/chat/completions"
for endpoint in (
"http://127.0.0.1:1234/v1",
"http://127.0.0.1:1234/v1/",
"http://127.0.0.1:1234",
"127.0.0.1:1234",
"127.0.0.1:1234/v1",
"http://127.0.0.1:1234/v1/chat/completions",
):
self.assertEqual(chat_completions_url(endpoint), expected, endpoint)
def test_empty_endpoint_falls_back_to_the_default(self):
self.assertEqual(
chat_completions_url(""),
DEFAULT_ENDPOINT.rstrip("/") + "/chat/completions",
)
self.assertEqual(chat_completions_url(None), chat_completions_url(""))
def test_https_and_remote_hosts_survive(self):
self.assertEqual(
chat_completions_url("https://box.local:8080/v1"),
"https://box.local:8080/v1/chat/completions",
)
def test_models_url_mirrors_every_endpoint_spelling(self):
expected = "http://127.0.0.1:1234/v1/models"
for endpoint in (
"http://127.0.0.1:1234/v1",
"http://127.0.0.1:1234",
"127.0.0.1:1234",
"http://127.0.0.1:1234/v1/chat/completions",
):
self.assertEqual(models_url(endpoint), expected, endpoint)
self.assertEqual(models_url(""), DEFAULT_ENDPOINT.rstrip("/") + "/models")
class PayloadTests(unittest.TestCase):
def test_text_only_payload_is_the_plain_openai_shape(self):
payload = build_chat_payload("", "", "hello", None, 0.7, 512, 42)
self.assertEqual(
payload["messages"], [{"role": "user", "content": "hello"}]
)
self.assertNotIn("model", payload) # empty = whatever is loaded
self.assertEqual(payload["max_tokens"], 512)
self.assertEqual(payload["seed"], 42)
self.assertIs(payload["stream"], False)
def test_system_prompt_prepends_a_system_message(self):
payload = build_chat_payload("", "be terse", "hi", None, 0.7, 512, 0)
self.assertEqual(payload["messages"][0], {"role": "system", "content": "be terse"})
self.assertEqual(len(payload["messages"]), 2)
def test_blank_system_prompt_sends_no_system_message(self):
payload = build_chat_payload("", " ", "hi", None, 0.7, 512, 0)
self.assertEqual(len(payload["messages"]), 1)
def test_image_becomes_a_typed_content_part(self):
payload = build_chat_payload("m", "", "what is this", "data:image/png;base64,AA==", 0.5, 256, 1)
content = payload["messages"][0]["content"]
self.assertEqual(content[0], {"type": "text", "text": "what is this"})
self.assertEqual(content[1]["type"], "image_url")
self.assertEqual(content[1]["image_url"]["url"], "data:image/png;base64,AA==")
self.assertEqual(payload["model"], "m")
def test_image_with_empty_prompt_sends_only_the_image(self):
payload = build_chat_payload("", "", "", "data:image/png;base64,AA==", 0.5, 256, 1)
content = payload["messages"][0]["content"]
self.assertEqual(len(content), 1)
self.assertEqual(content[0]["type"], "image_url")
def test_max_tokens_minus_one_is_left_to_the_server(self):
self.assertNotIn("max_tokens", build_chat_payload("", "", "hi", None, 0.7, -1, 0))
def test_neutral_advanced_values_change_nothing(self):
plain = build_chat_payload("", "", "hi", None, 0.7, 512, 0)
neutral = build_chat_payload(
"", "", "hi", None, 0.7, 512, 0,
advanced={
"top_p": 1.0, "top_k": 0, "repeat_penalty": 1.0, "min_p": 0.0,
"presence_penalty": 0.0, "thinking_mode": "model default",
"idle_unload_seconds": 0,
},
)
self.assertEqual(plain, neutral)
def test_active_advanced_values_land_in_the_body(self):
payload = build_chat_payload(
"", "", "hi", None, 0.7, 512, 0,
advanced={
"top_p": 0.9, "top_k": 40, "repeat_penalty": 1.1, "min_p": 0.05,
"presence_penalty": 0.5, "thinking_mode": "off",
"idle_unload_seconds": 300,
},
)
self.assertEqual(payload["top_p"], 0.9)
self.assertEqual(payload["top_k"], 40)
self.assertEqual(payload["repeat_penalty"], 1.1)
self.assertEqual(payload["min_p"], 0.05)
self.assertEqual(payload["presence_penalty"], 0.5)
self.assertEqual(payload["ttl"], 300)
self.assertEqual(payload["chat_template_kwargs"], {"enable_thinking": False})
def test_thinking_mode_on_asks_the_template_to_think(self):
payload = build_chat_payload(
"", "", "hi", None, 0.7, 512, 0, advanced={"thinking_mode": "on"}
)
self.assertEqual(payload["chat_template_kwargs"], {"enable_thinking": True})
class ParseTests(unittest.TestCase):
def test_plain_string_content(self):
data = {"choices": [{"message": {"content": "a reply"}}]}
self.assertEqual(parse_chat_text(data), "a reply")
def test_typed_part_list_content(self):
data = {"choices": [{"message": {"content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]}}]}
self.assertEqual(parse_chat_text(data), "ab")
def test_server_error_object_surfaces_its_message(self):
with self.assertRaises(RuntimeError) as caught:
parse_chat_text({"error": {"message": "No model loaded"}})
self.assertIn("No model loaded", str(caught.exception))
def test_malformed_response_names_the_expected_shape(self):
for bad in ({}, {"choices": []}, {"choices": [{}]}, "nope", None):
with self.assertRaises(RuntimeError) as caught:
parse_chat_text(bad)
self.assertIn("choices[0].message.content", str(caught.exception))
class SplitReasoningTests(unittest.TestCase):
def test_ordinary_replies_pass_through_with_empty_thinking(self):
self.assertEqual(split_reasoning("just an answer"), ("just an answer", ""))
def test_think_block_is_moved_to_the_second_output(self):
text = "<think>step 1\nstep 2</think>The answer is 4."
self.assertEqual(split_reasoning(text), ("The answer is 4.", "step 1\nstep 2"))
def test_multiple_blocks_join_and_case_does_not_matter(self):
text = "<THINK>a</THINK>mid<think>b</think> end"
answer, thinking = split_reasoning(text)
self.assertEqual(answer, "mid end")
self.assertEqual(thinking, "a\n\nb")
def test_an_unclosed_block_counts_as_reasoning_to_the_end(self):
# The model hit max_tokens mid-thought; the answer must not contain it.
answer, thinking = split_reasoning("<think>ran out of tok")
self.assertEqual(answer, "")
self.assertEqual(thinking, "ran out of tok")
def test_custom_tags_split_and_defaults_survive_blanks(self):
text = "<|sot|>plan<|eot|>done"
self.assertEqual(split_reasoning(text, "<|sot|>", "<|eot|>"), ("done", "plan"))
# Blank custom tags fall back to the <think> defaults.
self.assertEqual(
split_reasoning("<think>a</think>b", "", ""), ("b", "a")
)
class ImageDataUrlTests(unittest.TestCase):
def frames(self, batch=1, height=32, width=48):
return torch.rand((batch, height, width, 3))
def decode(self, url: str):
from PIL import Image
prefix = "data:image/png;base64,"
self.assertTrue(url.startswith(prefix))
return Image.open(io.BytesIO(base64.b64decode(url[len(prefix):])))
def test_round_trips_as_a_png_of_the_same_size(self):
picture = self.decode(image_data_url(self.frames(), 0))
self.assertEqual(picture.size, (48, 32))
self.assertEqual(picture.mode, "RGB")
def test_only_the_first_frame_of_a_batch_is_sent(self):
batch = torch.zeros((3, 8, 8, 3))
batch[0] = 1.0 # white first frame, black rest
picture = self.decode(image_data_url(batch, 0))
self.assertEqual(picture.getpixel((4, 4)), (255, 255, 255))
def test_max_edge_downscales_and_keeps_aspect(self):
picture = self.decode(image_data_url(self.frames(1, 400, 800), 200))
self.assertEqual(picture.size, (200, 100))
def test_max_edge_zero_never_resizes(self):
picture = self.decode(image_data_url(self.frames(1, 400, 800), 0))
self.assertEqual(picture.size, (800, 400))
def test_rejects_non_bhwc_input(self):
with self.assertRaises(ValueError):
image_data_url(torch.zeros((32, 32, 3)), 0)
class FakeReply:
def __init__(self, body: bytes):
self.body = body
def read(self):
return self.body
def __enter__(self):
return self
def __exit__(self, *args):
return False
class RequestChatTests(unittest.TestCase):
URL = "http://127.0.0.1:1234/v1/chat/completions"
def test_posts_json_and_returns_the_parsed_reply(self):
seen = {}
def fake_urlopen(request, timeout=None):
seen["url"] = request.full_url
seen["body"] = json.loads(request.data.decode("utf-8"))
seen["timeout"] = timeout
return FakeReply(b'{"choices": [{"message": {"content": "ok"}}]}')
with patch("urllib.request.urlopen", fake_urlopen):
data = request_chat(self.URL, {"messages": []}, 30)
self.assertEqual(parse_chat_text(data), "ok")
self.assertEqual(seen["url"], self.URL)
self.assertEqual(seen["body"], {"messages": []})
self.assertEqual(seen["timeout"], 30)
def test_connection_refused_says_how_to_start_the_server(self):
def fake_urlopen(request, timeout=None):
raise urllib.error.URLError(ConnectionRefusedError(111, "refused"))
with patch("urllib.request.urlopen", fake_urlopen):
with self.assertRaises(RuntimeError) as caught:
request_chat(self.URL, {}, 30)
message = str(caught.exception)
self.assertIn("LM Studio server running", message)
self.assertIn(self.URL, message)
def test_http_error_carries_the_server_message(self):
def fake_urlopen(request, timeout=None):
raise urllib.error.HTTPError(
self.URL, 404, "Not Found", {},
io.BytesIO(b'{"error": {"message": "Model not found"}}'),
)
with patch("urllib.request.urlopen", fake_urlopen):
with self.assertRaises(RuntimeError) as caught:
request_chat(self.URL, {}, 30)
message = str(caught.exception)
self.assertIn("HTTP 404", message)
self.assertIn("Model not found", message)
def test_timeout_names_the_wait_and_suggests_raising_it(self):
def fake_urlopen(request, timeout=None):
raise urllib.error.URLError(TimeoutError("timed out"))
with patch("urllib.request.urlopen", fake_urlopen):
with self.assertRaises(RuntimeError) as caught:
request_chat(self.URL, {}, 45)
self.assertIn("45s", str(caught.exception))
def test_non_json_reply_points_at_the_endpoint_setting(self):
def fake_urlopen(request, timeout=None):
return FakeReply(b"<html>a web page</html>")
with patch("urllib.request.urlopen", fake_urlopen):
with self.assertRaises(RuntimeError) as caught:
request_chat(self.URL, {}, 30)
self.assertIn("not with JSON", str(caught.exception))
class ChatNodeTests(unittest.TestCase):
def run_chat(self, **overrides):
node = AusBossLmStudioChat()
arguments = dict(
prompt="describe a cat",
system_prompt="",
endpoint=DEFAULT_ENDPOINT,
model="",
temperature=0.7,
max_tokens=512,
seed=0,
timeout_seconds=30,
image_max_edge=1024,
image=None,
)
arguments.update(overrides)
return asyncio.run(node.chat(**arguments))
def test_returns_text_and_split_thinking(self):
reply = {"choices": [{"message": {"content": "<think>hmm</think>A tabby cat."}}]}
with patch("nodes.node_lmstudio_chat.request_chat", return_value=reply) as sent:
text, thinking, history = self.run_chat()
self.assertEqual(text, "A tabby cat.")
self.assertEqual(thinking, "hmm")
url, payload, timeout = sent.call_args[0]
self.assertTrue(url.endswith("/v1/chat/completions"))
self.assertEqual(payload["messages"][0]["content"], "describe a cat")
self.assertEqual(timeout, 30)
def test_empty_prompt_without_an_image_refuses_before_any_request(self):
with patch("nodes.node_lmstudio_chat.request_chat") as sent:
with self.assertRaises(ValueError) as caught:
self.run_chat(prompt=" ")
self.assertIn("type a prompt or connect an image", str(caught.exception))
sent.assert_not_called()
def test_an_image_alone_is_a_valid_request(self):
reply = {"choices": [{"message": {"content": "A photo."}}]}
with patch("nodes.node_lmstudio_chat.request_chat", return_value=reply) as sent:
text, _, _history = self.run_chat(prompt="", image=torch.rand((1, 16, 16, 3)))
self.assertEqual(text, "A photo.")
content = sent.call_args[0][1]["messages"][0]["content"]
self.assertEqual(content[0]["type"], "image_url")
def test_the_entry_point_is_async(self):
import inspect
self.assertTrue(inspect.iscoroutinefunction(AusBossLmStudioChat.chat))
class HistoryTests(unittest.TestCase):
def test_normalize_keeps_only_clean_user_assistant_turns(self):
raw = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "system", "content": "nope"},
{"role": "user", "content": ""},
{"role": "user", "content": ["not", "a", "string"]},
"garbage",
]
self.assertEqual(
normalize_history(raw),
[{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}],
)
self.assertEqual(normalize_history(None), [])
self.assertEqual(normalize_history("nope"), [])
def test_history_turns_replay_between_system_and_the_new_prompt(self):
history = [{"role": "user", "content": "first"}, {"role": "assistant", "content": "reply"}]
payload = build_chat_payload(
"", "be brief", "second", None, 0.7, 128, 0, history=history
)
roles = [message["role"] for message in payload["messages"]]
self.assertEqual(roles, ["system", "user", "assistant", "user"])
self.assertEqual(payload["messages"][-1]["content"], "second")
def test_history_with_turn_appends_the_exchange(self):
grown = history_with_turn(None, "ask", False, "answer")
self.assertEqual(
grown,
[{"role": "user", "content": "ask"}, {"role": "assistant", "content": "answer"}],
)
with_image = history_with_turn(grown, "look", True, "seen")
self.assertEqual(with_image[2]["content"], "look [image attached]")
self.assertEqual(len(with_image), 4)
class ResponseSchemaTests(unittest.TestCase):
def test_empty_schema_means_plain_text(self):
self.assertIsNone(parse_response_schema(""))
self.assertIsNone(parse_response_schema(" "))
payload = build_chat_payload("", "", "hi", None, 0.7, 64, 0)
self.assertNotIn("response_format", payload)
def test_schema_rides_the_payload_as_strict_json_schema(self):
body = parse_response_schema('{"type": "object"}')
self.assertEqual(body["type"], "json_schema")
self.assertTrue(body["json_schema"]["strict"])
payload = build_chat_payload(
"", "", "hi", None, 0.7, 64, 0, response_format=body
)
self.assertEqual(payload["response_format"]["json_schema"]["schema"], {"type": "object"})
def test_bad_schema_raises_with_the_fix_spelled_out(self):
with self.assertRaisesRegex(ValueError, "not valid JSON"):
parse_response_schema("{nope")
with self.assertRaisesRegex(ValueError, "JSON object"):
parse_response_schema('"just a string"')
if __name__ == "__main__":
unittest.main()
# The gemma/qwen-style hybrid shape: the whole reply lands in a sibling
# reasoning_content field and `content` comes back empty.
REASONING_ONLY = {
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "",
"reasoning_content": "* Subject: a cabin.\n* Dusk.",
"tool_calls": []},
"finish_reason": "length",
}],
}
class ReasoningContentTests(unittest.TestCase):
def test_reads_the_servers_own_reasoning_field(self):
self.assertIn("Subject: a cabin", parse_reasoning_content(REASONING_ONLY))
def test_accepts_the_shorter_reasoning_spelling(self):
data = {"choices": [{"message": {"content": "", "reasoning": "thought"}}]}
self.assertEqual(parse_reasoning_content(data), "thought")
def test_absent_reasoning_is_empty_not_an_error(self):
self.assertEqual(parse_reasoning_content({"choices": [{"message": {"content": "hi"}}]}), "")
self.assertEqual(parse_reasoning_content({}), "")
self.assertEqual(parse_reasoning_content(None), "")
def test_blank_reasoning_does_not_count(self):
data = {"choices": [{"message": {"content": "", "reasoning_content": " "}}]}
self.assertEqual(parse_reasoning_content(data), "")
def test_finish_reason_read_and_tolerated(self):
self.assertEqual(parse_finish_reason(REASONING_ONLY), "length")
self.assertEqual(parse_finish_reason({}), "")
self.assertEqual(parse_finish_reason(None), "")
class EmptyReplyDiagnosisTests(unittest.TestCase):
def test_truncated_thinking_names_the_token_budget(self):
message = describe_empty_reply("thought", "length", 300)
self.assertIn("all 300 tokens", message)
self.assertIn("max_tokens", message)
message.encode("ascii")
def test_unknown_budget_stays_readable(self):
self.assertIn("its whole budget", describe_empty_reply("thought", "length", -1))
def test_reasoning_without_truncation_suggests_turning_thinking_off(self):
message = describe_empty_reply("thought", "stop", 512)
self.assertIn("thinking to 'off'", message)
def test_no_reasoning_means_no_diagnosis(self):
# A plain model returning "" is a different problem; do not guess.
self.assertEqual(describe_empty_reply("", "length", 300), "")