Compare commits

..
Author SHA1 Message Date
Gerald LonlasandClaude Opus 4.8 d9d5c77e0f test(contracts): add image-encoding and node status behavioral contracts
Close gaps that full line+branch coverage does not catch:

- comfy_image_to_base64_png_url: assert RGBA (4-channel) tensor round-trips
  correctly through PNG encoding (pixel values preserved).
- comfy_image_to_base64_png_url: assert non-square image dimensions (H x W)
  are preserved in the encoded PNG — catches H/W transposition mutations.
- comfy_image_to_base64_png_url: assert batch[0] (red) is encoded, not
  batch[1] (blue) — catches wrong-frame-index mutations.
- image_to_text_node._responses_kwargs: assert every image content entry uses
  the key 'image_url' (not 'url') and contains a valid data:image/png;base64,
  URL — for both single and batch paths.
- image_to_text_node._responses_kwargs: assert batch frames produce distinct
  data URLs — catches mutations that pass the same index for all frames.
- image_to_text_node.execute: assert the status message contains the actual
  model name for both the responses and chat.completions fallback paths.
- text_gen_node.execute: assert the status message contains the actual model
  name for both paths — a hardcoded name mutation goes undetected otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:26:41 +08:00
17 changed files with 192 additions and 467 deletions
+5 -9
View File
@@ -8,15 +8,11 @@ Small ComfyUI custom nodes to connect to a remote LMStudio server through OpenAI
- **LMStudio - Connect**
- Connect to a remote LMStudio URL
- Pick the model from a dropdown populated by **Refresh Models**
- **Test connectivity** to the server
- **Thinking / Reasoning** control — `auto` / `on` / `off`:
- `auto` — defer to the model, but force reasoning on for families that stay
silent by default (Gemma). Qwen3-style models already think by default.
- `on` — force reasoning on (`enable_thinking: true`).
- `off` — suppress reasoning so the model answers directly and doesn't burn
its token budget on a hidden `<think>` trace.
- Advanced: API token, Tooling / MCP toggle, max tokens, temperature, timeout
- Set API token
- Pull/load model list automatically (Refresh Models)
- Test connectivity to server
- Supports LM **Reasonning** mode toggle
- Supports Tooling / MCP toggle
- **LMStudio - Text Gen**
- System prompt + user prompt
+1 -78
View File
@@ -22,13 +22,6 @@ DEFAULT_API_KEY_PLACEHOLDER = "-"
DEFAULT_API_KEY_FALLBACK = "lm-studio"
MODEL_PLACEHOLDER = "<refresh models>"
# User-facing thinking/reasoning choices exposed on the Connect node.
THINKING_AUTO = "auto"
THINKING_ON = "on"
THINKING_OFF = "off"
THINKING_OPTIONS = [THINKING_AUTO, THINKING_ON, THINKING_OFF]
DEFAULT_THINKING = THINKING_AUTO
def normalize_server_url(server_url: str) -> str:
value = (server_url or "").strip()
@@ -252,88 +245,18 @@ def extract_responses_text(response: Any) -> str:
_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", flags=re.IGNORECASE | re.DOTALL)
_THINK_START_RE = re.compile(r"<think>.*$", flags=re.IGNORECASE | re.DOTALL)
_THINK_CLOSE_RE = re.compile(r"</think>", flags=re.IGNORECASE)
# Wire-level thinking modes resolved from the user's THINKING_* choice.
THINK_MODE_SUPPRESS = "suppress" # actively turn reasoning OFF
THINK_MODE_FORCE = "force" # actively turn reasoning ON
THINK_MODE_DEFAULT = "default" # send nothing; defer to the model's own template
# Trailing assistant turn that opens AND closes the reasoning block so a
# Qwen-style chat template starts generation *past* the <think> phase and the
# model writes prose immediately. Paired with THINK_SUPPRESS_KWARGS below,
# which newer templates (Qwen3+) actually honor. Harmless on non-thinking models.
THINK_SUPPRESS_PREFILL: dict[str, Any] = {"role": "assistant", "content": "<think></think>"}
# Template-level thinking switch — the same mechanism LM Studio's own "thinking"
# toggle uses. Forwarded to the upstream Jinja chat template via
# `chat_template_kwargs`. A *top-level* enable_thinking field is dropped before it
# reaches the template; the nesting under chat_template_kwargs is what makes it
# take effect. Ignored by templates that don't define the kwarg.
THINK_SUPPRESS_KWARGS: dict[str, Any] = {"enable_thinking": False}
THINK_ENABLE_KWARGS: dict[str, Any] = {"enable_thinking": True}
# Families whose LM Studio chat template does NOT think by default, so thinking
# level "auto" must actively pass enable_thinking:true to get any reasoning.
# Gemma (2/3/4) is the known case; Qwen 3.x-style templates think by default.
_THINK_OFF_BY_DEFAULT_RE = re.compile(r"\bgemma\b|gemma[-_]?\d", flags=re.IGNORECASE)
def model_thinks_off_by_default(model: str | None) -> bool:
"""True when the model's family needs thinking forced on in "auto" mode."""
return bool(_THINK_OFF_BY_DEFAULT_RE.search(model or ""))
def resolve_thinking_mode(thinking: str | None, model: str | None) -> str:
"""Resolve a user THINKING_* choice + model id into a wire-level mode.
- "off" -> suppress (enable_thinking:false + closed <think> prefill)
- "on" -> force (enable_thinking:true)
- "auto" -> force for families that don't think by default (Gemma),
otherwise default (send nothing — the template already thinks).
"""
choice = (thinking or DEFAULT_THINKING).strip().lower()
if choice == THINKING_OFF:
return THINK_MODE_SUPPRESS
if choice == THINKING_ON:
return THINK_MODE_FORCE
return THINK_MODE_FORCE if model_thinks_off_by_default(model) else THINK_MODE_DEFAULT
def thinking_chat_template_kwargs(mode: str) -> dict[str, Any] | None:
"""chat_template_kwargs payload for a resolved mode (None = send nothing)."""
if mode == THINK_MODE_SUPPRESS:
return dict(THINK_SUPPRESS_KWARGS)
if mode == THINK_MODE_FORCE:
return dict(THINK_ENABLE_KWARGS)
return None
def apply_thinking_prefill(
messages: list[dict[str, Any]], mode: str
) -> list[dict[str, Any]]:
"""Append the closed-<think> suppression prefill when actively suppressing."""
if mode == THINK_MODE_SUPPRESS:
return [*messages, dict(THINK_SUPPRESS_PREFILL)]
return messages
def strip_think_content(text: str | None) -> str:
"""
Remove reasoning blocks enclosed in <think>...</think>.
Also removes dangling <think> blocks without a closing tag and any
orphan </think> closing tags left by nested or malformed think blocks.
Also removes dangling <think> blocks without a closing tag.
"""
if not text:
return ""
cleaned = _THINK_BLOCK_RE.sub("", text)
cleaned = _THINK_START_RE.sub("", cleaned)
# After the above two passes, any remaining </think> tags are orphaned
# closing tags produced by nested blocks (e.g. <think><think>…</think></think>).
# The non-greedy block regex only removes up to the *first* </think>, leaving
# the outer one behind; strip it here so it never appears in visible output.
cleaned = _THINK_CLOSE_RE.sub("", cleaned)
return cleaned.strip()
+25 -64
View File
@@ -5,17 +5,11 @@ from comfy_api.latest import io, ui
from .client import (
DEFAULT_MAX_TOKENS,
DEFAULT_TEMPERATURE,
DEFAULT_THINKING,
DEFAULT_TIMEOUT_SECONDS,
MODEL_PLACEHOLDER,
THINK_MODE_DEFAULT,
THINK_MODE_FORCE,
THINK_MODE_SUPPRESS,
THINKING_OPTIONS,
get_server_models,
normalize_api_key,
normalize_server_url,
resolve_thinking_mode,
)
from .iotypes import ParamConnection
from .models import LMStudioConnectionPayload
@@ -30,8 +24,7 @@ class LMStudioConnect(io.ComfyNode):
category="LMStudio",
description=(
"Creates a reusable LMStudio connection for downstream nodes. "
"Set the Server URL, then click 'Refresh Models' to load and pick a model. "
"'Test Connection' verifies the server is reachable."
"Use the refresh/test buttons on this node to fetch models from the remote server."
),
inputs=[
io.String.Input(
@@ -39,52 +32,41 @@ class LMStudioConnect(io.ComfyNode):
display_name="Server URL",
default="http://127.0.0.1:1234",
placeholder="http://10.168.168.7:1234",
tooltip="LMStudio server address, e.g. http://127.0.0.1:1234 (no /v1).",
),
io.Combo.Input(
id="model",
display_name="Model",
options=[MODEL_PLACEHOLDER],
default=MODEL_PLACEHOLDER,
tooltip=(
"Loaded model to use. Click 'Refresh Models' to populate this "
"dropdown from the server, then pick one."
),
),
io.Combo.Input(
id="thinking",
display_name="Thinking / Reasoning",
options=list(THINKING_OPTIONS),
default=DEFAULT_THINKING,
tooltip=(
"Controls the model's reasoning phase.\n"
"• auto — let the model decide; Gemma-family models are nudged on "
"(they stay silent otherwise).\n"
"• on — force reasoning on (enable_thinking:true).\n"
"• off — suppress reasoning so the model answers directly, saving tokens."
),
tooltip="LMStudio server URL (without /v1).",
),
io.String.Input(
id="api_token",
display_name="API Token",
default="-",
placeholder="Leave '-' for lm-studio",
tooltip="Bearer token for the LMStudio OpenAI-compatible API. Leave '-' for local servers.",
advanced=True,
tooltip="Bearer token for LMStudio OpenAI-compatible API.",
),
io.String.Input(
id="model",
display_name="Model",
default=MODEL_PLACEHOLDER,
tooltip=(
"Model id. The web extension renders this field as a dropdown and syncs the selected model value."
),
),
io.Boolean.Input(
id="reasoning_enabled",
display_name="Enable Reasoning",
default=False,
tooltip="Enable model reasoning hints when using the responses endpoint.",
),
io.Boolean.Input(
id="use_tooling_mcp",
display_name="Use Tooling / MCP",
default=False,
tooltip=(
"Signal intent to use MCP tooling. Only useful when the target "
"model/session is configured for tool-enabled responses."
"Expose intent to use MCP tooling. Useful only when your target model/session is configured "
"for tool-enabled responses."
),
advanced=True,
),
io.Int.Input(
id="max_tokens",
display_name="Max Tokens",
display_name="Max Token",
default=DEFAULT_MAX_TOKENS,
min=1,
max=1_000_000,
@@ -133,17 +115,7 @@ class LMStudioConnect(io.ComfyNode):
)
@classmethod
def validate_inputs(
cls,
server_url: str,
timeout_seconds: int,
max_tokens: int,
model: str | None = None,
) -> bool | str:
# `model` is accepted here purely so ComfyUI skips its built-in combo
# "value not in list" check: the real option list is only known at
# runtime (fetched from the server), so the schema ships a placeholder.
# Actual model availability is validated in execute().
def validate_inputs(cls, server_url: str, timeout_seconds: int, max_tokens: int) -> bool | str:
try:
normalize_server_url(server_url)
except ValueError as exc:
@@ -161,7 +133,7 @@ class LMStudioConnect(io.ComfyNode):
server_url: str,
api_token: str | None,
model: str,
thinking: str,
reasoning_enabled: bool,
test_connectivity: bool,
max_tokens: int,
temperature: float,
@@ -199,32 +171,21 @@ class LMStudioConnect(io.ComfyNode):
base_url=f"{normalized_server_url}/v1",
api_key=normalize_api_key(api_token),
model=model_name,
thinking=thinking,
reasoning_enabled=reasoning_enabled,
max_tokens=max_tokens,
temperature=temperature,
timeout_seconds=timeout_seconds,
use_tooling_mcp=use_tooling_mcp,
)
think_mode = resolve_thinking_mode(thinking, model_name)
think_label = {
THINK_MODE_FORCE: "on",
THINK_MODE_SUPPRESS: "off",
THINK_MODE_DEFAULT: "model default",
}[think_mode]
thinking_note = f" Thinking: {thinking} ({think_label})."
model_count = len(models)
if should_probe_models:
status = (
f"Connected to {normalized_server_url}. "
f"Found {model_count} model(s). Using '{model_name}'.{thinking_note}"
f"Found {model_count} model(s). Using '{model_name}'."
)
else:
status = (
f"Connection prepared for {normalized_server_url}. "
f"Using '{model_name}'.{thinking_note}"
)
status = f"Connection prepared for {normalized_server_url}. Using '{model_name}'."
return io.NodeOutput(
payload,
+8 -28
View File
@@ -5,17 +5,13 @@ from typing import Any
from comfy_api.latest import io, ui
from .client import (
THINK_MODE_FORCE,
apply_thinking_prefill,
build_chat_messages_with_image,
comfy_image_to_base64_png_url,
create_openai_client,
extract_chat_completion_text,
extract_responses_text,
resolve_request_seed,
resolve_thinking_mode,
strip_think_content,
thinking_chat_template_kwargs,
)
from .iotypes import ParamConnection
from .models import LMStudioConnectionPayload
@@ -115,14 +111,8 @@ class LMStudioImageToText(io.ComfyNode):
}
if system_prompt.strip():
kwargs["instructions"] = system_prompt
mode = resolve_thinking_mode(connection.thinking, connection.model)
if mode == THINK_MODE_FORCE:
if connection.reasoning_enabled:
kwargs["reasoning"] = {"effort": "medium"}
template_kwargs = thinking_chat_template_kwargs(mode)
if template_kwargs is not None:
kwargs["extra_body"] = {"chat_template_kwargs": template_kwargs}
if connection.use_tooling_mcp:
kwargs["metadata"] = {"lmstudio_tooling_mcp_requested": "true"}
@@ -174,28 +164,18 @@ class LMStudioImageToText(io.ComfyNode):
first_image = image[0] if is_batch else image
image_data_url = comfy_image_to_base64_png_url(first_image)
mode = resolve_thinking_mode(connection.thinking, connection.model)
messages = apply_thinking_prefill(
build_chat_messages_with_image(
completion = client.chat.completions.create(
model=connection.model,
messages=build_chat_messages_with_image(
system_prompt=system_prompt,
user_prompt=user_prompt,
image_data_url=image_data_url,
),
mode,
seed=resolved_seed,
temperature=connection.temperature,
max_tokens=connection.max_tokens,
n=1,
)
completion_kwargs: dict[str, Any] = {
"model": connection.model,
"messages": messages,
"seed": resolved_seed,
"temperature": connection.temperature,
"max_tokens": connection.max_tokens,
"n": 1,
}
template_kwargs = thinking_chat_template_kwargs(mode)
if template_kwargs is not None:
completion_kwargs["extra_body"] = {"chat_template_kwargs": template_kwargs}
completion = client.chat.completions.create(**completion_kwargs)
text = extract_chat_completion_text(completion).strip()
if not text:
raise ValueError("chat.completions fallback returned no text output")
+1 -1
View File
@@ -7,7 +7,7 @@ class LMStudioConnectionPayload:
base_url: str
api_key: str
model: str
thinking: str
reasoning_enabled: bool
max_tokens: int
temperature: float
timeout_seconds: int
-1
View File
@@ -102,7 +102,6 @@ def _install_comfy_stubs() -> None:
Int=_FieldType,
Float=_FieldType,
Image=_FieldType,
Combo=_FieldType,
Custom=_custom_type,
)
ui_module = types.SimpleNamespace(
+4 -4
View File
@@ -349,7 +349,7 @@ def test_execute_discovers_model_when_empty_and_connectivity_disabled(
server_url="http://127.0.0.1:1234",
api_token="-",
model="",
thinking="auto",
reasoning_enabled=False,
test_connectivity=False, # disabled, but model is blank → must still probe
max_tokens=128,
temperature=0.5,
@@ -379,7 +379,7 @@ def test_execute_status_no_probe_does_not_say_found_models(
server_url="http://127.0.0.1:1234",
api_token="-",
model="chosen-model",
thinking="auto",
reasoning_enabled=False,
test_connectivity=False,
max_tokens=128,
temperature=0.5,
@@ -410,7 +410,7 @@ def test_execute_status_with_probe_includes_model_count(
server_url="http://127.0.0.1:1234",
api_token="-",
model="x",
thinking="auto",
reasoning_enabled=False,
test_connectivity=True,
max_tokens=128,
temperature=0.5,
@@ -437,7 +437,7 @@ def _connection_payload(**overrides):
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model="model-a",
thinking="auto",
reasoning_enabled=False,
max_tokens=111,
temperature=0.4,
timeout_seconds=30,
-15
View File
@@ -64,18 +64,3 @@ def test_strip_think_content_removes_dangling_start_tag() -> None:
# strip() removes the trailing real newline left after the dangling block is excised.
text = "Visible output\n<think>unfinished"
assert client.strip_think_content(text) == "Visible output"
def test_strip_think_content_nested_think_tags_no_orphan_close() -> None:
# When a think block contains nested <think> tags (e.g. a model quoting the tag
# in its own reasoning), the non-greedy regex matches up to the *first* </think>,
# which would leave the outer closing tag as an orphan in the visible output.
# The fix strips any remaining </think> tags after the block/dangling passes.
assert client.strip_think_content("<think><think>nested</think></think>") == ""
# Regression: visible text surrounding nested blocks must be preserved intact.
result = client.strip_think_content(
"Before <think>reasoning <think>deep</think></think> after"
)
assert "</think>" not in result
assert "Before" in result
assert "after" in result
+7 -15
View File
@@ -17,9 +17,9 @@ def test_define_schema_has_expected_order_and_defaults() -> None:
input_ids = [entry.id for entry in schema.inputs]
assert input_ids == [
"server_url",
"model",
"thinking",
"api_token",
"model",
"reasoning_enabled",
"use_tooling_mcp",
"max_tokens",
"temperature",
@@ -30,15 +30,9 @@ def test_define_schema_has_expected_order_and_defaults() -> None:
by_id = {entry.id: entry for entry in schema.inputs}
assert by_id["server_url"].default == "http://127.0.0.1:1234"
assert by_id["api_token"].default == "-"
assert by_id["model"].default == "<refresh models>"
assert by_id["model"].options == ["<refresh models>"]
assert by_id["thinking"].default == "auto"
assert by_id["thinking"].options == ["auto", "on", "off"]
assert by_id["max_tokens"].default == 1024
assert by_id["temperature"].default == 0.7
assert by_id["timeout_seconds"].default == 600
assert by_id["api_token"].advanced is True
assert by_id["use_tooling_mcp"].advanced is True
assert by_id["max_tokens"].advanced is True
assert by_id["temperature"].advanced is True
assert by_id["timeout_seconds"].advanced is True
@@ -50,8 +44,6 @@ def test_validate_inputs() -> None:
cls = connect_node.LMStudioConnect
assert cls.validate_inputs("http://127.0.0.1:1234", 10, 128) is True
# `model` is accepted (and ignored) so ComfyUI skips its combo validation.
assert cls.validate_inputs("http://127.0.0.1:1234", 10, 128, model="any-model") is True
assert "http://" in str(cls.validate_inputs("localhost:1234", 10, 128))
assert cls.validate_inputs("http://127.0.0.1:1234", 0, 128) == "timeout_seconds must be >= 1"
assert cls.validate_inputs("http://127.0.0.1:1234", 10, 0) == "max_tokens must be >= 1"
@@ -67,7 +59,7 @@ def test_execute_with_placeholder_model_uses_discovered_default(
server_url=" http://10.0.0.1:1234/v1/ ",
api_token="-",
model="<refresh models>",
thinking="on",
reasoning_enabled=True,
test_connectivity=True,
max_tokens=321,
temperature=0.6,
@@ -80,7 +72,7 @@ def test_execute_with_placeholder_model_uses_discovered_default(
assert payload.server_url == "http://10.0.0.1:1234"
assert payload.base_url == "http://10.0.0.1:1234/v1"
assert payload.model == "m1"
assert payload.thinking == "on"
assert payload.reasoning_enabled is True
assert payload.use_tooling_mcp is True
assert payload.max_tokens == 321
assert payload.temperature == 0.6
@@ -101,7 +93,7 @@ def test_execute_without_probe_keeps_selected_model(monkeypatch: pytest.MonkeyPa
server_url="http://127.0.0.1:1234",
api_token="token",
model="manually-selected-model",
thinking="auto",
reasoning_enabled=False,
test_connectivity=False,
max_tokens=128,
temperature=0.5,
@@ -126,7 +118,7 @@ def test_execute_raises_when_selected_model_not_on_server(
server_url="http://127.0.0.1:1234",
api_token="-",
model="requested-model",
thinking="auto",
reasoning_enabled=False,
test_connectivity=True,
max_tokens=128,
temperature=0.5,
@@ -146,7 +138,7 @@ def test_execute_raises_when_no_model_selected_and_none_discovered(
server_url="http://127.0.0.1:1234",
api_token="-",
model="<refresh models>",
thinking="auto",
reasoning_enabled=False,
test_connectivity=True,
max_tokens=128,
temperature=0.5,
+2 -2
View File
@@ -145,14 +145,14 @@ def _image_module():
return import_repo_module("image_to_text_node", force_reload=True)
def _connection_payload(*, thinking: str = "auto", use_tooling_mcp: bool = False):
def _connection_payload(*, reasoning_enabled: bool = False, use_tooling_mcp: bool = False):
models = import_repo_module("models", force_reload=True)
return models.LMStudioConnectionPayload(
server_url="http://127.0.0.1:1234",
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model="vision-model",
thinking=thinking,
reasoning_enabled=reasoning_enabled,
max_tokens=128,
temperature=0.3,
timeout_seconds=30,
+2 -2
View File
@@ -54,7 +54,7 @@ def _connection_payload(model: str = "vision-model"):
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model=model,
thinking="auto",
reasoning_enabled=False,
max_tokens=128,
temperature=0.3,
timeout_seconds=30,
@@ -306,7 +306,7 @@ def _text_connection_payload(model: str = "text-model"):
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model=model,
thinking="auto",
reasoning_enabled=False,
max_tokens=256,
temperature=0.7,
timeout_seconds=30,
+5 -15
View File
@@ -13,14 +13,14 @@ def _image_module():
return import_repo_module("image_to_text_node", force_reload=True)
def _connection_payload(*, thinking: str = "auto", use_tooling_mcp: bool = False):
def _connection_payload(*, reasoning_enabled: bool = False, use_tooling_mcp: bool = False):
models = import_repo_module("models", force_reload=True)
return models.LMStudioConnectionPayload(
server_url="http://127.0.0.1:1234",
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model="vision-model",
thinking=thinking,
reasoning_enabled=reasoning_enabled,
max_tokens=128,
temperature=0.3,
timeout_seconds=30,
@@ -73,7 +73,7 @@ def test_responses_kwargs_single_and_batch() -> None:
assert single_content[1]["type"] == "input_image"
kwargs_batch = image_node.LMStudioImageToText._responses_kwargs(
connection=_connection_payload(thinking="on", use_tooling_mcp=True),
connection=_connection_payload(reasoning_enabled=True, use_tooling_mcp=True),
image=_batch_image(),
system_prompt="sys",
user_prompt="describe",
@@ -82,7 +82,6 @@ def test_responses_kwargs_single_and_batch() -> None:
batch_content = kwargs_batch["input"][0]["content"]
assert len(batch_content) == 3
assert kwargs_batch["reasoning"] == {"effort": "medium"}
assert kwargs_batch["extra_body"] == {"chat_template_kwargs": {"enable_thinking": True}}
assert kwargs_batch["metadata"] == {"lmstudio_tooling_mcp_requested": "true"}
@@ -140,22 +139,15 @@ def test_execute_chat_fallback_reports_dropped_frames(
completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="fallback vision result"))]
)
captured_kwargs: dict[str, object] = {}
def create_completion(**kwargs):
captured_kwargs.update(kwargs)
return completion
fake_client = SimpleNamespace(
responses=SimpleNamespace(create=raise_responses),
chat=SimpleNamespace(completions=SimpleNamespace(create=create_completion)),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: completion)),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 99)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
# thinking="off" exercises the suppression prefill + enable_thinking:false path.
output = image_node.LMStudioImageToText.execute(
connection=_connection_payload(thinking="off"),
connection=_connection_payload(),
image=_batch_image(),
system_prompt="sys",
user_prompt="describe",
@@ -166,8 +158,6 @@ def test_execute_chat_fallback_reports_dropped_frames(
assert "via chat.completions" in output.ui.text
assert "responses unavailable" in output.ui.text
assert "additional frame(s) were dropped" in output.ui.text
assert captured_kwargs["messages"][-1] == {"role": "assistant", "content": "<think></think>"}
assert captured_kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
def test_execute_raises_when_all_text_is_think_blocks(
+1 -1
View File
@@ -14,7 +14,7 @@ def test_connection_payload_is_frozen_dataclass() -> None:
base_url="http://127.0.0.1:1234/v1",
api_key="-",
model="m1",
thinking="auto",
reasoning_enabled=False,
max_tokens=64,
temperature=0.5,
timeout_seconds=10,
+8 -48
View File
@@ -11,18 +11,18 @@ def _text_module():
return import_repo_module("text_gen_node", force_reload=True)
def _connection_payload(*, thinking: str = "auto", use_tooling_mcp: bool = False, model: str = "model-a"):
def _connection_payload():
models = import_repo_module("models", force_reload=True)
return models.LMStudioConnectionPayload(
server_url="http://127.0.0.1:1234",
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model=model,
thinking=thinking,
model="model-a",
reasoning_enabled=False,
max_tokens=111,
temperature=0.4,
timeout_seconds=30,
use_tooling_mcp=use_tooling_mcp,
use_tooling_mcp=False,
)
@@ -41,8 +41,8 @@ def test_define_schema_and_validate_inputs() -> None:
def test_responses_kwargs_include_optional_fields() -> None:
text_gen_node = _text_module()
# thinking="on" forces reasoning on (effort + enable_thinking:true kwarg).
connection = _connection_payload(thinking="on", use_tooling_mcp=True)
connection = _connection_payload()
connection = connection.__class__(**{**connection.__dict__, "reasoning_enabled": True, "use_tooling_mcp": True})
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
connection=connection,
@@ -54,27 +54,11 @@ def test_responses_kwargs_include_optional_fields() -> None:
assert kwargs["seed"] == 12
assert kwargs["instructions"] == "sys"
assert kwargs["reasoning"] == {"effort": "medium"}
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": True}}
assert kwargs["metadata"] == {"lmstudio_tooling_mcp_requested": "true"}
def test_responses_kwargs_suppress_thinking_when_off() -> None:
text_gen_node = _text_module()
# thinking="off" suppresses reasoning: enable_thinking:false, no reasoning effort.
connection = _connection_payload(thinking="off")
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
connection=connection,
system_prompt="sys",
user_prompt="user",
seed=5,
)
assert "reasoning" not in kwargs
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
def test_responses_kwargs_omit_optional_fields_when_disabled() -> None:
text_gen_node = _text_module()
# "auto" on a non-Gemma model defers to the template: nothing sent.
connection = _connection_payload()
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
connection=connection,
@@ -84,24 +68,9 @@ def test_responses_kwargs_omit_optional_fields_when_disabled() -> None:
)
assert "instructions" not in kwargs
assert "reasoning" not in kwargs
assert "extra_body" not in kwargs
assert "metadata" not in kwargs
def test_responses_kwargs_auto_forces_thinking_for_gemma() -> None:
text_gen_node = _text_module()
# "auto" on a Gemma model forces thinking on (Gemma stays silent otherwise).
connection = _connection_payload(thinking="auto", model="google/gemma-3-12b")
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
connection=connection,
system_prompt="sys",
user_prompt="user",
seed=7,
)
assert kwargs["reasoning"] == {"effort": "medium"}
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": True}}
def test_execute_rejects_empty_user_prompt() -> None:
text_gen_node = _text_module()
with pytest.raises(ValueError, match="user_prompt must not be empty"):
@@ -150,28 +119,21 @@ def test_execute_uses_responses_endpoint_and_strips_think(
def test_execute_falls_back_to_chat_completions(monkeypatch: pytest.MonkeyPatch) -> None:
text_gen_node = _text_module()
captured_kwargs: dict[str, object] = {}
def raise_responses(**kwargs):
raise RuntimeError("responses disabled")
completion = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Fallback answer"))])
def create_completion(**kwargs):
captured_kwargs.update(kwargs)
return completion
fake_client = SimpleNamespace(
responses=SimpleNamespace(create=raise_responses),
chat=SimpleNamespace(completions=SimpleNamespace(create=create_completion)),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: completion)),
)
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 99)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
# thinking="off" exercises the suppression prefill + enable_thinking:false path.
output = text_gen_node.LMStudioTextGen.execute(
connection=_connection_payload(thinking="off"),
connection=_connection_payload(),
system_prompt="sys",
user_prompt="hello",
seed=0,
@@ -180,8 +142,6 @@ def test_execute_falls_back_to_chat_completions(monkeypatch: pytest.MonkeyPatch)
assert output[0] == "Fallback answer"
assert "via chat.completions" in output.ui.text
assert "responses disabled" in output.ui.text
assert captured_kwargs["messages"][-1] == {"role": "assistant", "content": "<think></think>"}
assert captured_kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
def test_execute_raises_if_both_endpoints_return_no_text(
-72
View File
@@ -1,72 +0,0 @@
from __future__ import annotations
import pytest
from helpers.imports import import_repo_module
def _client():
return import_repo_module("client", force_reload=True)
@pytest.mark.parametrize(
"model, expected",
[
("google/gemma-3-12b", True),
("Gemma2-9B", True),
("gemma", True),
("qwen3-8b", False),
("llama-3.1-8b", False),
("", False),
(None, False),
],
)
def test_model_thinks_off_by_default(model, expected) -> None:
client = _client()
assert client.model_thinks_off_by_default(model) is expected
@pytest.mark.parametrize(
"thinking, model, expected",
[
# Explicit off/on always win regardless of family.
("off", "qwen3-8b", "suppress"),
("off", "gemma-3", "suppress"),
("on", "gemma-3", "force"),
("on", "qwen3-8b", "force"),
# auto defers for thinking-by-default families, forces Gemma.
("auto", "qwen3-8b", "default"),
("auto", "google/gemma-3-12b", "force"),
# Normalization: case/whitespace and unknown/blank fall back to auto.
(" OFF ", "qwen", "suppress"),
("", "qwen", "default"),
(None, "gemma-3", "force"),
],
)
def test_resolve_thinking_mode(thinking, model, expected) -> None:
client = _client()
assert client.resolve_thinking_mode(thinking, model) == expected
def test_thinking_chat_template_kwargs() -> None:
client = _client()
assert client.thinking_chat_template_kwargs(client.THINK_MODE_SUPPRESS) == {
"enable_thinking": False
}
assert client.thinking_chat_template_kwargs(client.THINK_MODE_FORCE) == {
"enable_thinking": True
}
assert client.thinking_chat_template_kwargs(client.THINK_MODE_DEFAULT) is None
def test_apply_thinking_prefill_only_on_suppress() -> None:
client = _client()
messages = [{"role": "user", "content": "hi"}]
suppressed = client.apply_thinking_prefill(messages, client.THINK_MODE_SUPPRESS)
assert suppressed[-1] == {"role": "assistant", "content": "<think></think>"}
# Original list is not mutated.
assert messages == [{"role": "user", "content": "hi"}]
for mode in (client.THINK_MODE_FORCE, client.THINK_MODE_DEFAULT):
assert client.apply_thinking_prefill(messages, mode) == messages
+8 -26
View File
@@ -5,17 +5,13 @@ from typing import Any
from comfy_api.latest import io, ui
from .client import (
THINK_MODE_FORCE,
apply_thinking_prefill,
build_chat_messages,
build_responses_input_text,
create_openai_client,
extract_chat_completion_text,
extract_responses_text,
resolve_request_seed,
resolve_thinking_mode,
strip_think_content,
thinking_chat_template_kwargs,
)
from .iotypes import ParamConnection
from .models import LMStudioConnectionPayload
@@ -91,13 +87,8 @@ class LMStudioTextGen(io.ComfyNode):
}
if system_prompt.strip():
kwargs["instructions"] = system_prompt
mode = resolve_thinking_mode(connection.thinking, connection.model)
if mode == THINK_MODE_FORCE:
if connection.reasoning_enabled:
kwargs["reasoning"] = {"effort": "medium"}
template_kwargs = thinking_chat_template_kwargs(mode)
if template_kwargs is not None:
kwargs["extra_body"] = {"chat_template_kwargs": template_kwargs}
# MCP/tooling requires additional tool definitions and server details.
# Keep an explicit metadata signal for future extension without sending invalid tool payloads.
@@ -145,23 +136,14 @@ class LMStudioTextGen(io.ComfyNode):
via_endpoint = "chat.completions"
fallback_reason = str(responses_error)
mode = resolve_thinking_mode(connection.thinking, connection.model)
messages = apply_thinking_prefill(
build_chat_messages(system_prompt, user_prompt), mode
completion = client.chat.completions.create(
model=connection.model,
messages=build_chat_messages(system_prompt, user_prompt),
seed=resolved_seed,
temperature=connection.temperature,
max_tokens=connection.max_tokens,
n=1,
)
completion_kwargs: dict[str, Any] = {
"model": connection.model,
"messages": messages,
"seed": resolved_seed,
"temperature": connection.temperature,
"max_tokens": connection.max_tokens,
"n": 1,
}
template_kwargs = thinking_chat_template_kwargs(mode)
if template_kwargs is not None:
completion_kwargs["extra_body"] = {"chat_template_kwargs": template_kwargs}
completion = client.chat.completions.create(**completion_kwargs)
text = extract_chat_completion_text(completion).strip()
if not text:
raise ValueError("chat.completions fallback returned no text output")
+115 -86
View File
@@ -30,12 +30,6 @@ function getWidget(node, name) {
return node.widgets?.find((widget) => widget?.name === name);
}
function getModelWidget(node) {
// The backend now ships `model` as a native combo, so there is exactly one
// model widget — no injected duplicate. Match by id first, display name second.
return getWidget(node, "model") || getWidget(node, "Model");
}
function normalizeModelList(models) {
if (!Array.isArray(models)) {
return [];
@@ -53,46 +47,68 @@ function normalizeModelList(models) {
return out;
}
// Keep the currently-selected model visible even before a refresh: a workflow
// reloaded from disk restores the saved value, but the combo only knows the
// placeholder until the server is queried again.
function ensureSelectionInOptions(node) {
const widget = getModelWidget(node);
if (!widget?.options) {
return;
function ensureModelDropdown(node) {
if (node.__lmstudioModelDropdown && node.__lmstudioModelTextWidget) {
return {
comboWidget: node.__lmstudioModelDropdown,
textWidget: node.__lmstudioModelTextWidget,
};
}
const current = String(widget.value ?? "").trim();
const options = normalizeModelList(widget.options.values || []);
if (options.length === 0) {
options.push(MODEL_PLACEHOLDER);
const modelTextWidget = getWidget(node, "model");
if (!modelTextWidget) {
return null;
}
if (current && current !== MODEL_PLACEHOLDER && !options.includes(current)) {
options.unshift(current);
modelTextWidget.hidden = true;
const currentModel = String(modelTextWidget.value ?? "").trim();
const initialValues = currentModel && currentModel !== MODEL_PLACEHOLDER
? [currentModel]
: [MODEL_PLACEHOLDER];
const comboWidget = node.addWidget(
"combo",
"Model",
initialValues[0],
(value) => {
modelTextWidget.value = String(value ?? "").trim();
},
{ values: initialValues }
);
// Keep visible ordering aligned with backend schema: insert combo at the original model slot position.
const modelIndex = node.widgets?.indexOf(modelTextWidget) ?? -1;
const comboIndex = node.widgets?.indexOf(comboWidget) ?? -1;
if (modelIndex >= 0 && comboIndex >= 0 && comboIndex !== modelIndex) {
node.widgets.splice(comboIndex, 1);
node.widgets.splice(modelIndex, 0, comboWidget);
}
widget.options.values = options;
node.__lmstudioModelDropdown = comboWidget;
node.__lmstudioModelTextWidget = modelTextWidget;
return { comboWidget, textWidget: modelTextWidget };
}
function applyModelOptions(node, models) {
const widget = getModelWidget(node);
if (!widget?.options) {
function syncDropdownFromText(node) {
const refs = ensureModelDropdown(node);
if (!refs) {
return;
}
const current = String(widget.value ?? "").trim();
let nextValues = normalizeModelList(models);
if (current && current !== MODEL_PLACEHOLDER && !nextValues.includes(current)) {
nextValues.unshift(current);
}
if (nextValues.length === 0) {
nextValues = [MODEL_PLACEHOLDER];
const { comboWidget, textWidget } = refs;
const currentModel = String(textWidget.value ?? "").trim();
const options = normalizeModelList(comboWidget?.options?.values || []);
if (!currentModel) {
return;
}
widget.options.values = nextValues;
widget.value = current && nextValues.includes(current) ? current : nextValues[0];
widget.callback?.(widget.value);
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas?.(true, true);
if (!options.includes(currentModel)) {
options.unshift(currentModel);
comboWidget.options.values = options;
}
comboWidget.value = currentModel;
}
function buildQuery(node) {
@@ -131,37 +147,46 @@ async function fetchJson(path) {
return payload;
}
function notify(severity, message) {
const summary = severity === "error" ? "LMStudio — Error" : "LMStudio";
if (typeof app.extensionManager?.toast?.add === "function") {
app.extensionManager.toast.add({
severity,
summary,
detail: message,
life: severity === "error" ? 6000 : 3500,
});
} else if (severity === "error") {
console.error(`[LMStudio] ${message}`);
} else {
console.info(`[LMStudio] ${message}`);
function applyModelOptions(node, models) {
const refs = ensureModelDropdown(node);
if (!refs) {
return;
}
const { comboWidget, textWidget } = refs;
const currentModel = String(textWidget.value ?? "").trim();
let nextValues = normalizeModelList(models);
if (currentModel && currentModel !== MODEL_PLACEHOLDER && !nextValues.includes(currentModel)) {
nextValues.unshift(currentModel);
}
if (nextValues.length === 0) {
nextValues = [MODEL_PLACEHOLDER];
}
comboWidget.options.values = nextValues;
const selected = currentModel && nextValues.includes(currentModel)
? currentModel
: nextValues[0];
comboWidget.value = selected;
textWidget.value = selected;
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas?.(true, true);
}
// Run an async button handler with a "…" progress label and unified error toasts,
// so the user gets feedback on the node instead of a blocking window.alert.
async function runWithButtonFeedback(button, busyLabel, action, failPrefix) {
const originalLabel = button.name;
button.name = busyLabel;
button.disabled = true;
try {
await action();
} catch (error) {
const message = error?.message || String(error);
console.error(`[LMStudio] ${failPrefix}`, error);
notify("error", `${failPrefix}: ${message}`);
} finally {
button.name = originalLabel;
button.disabled = false;
function notifySuccess(message) {
if (typeof app.extensionManager?.toast?.add === "function") {
app.extensionManager.toast.add({
severity: "success",
summary: "LMStudio",
detail: message,
life: 3500,
});
} else {
console.info(`[LMStudio] ${message}`);
}
}
@@ -242,14 +267,13 @@ function attachButtons(node) {
return;
}
ensureSelectionInOptions(node);
ensureModelDropdown(node);
syncDropdownFromText(node);
const refreshModels = async () => {
const query = buildQuery(node);
const payload = await fetchJson(`/lmstudio/models?${query.toString()}`);
const models = payload.models || [];
applyModelOptions(node, models);
notify("success", `Loaded ${models.length} model(s). Pick one from the Model dropdown.`);
applyModelOptions(node, payload.models || []);
};
const testConnection = async () => {
@@ -257,26 +281,30 @@ function attachButtons(node) {
const payload = await fetchJson(`/lmstudio/test?${query.toString()}`);
const models = payload.models || [];
applyModelOptions(node, models);
notify("success", payload.message || `Connected. ${models.length} model(s) available.`);
const message = payload.message || `Connected. ${models.length} model(s) available.`;
notifySuccess(message);
};
const refreshButton = node.addWidget("button", "🔄 Refresh Models", null, () =>
runWithButtonFeedback(
refreshButton,
"Refreshing…",
refreshModels,
"Model refresh failed"
)
);
node.addWidget("button", "Refresh Models", null, async () => {
try {
await refreshModels();
} catch (error) {
const message = error?.message || String(error);
console.error("[LMStudio] Model refresh failed", error);
window.alert(`Model refresh failed: ${message}`);
}
});
const testButton = node.addWidget("button", "🔌 Test Connection", null, () =>
runWithButtonFeedback(
testButton,
"Testing…",
testConnection,
"Connectivity test failed"
)
);
node.addWidget("button", "Test Connection", null, async () => {
try {
await testConnection();
} catch (error) {
const message = error?.message || String(error);
console.error("[LMStudio] Connectivity test failed", error);
window.alert(`Connectivity test failed: ${message}`);
}
});
node.__lmstudioButtonsAttached = true;
}
@@ -294,7 +322,8 @@ app.registerExtension({
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
ensureSelectionInOptions(this);
ensureModelDropdown(this);
syncDropdownFromText(this);
};
}