Compare commits

...
Author SHA1 Message Date
Gérald LONLASandClaude Opus 5.5 4d50a97925 fix(network): explain macOS Local Network denials and ship a LAN forwarder (#20)
On macOS, an app without Local Network permission gets "[Errno 65] No route
to host" for every host on the local network, surfaced by the OpenAI client as
a bare "Connection error." Some launchers (Comfy Desktop 1.1.3) never request
the permission, so it cannot be granted in System Settings at all.

- network.py: detect the denial (darwin + EHOSTUNREACH anywhere in the
  exception chain + a LAN target) and raise LocalNetworkBlockedError with a
  fix built from the user's own server_url. Wired into Connect, the
  /lmstudio/test and /lmstudio/models routes, Text Gen and Image To Text.
  Other errors and platforms are untouched.
- scripts/lan_forward.py: stdlib-only relay from 127.0.0.1 (loopback is
  exempt) to the LM Studio host. --target is required; no built-in address.
  Probes the target on startup and explains failures.
- README: troubleshooting section. launchd agents are blocked the same way,
  so the start-at-login recipe is a .command file opened by Terminal.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:10:42 +08:00
Gérald LONLASandClaude Opus 4.8 873343667e feat(connect): tri-state thinking control, native model dropdown, UI rework (#19)
Replace the binary "Enable Reasoning" toggle with a tri-state
Thinking / Reasoning control (auto / on / off) that drives LM Studio's
template-level reasoning switch via chat_template_kwargs.enable_thinking
(nested — a top-level field never reaches the Jinja template):

- off  -> suppress: enable_thinking:false + a closed <think></think>
          assistant prefill, so a reasoning model answers directly instead
          of burning its whole budget on a hidden think trace.
- on   -> force: enable_thinking:true.
- auto -> force for families that stay silent by default (Gemma), otherwise
          send nothing (Qwen3-style templates already think by default).

Applied to both the responses and chat.completions paths in the text and
image nodes, so reasoning is controlled consistently regardless of which
endpoint the server supports.

Fix the duplicate "Model" field: the web extension used to inject a second
combo widget and hide the backend string widget (unreliably), leaving two
model fields on screen. The model input is now a native io.Combo populated
in place by Refresh Models; `model` is listed in validate_inputs so ComfyUI
skips its "value not in list" check (the real options are only known at
runtime).

Rework the Connect node UI: essentials first (Server URL -> Model ->
Thinking), secondary settings moved to advanced, buttons show progress
labels and toast feedback instead of a blocking alert, and the status line
reports the resolved thinking mode.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:05:26 +08:00
Gérald LONLASandClaude Opus 4.8 9245fcc4bb test(contracts): add image-encoding and node status behavioral contracts (#17)
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:30:56 +08:00
Gérald LONLASandClaude Opus 4.8 f6a5cc1b02 fix(client): strip orphan </think> tags left by nested think blocks (#18)
The non-greedy `<think>.*?</think>` regex removes only up to the first
closing tag. When a model nests `<think>` inside its own reasoning (e.g.
`<think><think>inner</think></think>`), the outer `</think>` was never
consumed, causing it to appear verbatim in the visible output returned to
the caller.

Added a third pass (`_THINK_CLOSE_RE`) that removes any `</think>` tags
still present after the block and dangling-open passes; such tags can only
be orphaned closing tags at that point.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:30:48 +08:00
Gérald LONLASandClaude Opus 4.8 1ccce73904 test(contracts): add behavioral-contract tests that catch value-mutant regressions (#15)
25 new tests across client.py, routes.py, connect_node.py, and text_gen_node.py.
Each test targets a specific behavioral contract that 100% line+branch coverage
did not verify and that a realistic mutation would silently break:

- normalize_server_url: whitespace-only input, trailing-slash stripping,
  https acceptance
- coerce_seed: whitespace-only string returns None (not ValueError)
- resolve_request_seed: seed=0 passes through; large values not clipped
- build_responses_input_text: 'type' must be 'input_text' (not 'text')
- build_chat_messages: whitespace system prompt omitted; empty user included
- list_models: result is sorted AND deduplicated (both invariants checked)
- strip_think_content: surrounding text order preserved; block-only → ""
- _query_int: negative integers rejected; empty string uses default
- _models_handler: empty models → default_model="" not IndexError;
  error prefix is 'Failed to list models' not 'Connection test failed'
- _test_handler: model_count matches actual list length; distinct error prefix
- connect_node execute: empty model with test_connectivity=False still probes;
  no-probe status omits 'Found N model(s)'; probe status includes exact count
- _responses_kwargs: 'input' key present with user-role and correct prompt text;
  temperature/max_tokens forwarded from payload
- text_gen_node execute: fallback path status includes resolved seed;
  user_prompt=None raises clean ValueError

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:22:32 +08:00
Gérald LONLASandClaude Opus 4.8 4fc78624c4 fix(nodes): raise error when response is entirely think-block content (#16)
When a reasoning model returns a response consisting solely of
<think>...</think> content, the emptiness guard fired before
strip_think_content ran, so the node silently returned an empty
string rather than signalling an error. Add a post-strip check
in both LMStudioTextGen and LMStudioImageToText that raises
ValueError if strip_think_content reduces the text to nothing.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:21:55 +08:00
Gérald LONLASandClaude Opus 4.8 0d05296bc0 fix(client): strip /v1 suffix case-insensitively in normalize_server_url (#12)
`normalize_server_url` used a case-sensitive `endswith("/v1")` check, so
user-supplied URLs like `https://host/V1` passed through intact. The
downstream `to_openai_base_url` then appended another `/v1`, producing the
double-suffix broken URL `https://host/V1/v1`, causing every API call to
fail with a 404.

Fix: compare against `value.lower()` so any casing of the suffix is stripped.
Adds a targeted regression test covering lowercase, uppercase, and mixed-case
inputs as well as the end-to-end `to_openai_base_url` output.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:14:19 +08:00
Gérald LONLASandClaude Opus 4.8 25d7309fa7 test(client): cover all defensive branches in extract_responses_text (#14)
Add four targeted tests hitting the previously uncovered branches in
`client.extract_responses_text` (lines 221–233): dict items with a
non-list content field, content list entries that are not dicts, dict
entries whose text value is not a str, and object items where
`getattr(entry, "text")` is non-str.

Also declare `aiohttp` in the optional [test] extra so the full suite
can import `routes.py` without a ModuleNotFoundError.

Branch coverage: 99% (4 branches missed) → 100% (146/146 branches).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:13:45 +08:00
Gérald LONLASandClaude Opus 4.8 fa252dbac6 chore(tests): remove unused _batch_image helper from test_coverage_gaps.py (#13)
_batch_image() was defined in tests/test_coverage_gaps.py but never called
in that file — an identical helper already exists in test_image_to_text_node.py
where it is actively used. Dead code with no callers; safe to remove.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:13:41 +08:00
Gérald LONLASandClaude Opus 4.8 0e42058f20 chore: remove unused dump_openai_response and dead test helpers (#10)
- Delete client.dump_openai_response (and its import json): never called
  from any production path (connect_node, text_gen_node, image_to_text_node,
  routes, __init__); existed only as a debugging aid.
- Delete its test (test_dump_openai_response_branches in test_client_full.py)
  and the now-orphaned `import json` in that file.
- Delete make_response_with_output_text / make_response_with_output_items
  from tests/helpers/fakes.py: defined in the initial test scaffolding
  commit but never imported or referenced by any test module.

All 68 tests pass; branch coverage holds at 96.93 % (> 90 % required).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:04:33 +08:00
Gérald LONLASandClaude Opus 4.8 9d8e262304 test(coverage): close remaining branch gaps in client and image node (#9)
Add 12 focused unit tests targeting uncovered branches identified by
pytest-cov on main (97.01% → 99.30%):

client.py
- coerce_seed: .item() raises at runtime → falls through to unsupported-type error (lines 117-118, 142)
- coerce_seed: plain unsupported types (dict, object) raise ValueError (line 142)
- extract_chat_completion_text: non-str/non-list content returns "" (line 207)
- extract_chat_completion_text: list items with no usable text are skipped (lines 222-228)
- extract_responses_text: output list with unresolvable items returns "" (line 234)
- extract_responses_text: output=None returns "" (end of function)
- build_chat_messages_with_image: blank/None system_prompt omits the system message (line 291)

image_to_text_node.py
- _responses_kwargs: empty/whitespace system_prompt omits "instructions" key (line 112)
- execute: responses endpoint returns empty string → falls back to chat.completions (line 157)
- execute: both endpoints return empty text → ValueError propagates (line 181)
- execute: single-image chat fallback status does not mention dropped frames (line 183)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:00:30 +08:00
Gérald LONLASandClaude Opus 4.8 73ed6d7f38 fix(client): avoid duplicate text when output item has both content and text (#11)
In `extract_responses_text`, the loop over non-dict output items extracted
text from `item.content[].text` AND unconditionally also from `item.text`.
When an item carried both attributes (e.g. an OpenAI Responses API message
object that exposes `.content` as a list and also a convenience `.text`
shorthand), the same content was appended twice, producing a response like
`"the-answer\nthe-answer"` instead of `"the-answer"`.

The dict-item branch already used `continue` to prevent this; mirror that
guard in the object branch by making `item.text` extraction an `else` clause
that only runs when `item.content` is NOT a list.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:00:00 +08:00
25 changed files with 2798 additions and 235 deletions
+52 -5
View File
@@ -8,11 +8,15 @@ Small ComfyUI custom nodes to connect to a remote LMStudio server through OpenAI
- **LMStudio - Connect**
- Connect to a remote LMStudio URL
- Set API token
- Pull/load model list automatically (Refresh Models)
- Test connectivity to server
- Supports LM **Reasonning** mode toggle
- Supports Tooling / MCP toggle
- 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
- **LMStudio - Text Gen**
- System prompt + user prompt
@@ -33,6 +37,49 @@ Small ComfyUI custom nodes to connect to a remote LMStudio server through OpenAI
```
3. Restart ComfyUI
## Troubleshooting
### macOS: "Connection error" / "No route to host" to a server on your network
LM Studio runs on another machine (say `192.168.1.10`), `curl` from Terminal
reaches it fine, but ComfyUI fails with `Connection error` or
`[Errno 65] No route to host`.
This is **macOS Local Network privacy**. Every app needs permission to talk to
devices on your local network, and the app that launched ComfyUI does not have
it. The nodes detect this case and print the fix, filled in with your own
server address.
**Fix 1: grant the permission.** System Settings → Privacy & Security →
Local Network → switch on the app that runs ComfyUI (Terminal, iTerm,
Comfy Desktop...). Then quit that app fully (⌘Q) and relaunch it.
**Fix 2: the app is not in that list.** Some apps never ask for the
permission, so they never appear there. Comfy Desktop 1.1.3 is one of them. Connections to
your own Mac are always allowed, so run the bundled forwarder in a Terminal
window. It accepts connections on `127.0.0.1` and relays them to your server:
```bash
python3 scripts/lan_forward.py --target 192.168.1.10:1234
```
Leave it running, then set **server_url** on *LMStudio - Connect* to
`http://127.0.0.1:1234`. Use `--listen 11234` if port 1234 is taken on your
Mac (then use `http://127.0.0.1:11234`). On startup the forwarder checks that
it can reach the target, and it tells you if it cannot.
The forwarder only needs the Python that ships with macOS (standard library,
3.9+). It must run from an app that has Local Network permission, such as
Terminal. A `launchd` agent is blocked the same way ComfyUI is. To start it
at login, save this as `lan-forward.command`, make it executable
(`chmod +x lan-forward.command`), and add it under System Settings → General →
Login Items. It opens in Terminal:
```bash
#!/bin/bash
exec python3 "/path/to/comfyUI-LMStudio-nodes/scripts/lan_forward.py" --target 192.168.1.10:1234
```
## Testing
Run the Python unit tests with coverage gate (`>=90%`):
+84 -17
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import base64
import json
import math
import re
import secrets
@@ -23,6 +22,13 @@ 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()
@@ -35,9 +41,9 @@ def normalize_server_url(server_url: str) -> str:
if not parsed.netloc:
raise ValueError("server_url must include host and optional port")
# Ensure no trailing slash and no duplicate /v1.
# Ensure no trailing slash and no duplicate /v1 (case-insensitive).
value = value.rstrip("/")
if value.endswith("/v1"):
if value.lower().endswith("/v1"):
value = value[:-3]
return value
@@ -233,9 +239,10 @@ def extract_responses_text(response: Any) -> str:
text = getattr(entry, "text", None)
if isinstance(text, str):
parts.append(text)
text = getattr(item, "text", None)
if isinstance(text, str):
parts.append(text)
else:
text = getattr(item, "text", None)
if isinstance(text, str):
parts.append(text)
if parts:
return "\n".join(parts)
@@ -243,30 +250,90 @@ def extract_responses_text(response: Any) -> str:
return ""
def dump_openai_response(response: Any) -> str:
if hasattr(response, "model_dump"):
return json.dumps(response.model_dump(), indent=2, default=str)
if hasattr(response, "to_dict"):
return json.dumps(response.to_dict(), indent=2, default=str)
if isinstance(response, dict):
return json.dumps(response, indent=2, default=str)
return str(response)
_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.
Also removes dangling <think> blocks without a closing tag and any
orphan </think> closing tags left by nested or malformed think blocks.
"""
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()
+71 -30
View File
@@ -5,13 +5,20 @@ 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 .network import explain_network_errors
from .models import LMStudioConnectionPayload
@@ -24,7 +31,8 @@ class LMStudioConnect(io.ComfyNode):
category="LMStudio",
description=(
"Creates a reusable LMStudio connection for downstream nodes. "
"Use the refresh/test buttons on this node to fetch models from the remote server."
"Set the Server URL, then click 'Refresh Models' to load and pick a model. "
"'Test Connection' verifies the server is reachable."
),
inputs=[
io.String.Input(
@@ -32,41 +40,52 @@ 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 URL (without /v1).",
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."
),
),
io.String.Input(
id="api_token",
display_name="API Token",
default="-",
placeholder="Leave '-' for lm-studio",
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.",
tooltip="Bearer token for the LMStudio OpenAI-compatible API. Leave '-' for local servers.",
advanced=True,
),
io.Boolean.Input(
id="use_tooling_mcp",
display_name="Use Tooling / MCP",
default=False,
tooltip=(
"Expose intent to use MCP tooling. Useful only when your target model/session is configured "
"for tool-enabled responses."
"Signal intent to use MCP tooling. Only useful when the target "
"model/session is configured for tool-enabled responses."
),
advanced=True,
),
io.Int.Input(
id="max_tokens",
display_name="Max Token",
display_name="Max Tokens",
default=DEFAULT_MAX_TOKENS,
min=1,
max=1_000_000,
@@ -115,7 +134,17 @@ class LMStudioConnect(io.ComfyNode):
)
@classmethod
def validate_inputs(cls, server_url: str, timeout_seconds: int, max_tokens: int) -> bool | str:
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().
try:
normalize_server_url(server_url)
except ValueError as exc:
@@ -133,7 +162,7 @@ class LMStudioConnect(io.ComfyNode):
server_url: str,
api_token: str | None,
model: str,
reasoning_enabled: bool,
thinking: str,
test_connectivity: bool,
max_tokens: int,
temperature: float,
@@ -146,11 +175,12 @@ class LMStudioConnect(io.ComfyNode):
should_probe_models = test_connectivity or model_name in {"", MODEL_PLACEHOLDER}
if should_probe_models:
models = get_server_models(
server_url=normalized_server_url,
api_key=api_token,
timeout_seconds=timeout_seconds,
)
with explain_network_errors(normalized_server_url):
models = get_server_models(
server_url=normalized_server_url,
api_key=api_token,
timeout_seconds=timeout_seconds,
)
if model_name in {"", MODEL_PLACEHOLDER}:
if models:
@@ -171,21 +201,32 @@ class LMStudioConnect(io.ComfyNode):
base_url=f"{normalized_server_url}/v1",
api_key=normalize_api_key(api_token),
model=model_name,
reasoning_enabled=reasoning_enabled,
thinking=thinking,
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}'."
f"Found {model_count} model(s). Using '{model_name}'.{thinking_note}"
)
else:
status = f"Connection prepared for {normalized_server_url}. Using '{model_name}'."
status = (
f"Connection prepared for {normalized_server_url}. "
f"Using '{model_name}'.{thinking_note}"
)
return io.NodeOutput(
payload,
+34 -8
View File
@@ -5,16 +5,21 @@ 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
from .network import explain_network_errors
class LMStudioImageToText(io.ComfyNode):
@@ -111,8 +116,14 @@ class LMStudioImageToText(io.ComfyNode):
}
if system_prompt.strip():
kwargs["instructions"] = system_prompt
if connection.reasoning_enabled:
mode = resolve_thinking_mode(connection.thinking, connection.model)
if mode == THINK_MODE_FORCE:
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"}
@@ -164,18 +175,29 @@ class LMStudioImageToText(io.ComfyNode):
first_image = image[0] if is_batch else image
image_data_url = comfy_image_to_base64_png_url(first_image)
completion = client.chat.completions.create(
model=connection.model,
messages=build_chat_messages_with_image(
mode = resolve_thinking_mode(connection.thinking, connection.model)
messages = apply_thinking_prefill(
build_chat_messages_with_image(
system_prompt=system_prompt,
user_prompt=user_prompt,
image_data_url=image_data_url,
),
seed=resolved_seed,
temperature=connection.temperature,
max_tokens=connection.max_tokens,
n=1,
mode,
)
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}
with explain_network_errors(connection.server_url):
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")
@@ -187,6 +209,10 @@ class LMStudioImageToText(io.ComfyNode):
)
text = strip_think_content(text)
if not text:
raise ValueError(
"All content was inside <think> blocks; no visible text was produced."
)
status = f"Model '{connection.model}' via {via_endpoint} (seed={resolved_seed})."
if fallback_reason:
+1 -1
View File
@@ -7,7 +7,7 @@ class LMStudioConnectionPayload:
base_url: str
api_key: str
model: str
reasoning_enabled: bool
thinking: str
max_tokens: int
temperature: float
timeout_seconds: int
+137
View File
@@ -0,0 +1,137 @@
"""Turn opaque connection failures into actionable messages.
macOS Local Network privacy blocks apps without the permission from reaching
any host on the local network, and the only symptom is a generic
"[Errno 65] No route to host" deep inside the OpenAI client. Some apps that
launch ComfyUI (Comfy Desktop, for one) never ask for the permission, so they
never appear in System Settings and the user has nothing to switch on.
Loopback is always allowed, which is what the bundled forwarder relies on.
"""
from __future__ import annotations
import errno
import ipaddress
import re
import socket
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
from urllib.parse import urlparse
FORWARDER_SCRIPT = Path(__file__).resolve().parent / "scripts" / "lan_forward.py"
# What macOS reports when Local Network privacy denies a connection.
_BLOCKED_ERRNOS = frozenset({errno.EHOSTUNREACH})
_ERRNO_IN_TEXT_RE = re.compile(r"\[Errno (\d+)\]")
class LocalNetworkBlockedError(ConnectionError):
"""macOS refused to let this process reach a host on the local network."""
def _exception_chain(exc: BaseException) -> Iterator[BaseException]:
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
yield current
current = current.__cause__ or current.__context__
def find_errno(exc: BaseException) -> int | None:
"""First OS error number anywhere in the exception chain."""
for item in _exception_chain(exc):
if isinstance(item, OSError) and item.errno:
return item.errno
match = _ERRNO_IN_TEXT_RE.search(str(item))
if match:
return int(match.group(1))
return None
def _is_lan_ip(address: str) -> bool:
try:
ip = ipaddress.ip_address(address.split("%", 1)[0])
except ValueError:
return False
return (ip.is_private or ip.is_link_local) and not ip.is_loopback
def is_local_network_host(host: str | None) -> bool:
"""True when `host` is (or resolves to) a non-loopback local network address."""
if not host:
return False
if _is_lan_ip(host):
return True
try:
ipaddress.ip_address(host.split("%", 1)[0])
return False # a literal IP that is loopback or public
except ValueError:
pass
if host.endswith(".local") or "." not in host.rstrip("."):
return host.lower() != "localhost"
try:
infos = socket.getaddrinfo(host, None)
except OSError:
return False
return any(_is_lan_ip(str(info[4][0])) for info in infos)
def _format_host(host: str) -> str:
return f"[{host}]" if ":" in host else host
def local_network_hint(
exc: BaseException, server_url: str, platform: str | None = None
) -> str | None:
"""Explain a macOS Local Network privacy denial, or None if that is not what happened."""
if (platform or sys.platform) != "darwin":
return None
if find_errno(exc) not in _BLOCKED_ERRNOS:
return None
parsed = urlparse(server_url)
host = parsed.hostname
if not is_local_network_host(host):
return None
scheme = parsed.scheme or "http"
port = parsed.port or (443 if scheme == "https" else 80)
target = f"{_format_host(host)}:{port}"
lines = [
f"macOS blocked ComfyUI from reaching the LM Studio server at {server_url} "
'("No route to host").',
"",
"This is macOS Local Network privacy, not a network fault: the app that runs "
f"ComfyUI is not allowed to talk to devices on your local network, even though "
f"other apps can reach {host}.",
"",
"Fix it one of these ways:",
" 1. System Settings > Privacy & Security > Local Network: switch on the app "
"that runs ComfyUI (Terminal, iTerm, Comfy Desktop...), then quit it fully "
"(Cmd+Q) and relaunch.",
" 2. If that app is not in the list (some apps, such as Comfy Desktop, never "
"ask for the permission), run the bundled forwarder in a Terminal window and "
"leave it open:",
f' python3 "{FORWARDER_SCRIPT}" --target {target}',
f" then set server_url to {scheme}://127.0.0.1:{port}",
]
if scheme == "https":
lines.append(
" (with https, the server certificate must also be valid for 127.0.0.1)"
)
return "\n".join(lines)
@contextmanager
def explain_network_errors(server_url: str) -> Iterator[None]:
"""Re-raise a macOS Local Network denial as LocalNetworkBlockedError with a fix."""
try:
yield
except Exception as exc:
hint = local_network_hint(exc, server_url)
if hint is None:
raise
raise LocalNetworkBlockedError(hint) from exc
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
test = [
"pytest>=8.0.0",
"pytest-cov>=5.0.0",
"aiohttp>=3.9.0",
]
[tool.comfy]
+13 -10
View File
@@ -4,6 +4,7 @@ from aiohttp import web
from server import PromptServer
from .client import get_server_models, normalize_server_url
from .network import explain_network_errors
_ROUTES_REGISTERED = False
@@ -29,11 +30,12 @@ async def _models_handler(request: web.Request) -> web.Response:
try:
timeout_seconds = _query_int(request, "timeout_seconds", 15)
normalized_url = normalize_server_url(server_url)
models = get_server_models(
server_url=normalized_url,
api_key=api_token,
timeout_seconds=timeout_seconds,
)
with explain_network_errors(normalized_url):
models = get_server_models(
server_url=normalized_url,
api_key=api_token,
timeout_seconds=timeout_seconds,
)
except ValueError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
except Exception as exc:
@@ -64,11 +66,12 @@ async def _test_handler(request: web.Request) -> web.Response:
# widget value anyway, so this only matters when the endpoint is called directly.
timeout_seconds = _query_int(request, "timeout_seconds", 15)
normalized_url = normalize_server_url(server_url)
models = get_server_models(
server_url=normalized_url,
api_key=api_token,
timeout_seconds=timeout_seconds,
)
with explain_network_errors(normalized_url):
models = get_server_models(
server_url=normalized_url,
api_key=api_token,
timeout_seconds=timeout_seconds,
)
except ValueError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
except Exception as exc:
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Relay a local port to an LM Studio server elsewhere on your network.
Why this exists: on macOS, an app without Local Network permission gets
"No route to host" for every address on the local network. Some apps that
launch ComfyUI (Comfy Desktop, for one) never ask for that permission, so it
cannot be granted in System Settings. Connections to this Mac itself (loopback)
are always allowed, so run this relay from Terminal, which can be granted the
permission, and point the LMStudio - Connect node at http://127.0.0.1:<port>.
python3 lan_forward.py --target 192.168.1.10:1234
python3 lan_forward.py --target http://lmstudio.local:1234 --listen 11234
Standard library only; works with the Python 3.9 that ships with macOS.
Stop it with Ctrl+C.
"""
from __future__ import annotations
import argparse
import asyncio
import errno
import ipaddress
import logging
import sys
from typing import Optional, Sequence, Tuple
from urllib.parse import urlsplit
Endpoint = Tuple[str, int]
log = logging.getLogger("lan_forward")
DEFAULT_LISTEN_HOST = "127.0.0.1"
DEFAULT_CONNECT_TIMEOUT = 10.0
BUFFER_SIZE = 64 * 1024
def parse_endpoint(value: str, default_host: Optional[str] = None) -> Endpoint:
"""Parse "host:port", "[v6]:port", a bare "port", or a URL such as http://host:1234/v1."""
raw = (value or "").strip()
if raw.isdigit() and default_host is not None:
host, port = default_host, int(raw)
else:
parts = urlsplit(raw if "://" in raw else f"//{raw}")
try:
port = parts.port
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid port in {value!r}") from exc
if port is None and parts.scheme in ("http", "https"):
port = 443 if parts.scheme == "https" else 80
host = parts.hostname or default_host
if not host or port is None:
raise argparse.ArgumentTypeError(
f"expected host:port (for example 192.168.1.10:1234), got {value!r}"
)
if not 0 < port < 65536:
raise argparse.ArgumentTypeError(f"port out of range in {value!r}")
return host, port
def is_loopback(host: str) -> bool:
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def describe_connect_error(exc: BaseException) -> str:
if isinstance(exc, asyncio.TimeoutError):
return "timed out (is the server running and is its port open?)"
if isinstance(exc, OSError) and exc.errno == errno.EHOSTUNREACH and sys.platform == "darwin":
return (
"No route to host. On macOS this usually means the app running this script "
"has no Local Network permission either: run it from Terminal or iTerm and "
"allow that app under System Settings > Privacy & Security > Local Network."
)
if isinstance(exc, OSError) and exc.errno == errno.ECONNREFUSED:
return "connection refused (is LM Studio's server started, and on that port?)"
return str(exc) or type(exc).__name__
async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
"""Copy one direction until EOF, then half-close so the other direction can finish."""
try:
while True:
data = await reader.read(BUFFER_SIZE)
if not data:
break
writer.write(data)
await writer.drain()
except (ConnectionError, OSError):
pass
finally:
try:
if writer.can_write_eof():
writer.write_eof()
except (OSError, RuntimeError):
pass
async def _close(writer: asyncio.StreamWriter) -> None:
writer.close()
try:
await writer.wait_closed()
except (ConnectionError, OSError):
pass
class Forwarder:
def __init__(self, target: Endpoint, connect_timeout: float = DEFAULT_CONNECT_TIMEOUT):
self.target = target
self.connect_timeout = connect_timeout
async def open_upstream(self) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]:
return await asyncio.wait_for(
asyncio.open_connection(*self.target), timeout=self.connect_timeout
)
async def handle(self, client_reader: asyncio.StreamReader,
client_writer: asyncio.StreamWriter) -> None:
try:
upstream_reader, upstream_writer = await self.open_upstream()
except (OSError, asyncio.TimeoutError) as exc:
log.warning("cannot reach %s:%s: %s", *self.target, describe_connect_error(exc))
await _close(client_writer)
return
try:
await asyncio.gather(
_pipe(client_reader, upstream_writer),
_pipe(upstream_reader, client_writer),
)
finally:
await _close(upstream_writer)
await _close(client_writer)
async def probe(self) -> Optional[str]:
"""None when the target accepts a connection, otherwise why it did not."""
try:
_, writer = await self.open_upstream()
except (OSError, asyncio.TimeoutError) as exc:
return describe_connect_error(exc)
await _close(writer)
return None
async def start(listen: Endpoint, target: Endpoint,
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT) -> asyncio.AbstractServer:
forwarder = Forwarder(target, connect_timeout)
server = await asyncio.start_server(forwarder.handle, *listen)
log.info("forwarding %s:%s -> %s:%s", *listen, *target)
problem = await forwarder.probe()
if problem:
log.warning("target %s:%s is not reachable yet: %s", *target, problem)
else:
log.info("target %s:%s is reachable", *target)
return server
async def serve(listen: Endpoint, target: Endpoint, connect_timeout: float) -> None:
server = await start(listen, target, connect_timeout)
scheme_hint = f"http://{listen[0]}:{listen[1]}"
log.info("set the LMStudio - Connect node's server_url to %s (Ctrl+C to stop)", scheme_hint)
async with server:
await server.serve_forever()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Relay a local port to an LM Studio server on your network "
"(works around macOS Local Network privacy).",
)
parser.add_argument(
"--target", required=True,
help="LM Studio server as host:port or URL, e.g. 192.168.1.10:1234",
)
parser.add_argument(
"--listen", default=None,
help="local [host:]port to accept connections on "
f"(default: {DEFAULT_LISTEN_HOST} and the target's port)",
)
parser.add_argument(
"--connect-timeout", type=float, default=DEFAULT_CONNECT_TIMEOUT,
help=f"seconds to wait for the target on each connection (default: {DEFAULT_CONNECT_TIMEOUT:g})",
)
parser.add_argument("--quiet", action="store_true", help="only log warnings")
return parser
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
target = parse_endpoint(args.target)
listen = (parse_endpoint(args.listen, default_host=DEFAULT_LISTEN_HOST)
if args.listen else (DEFAULT_LISTEN_HOST, target[1]))
except argparse.ArgumentTypeError as exc:
parser.error(str(exc))
if is_loopback(target[0]) and target[1] == listen[1]:
parser.error("target and listen address are the same; this would forward to itself")
logging.basicConfig(
level=logging.WARNING if args.quiet else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
if not is_loopback(listen[0]):
log.warning("listening on %s exposes your LM Studio server to other machines", listen[0])
try:
asyncio.run(serve(listen, target, args.connect_timeout))
except KeyboardInterrupt:
return 0
except OSError as exc:
if exc.errno == errno.EADDRINUSE:
log.error(
"port %s is already in use on %s. Another forwarder or a local LM Studio "
"may be running; pick another one with --listen.", listen[1], listen[0],
)
else:
log.error("cannot listen on %s:%s: %s", *listen, exc)
return 1
return 0 # pragma: no cover - serve() only returns by raising
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+1
View File
@@ -102,6 +102,7 @@ def _install_comfy_stubs() -> None:
Int=_FieldType,
Float=_FieldType,
Image=_FieldType,
Combo=_FieldType,
Custom=_custom_type,
)
ui_module = types.SimpleNamespace(
-8
View File
@@ -14,14 +14,6 @@ def make_chat_completion(content: Any) -> SimpleNamespace:
return ns(choices=[ns(message=ns(content=content))])
def make_response_with_output_text(text: str) -> SimpleNamespace:
return ns(output_text=text)
def make_response_with_output_items(*items: Any) -> SimpleNamespace:
return ns(output=list(items))
class FakeTensor:
def __init__(self, array: np.ndarray):
self._array = np.asarray(array, dtype=np.float32)
+545
View File
@@ -0,0 +1,545 @@
"""
Tests for behavioral contracts that 100% line/branch coverage leaves unverified.
Each test is annotated with the mutation it would catch (i.e. what regression
would slip through if the test didn't exist).
"""
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
import pytest
import client
from helpers.imports import import_repo_module
# ---------------------------------------------------------------------------
# client.py – normalize_server_url
# ---------------------------------------------------------------------------
def test_normalize_server_url_whitespace_only_raises() -> None:
"""
Whitespace-only input must raise 'must not be empty'.
A mutation that strips before the emptiness check would silently accept
whitespace and try to parse it, producing a confusing error or wrong URL.
"""
with pytest.raises(ValueError, match="must not be empty"):
client.normalize_server_url(" ")
def test_normalize_server_url_trailing_slash_no_v1() -> None:
"""
A URL with a trailing slash but no /v1 suffix must have the slash stripped.
If the strip was removed, to_openai_base_url would produce 'http://host//v1'.
"""
assert client.normalize_server_url("http://127.0.0.1:1234/") == "http://127.0.0.1:1234"
def test_normalize_server_url_https_accepted() -> None:
"""
https:// scheme must be accepted.
A mutation that only allows http:// would silently break TLS setups.
"""
assert client.normalize_server_url("https://example.com:5000") == "https://example.com:5000"
# ---------------------------------------------------------------------------
# client.py – coerce_seed: whitespace-only string
# ---------------------------------------------------------------------------
def test_coerce_seed_whitespace_only_string_returns_none() -> None:
"""
A whitespace-only seed string (e.g. " ") must return None (treated as
'no seed provided'), exactly like an empty string.
A mutation that compared `value == ""` instead of `not stripped` would
return None for "" but raise ValueError for " ", breaking nodes that
receive padding from UI widgets.
"""
assert client.coerce_seed(" ") is None
assert client.coerce_seed("\t") is None
# ---------------------------------------------------------------------------
# client.py – resolve_request_seed: seed=0 is valid (boundary)
# ---------------------------------------------------------------------------
def test_resolve_request_seed_zero_is_valid() -> None:
"""
seed=0 is explicitly >= 0 and not -1, so it must be returned as-is.
A mutation of the seed == -1 check to seed <= 0 would incorrectly
randomise seed=0 instead of honouring the deterministic value.
"""
assert client.resolve_request_seed(0) == 0
def test_resolve_request_seed_large_positive_passes_through() -> None:
"""
A large positive seed must pass through unchanged.
Ensures no accidental clipping or modulo is applied.
"""
big = 2**62
assert client.resolve_request_seed(big) == big
# ---------------------------------------------------------------------------
# client.py – build_responses_input_text: content structure
# ---------------------------------------------------------------------------
def test_build_responses_input_text_content_type_is_input_text() -> None:
"""
The 'type' field in the content entry MUST be 'input_text', not 'text'.
A mutation swapping the type string would break LMStudio's Responses API
but would not be caught by shape-only assertions.
"""
payload = client.build_responses_input_text("hello world")
entry = payload[0]["content"][0]
assert entry["type"] == "input_text"
assert entry["text"] == "hello world"
# Exactly one content entry; no extra fields.
assert len(payload[0]["content"]) == 1
# ---------------------------------------------------------------------------
# client.py – build_chat_messages: system prompt skipping edge
# ---------------------------------------------------------------------------
def test_build_chat_messages_whitespace_system_skipped() -> None:
"""
A system_prompt that is only whitespace must be omitted (same as empty),
so that only the user message appears.
A mutation removing the .strip() call would include a whitespace-only
system message, poisoning the model's context.
"""
messages = client.build_chat_messages(" \t ", "ask something")
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "ask something"
def test_build_chat_messages_empty_user_prompt_included() -> None:
"""
Even an empty user prompt must produce a user message (the slot must be
present). A mutation that skipped the user message when content is empty
would cause the API call to fail with a missing-message error.
"""
messages = client.build_chat_messages("System", "")
# system + user
assert len(messages) == 2
assert messages[1] == {"role": "user", "content": ""}
# ---------------------------------------------------------------------------
# client.py – list_models: ordering / dedup contract
# ---------------------------------------------------------------------------
def test_list_models_returns_sorted_unique_ids() -> None:
"""
list_models must return a sorted, deduplicated list.
A mutation removing sorted() would produce an arbitrary order, breaking
deterministic dropdown selection in the UI.
A mutation removing set() dedup would expose duplicate model entries.
"""
from types import SimpleNamespace as NS
model_data = [
NS(id="z-model"),
NS(id="a-model"),
NS(id="m-model"),
NS(id="a-model"), # duplicate
NS(id=""), # empty – filtered
]
fake_client = NS(models=NS(list=lambda: NS(data=model_data)))
result = client.list_models(fake_client)
assert result == ["a-model", "m-model", "z-model"]
# Idempotent: second call produces same order.
assert client.list_models(fake_client) == result
# ---------------------------------------------------------------------------
# client.py – strip_think_content: multiple blocks, ordering preserved
# ---------------------------------------------------------------------------
def test_strip_think_content_preserves_surrounding_text_order() -> None:
"""
Text before and after think blocks must appear in the correct order.
A mutation reversing the substitution or collapsing surrounding text
would reorder the visible output.
"""
result = client.strip_think_content("A <think>x</think> B <think>y</think> C")
assert result == "A B C"
def test_strip_think_content_only_think_block_returns_empty() -> None:
"""
A string consisting entirely of a think block must return "".
A mutation that stripped surrounding text instead of the block would
produce the block content rather than the empty string.
"""
assert client.strip_think_content("<think>only reasoning</think>") == ""
# ---------------------------------------------------------------------------
# routes.py – _query_int: negative integer rejected
# ---------------------------------------------------------------------------
def _routes_module():
return import_repo_module("routes", force_reload=True)
def test_query_int_negative_value_raises() -> None:
"""
Negative integers (e.g. -1, -5) must be rejected with '>= 1'.
The check is `value < 1`, which covers both 0 (already tested upstream)
and negative numbers. A mutation changing < 1 to == 0 would silently
accept -1 as a timeout, causing immediate connection failures.
"""
routes = _routes_module()
for raw in ["-1", "-100"]:
with pytest.raises(ValueError, match="must be >= 1"):
routes._query_int(
SimpleNamespace(query={"timeout_seconds": raw}),
"timeout_seconds",
15,
)
def test_query_int_empty_string_uses_default() -> None:
"""
An empty-string query value must fall back to the default, not raise.
A mutation that only checked `raw is None` (not `raw == ""`) would try
to parse "" and raise ValueError instead of returning the default.
"""
routes = _routes_module()
request = SimpleNamespace(query={"timeout_seconds": ""})
assert routes._query_int(request, "timeout_seconds", 15) == 15
# ---------------------------------------------------------------------------
# routes.py – _models_handler: empty models list produces correct default_model
# ---------------------------------------------------------------------------
def test_models_handler_empty_models_default_model_is_empty_string(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When the server returns no models, default_model must be "" (empty string),
not raise an IndexError.
A mutation changing `models[0] if models else ""` to just `models[0]`
would crash on empty lists.
"""
routes = _routes_module()
monkeypatch.setattr(routes, "normalize_server_url", lambda url: "http://x:1234")
monkeypatch.setattr(routes, "get_server_models", lambda **kwargs: [])
request = SimpleNamespace(query={"server_url": "http://x:1234"})
response = asyncio.run(routes._models_handler(request))
payload = json.loads(response.text)
assert response.status == 200
assert payload["ok"] is True
assert payload["default_model"] == ""
assert payload["models"] == []
# ---------------------------------------------------------------------------
# routes.py – _test_handler: model_count matches actual list length
# ---------------------------------------------------------------------------
def test_test_handler_model_count_matches_models_list(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
model_count in the response must equal len(models).
The existing test only checks model_count==1; this one checks that the
count is derived from the list, not hardcoded.
A mutation replacing len(models) with 1 would pass the existing test
but fail here.
"""
routes = _routes_module()
monkeypatch.setattr(routes, "normalize_server_url", lambda url: "http://x:1234")
monkeypatch.setattr(routes, "get_server_models", lambda **kwargs: ["a", "b", "c"])
request = SimpleNamespace(query={"server_url": "http://x:1234"})
response = asyncio.run(routes._test_handler(request))
payload = json.loads(response.text)
assert payload["model_count"] == 3
assert payload["models"] == ["a", "b", "c"]
assert "3 model(s)" in payload["message"]
def test_test_handler_error_prefix_is_connection_test_failed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The 502 error message for _test_handler must use the prefix
'Connection test failed', NOT 'Failed to list models' (which is the
_models_handler prefix). A copy-paste mutation that shared the same
error string between the two handlers would confuse operator debugging.
"""
routes = _routes_module()
monkeypatch.setattr(routes, "normalize_server_url", lambda url: "http://x:1234")
monkeypatch.setattr(
routes,
"get_server_models",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("timeout")),
)
request = SimpleNamespace(query={"server_url": "http://x:1234"})
response = asyncio.run(routes._test_handler(request))
payload = json.loads(response.text)
assert response.status == 502
assert "Connection test failed" in payload["error"]
assert "Failed to list models" not in payload["error"]
def test_models_handler_error_prefix_is_failed_to_list(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Symmetric guard: the 502 error from _models_handler must use
'Failed to list models', not 'Connection test failed'.
"""
routes = _routes_module()
monkeypatch.setattr(routes, "normalize_server_url", lambda url: "http://x:1234")
monkeypatch.setattr(
routes,
"get_server_models",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
)
request = SimpleNamespace(query={"server_url": "http://x:1234"})
response = asyncio.run(routes._models_handler(request))
payload = json.loads(response.text)
assert response.status == 502
assert "Failed to list models" in payload["error"]
assert "Connection test failed" not in payload["error"]
# ---------------------------------------------------------------------------
# connect_node.py – auto-discover model when model="" and test_connectivity=False
# ---------------------------------------------------------------------------
def _connect_module():
return import_repo_module("connect_node", force_reload=True)
def test_execute_discovers_model_when_empty_and_connectivity_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When model="" (blank) and test_connectivity=False, the node must still
probe the server (because no model is selected) and use the first result.
should_probe_models is `test_connectivity OR model in {"", MODEL_PLACEHOLDER}`.
A mutation changing `or` to `and` would skip the probe and raise ValueError
('No model selected') instead of auto-selecting.
"""
connect_node = _connect_module()
monkeypatch.setattr(connect_node, "get_server_models", lambda **_: ["auto-model"])
output = connect_node.LMStudioConnect.execute(
server_url="http://127.0.0.1:1234",
api_token="-",
model="",
thinking="auto",
test_connectivity=False, # disabled, but model is blank → must still probe
max_tokens=128,
temperature=0.5,
timeout_seconds=10,
use_tooling_mcp=False,
)
payload = output[0]
assert payload.model == "auto-model"
def test_execute_status_no_probe_does_not_say_found_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When test_connectivity=False and a model is explicitly set, the status
message must say 'Connection prepared' and NOT say 'Found N model(s)'.
A mutation that always used the probe-status template would expose
incorrect model-count information.
"""
connect_node = _connect_module()
monkeypatch.setattr(
connect_node,
"get_server_models",
lambda **_: (_ for _ in ()).throw(AssertionError("must not probe")),
)
output = connect_node.LMStudioConnect.execute(
server_url="http://127.0.0.1:1234",
api_token="-",
model="chosen-model",
thinking="auto",
test_connectivity=False,
max_tokens=128,
temperature=0.5,
timeout_seconds=10,
use_tooling_mcp=False,
)
status = output[1]
assert "Connection prepared" in status
assert "Found" not in status
def test_execute_status_with_probe_includes_model_count(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When the probe runs, the status must include 'Found N model(s)' with the
actual count.
A mutation hardcoding the count as 0 or 1 would pass the existing test
(which uses 2 models) but would be exposed by checking a different count.
"""
connect_node = _connect_module()
monkeypatch.setattr(
connect_node,
"get_server_models",
lambda **_: ["x", "y", "z"],
)
output = connect_node.LMStudioConnect.execute(
server_url="http://127.0.0.1:1234",
api_token="-",
model="x",
thinking="auto",
test_connectivity=True,
max_tokens=128,
temperature=0.5,
timeout_seconds=10,
use_tooling_mcp=False,
)
status = output[1]
assert "Found 3 model(s)" in status
# ---------------------------------------------------------------------------
# text_gen_node.py – _responses_kwargs: 'input' field is always present
# ---------------------------------------------------------------------------
def _text_module():
return import_repo_module("text_gen_node", force_reload=True)
def _connection_payload(**overrides):
models = import_repo_module("models", force_reload=True)
base = dict(
server_url="http://127.0.0.1:1234",
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model="model-a",
thinking="auto",
max_tokens=111,
temperature=0.4,
timeout_seconds=30,
use_tooling_mcp=False,
)
base.update(overrides)
return models.LMStudioConnectionPayload(**base)
def test_responses_kwargs_input_field_is_present_and_has_user_role() -> None:
"""
The 'input' key in _responses_kwargs must be set to a list containing
a user-role message with the prompt text.
A mutation that removed or misspelled the 'input' key would send an
incomplete payload that LMStudio's Responses API would reject.
"""
text_gen_node = _text_module()
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
connection=_connection_payload(),
system_prompt="",
user_prompt="describe the image",
seed=1,
)
assert "input" in kwargs
assert isinstance(kwargs["input"], list)
assert len(kwargs["input"]) == 1
assert kwargs["input"][0]["role"] == "user"
# The content must carry the user_prompt text.
content = kwargs["input"][0]["content"]
assert any(
(isinstance(entry, dict) and entry.get("text") == "describe the image")
for entry in content
), f"user_prompt not found in content: {content!r}"
def test_responses_kwargs_temperature_and_max_tokens_forwarded() -> None:
"""
temperature and max_output_tokens in _responses_kwargs must mirror the
connection payload values exactly.
A mutation swapping temperature and max_output_tokens, or using a
hardcoded default, would break generation quality silently.
"""
text_gen_node = _text_module()
conn = _connection_payload(temperature=1.5, max_tokens=512)
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
connection=conn,
system_prompt="",
user_prompt="hello",
seed=7,
)
assert kwargs["temperature"] == 1.5
assert kwargs["max_output_tokens"] == 512
# ---------------------------------------------------------------------------
# text_gen_node.py – execute: seed appears in status for fallback path too
# ---------------------------------------------------------------------------
def test_execute_fallback_status_includes_seed(monkeypatch: pytest.MonkeyPatch) -> None:
"""
The status message must include the resolved seed even when falling back
to chat.completions. The existing fallback test checks for the fallback
reason, but NOT for the seed, so a mutation zeroing the seed in the
status template would go undetected.
"""
text_gen_node = _text_module()
completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="Answer"))]
)
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(RuntimeError("responses off"))
),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: completion)),
)
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 42)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
output = text_gen_node.LMStudioTextGen.execute(
connection=_connection_payload(),
system_prompt="sys",
user_prompt="hello",
seed=0,
)
assert output[0] == "Answer"
assert "(seed=42)" in output.ui.text
assert "via chat.completions" in output.ui.text
def test_execute_user_prompt_none_treated_as_empty_raises() -> None:
"""
user_prompt=None must be treated as empty and raise ValueError.
A mutation that skipped the `user_prompt = user_prompt or ""` coercion
would cause a NoneType AttributeError instead of a clean ValueError.
"""
text_gen_node = _text_module()
with pytest.raises(ValueError, match="user_prompt must not be empty"):
text_gen_node.LMStudioTextGen.execute(
connection=_connection_payload(),
system_prompt="sys",
user_prompt=None,
seed=1,
)
+15
View File
@@ -64,3 +64,18 @@ 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
+51 -15
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import base64
from io import BytesIO
import json
import sys
import types
@@ -30,6 +29,18 @@ def test_normalize_server_url_rejects_invalid_values() -> None:
client.normalize_server_url("http://")
def test_normalize_server_url_strips_v1_case_insensitive() -> None:
# Lowercase (existing behaviour).
assert client.normalize_server_url("http://localhost:1234/v1") == "http://localhost:1234"
# Uppercase /V1 was silently left in place before the fix, causing
# to_openai_base_url() to produce "…/V1/v1" — a double-suffix broken URL.
assert client.normalize_server_url("http://localhost:1234/V1") == "http://localhost:1234"
# Mixed case.
assert client.normalize_server_url("http://localhost:1234/V1/") == "http://localhost:1234"
# to_openai_base_url must append exactly one /v1 regardless of input casing.
assert client.to_openai_base_url("http://localhost:1234/V1") == "http://localhost:1234/v1"
def test_to_openai_base_url_appends_v1() -> None:
assert client.to_openai_base_url("http://127.0.0.1:1234") == "http://127.0.0.1:1234/v1"
@@ -183,23 +194,48 @@ def test_extract_responses_text_variants() -> None:
assert client.extract_responses_text(ns(output_text="", output=[])) == ""
def test_dump_openai_response_branches() -> None:
class WithModelDump:
def model_dump(self):
return {"a": 1}
def test_extract_responses_text_no_duplicate_when_item_has_content_and_text() -> None:
# An output item that has BOTH a content list and a top-level text attribute
# should only yield text from the content list; the item-level text field
# must NOT be appended a second time (regression guard for the duplicate-text bug).
item_with_both = ns(content=[ns(text="the-answer")], text="the-answer")
response = ns(output=[item_with_both])
result = client.extract_responses_text(response)
assert result == "the-answer", (
f"Expected 'the-answer' but got {result!r} — "
"item.text was appended even though content was already processed"
)
class WithToDict:
def to_dict(self):
return {"b": 2}
class Plain:
def __str__(self) -> str:
return "plain-response"
assert json.loads(client.dump_openai_response(WithModelDump())) == {"a": 1}
assert json.loads(client.dump_openai_response(WithToDict())) == {"b": 2}
assert json.loads(client.dump_openai_response({"c": 3})) == {"c": 3}
assert client.dump_openai_response(Plain()) == "plain-response"
def test_extract_responses_text_dict_item_non_list_content_is_skipped() -> None:
# Branch 221->227: dict item where "content" is not a list.
# The function should skip it (continue) and return empty string — not crash.
response = ns(output=[{"content": "not-a-list"}, {"content": {"nested": "dict"}}])
assert client.extract_responses_text(response) == ""
def test_extract_responses_text_dict_item_content_list_with_non_dict_entries() -> None:
# Branch 223->222: content list contains non-dict entries (e.g. plain strings).
# Those entries must be silently ignored; only dict entries with a str "text" key count.
response = ns(output=[{"content": ["string-entry", 42, None, {"text": "kept"}]}])
assert client.extract_responses_text(response) == "kept"
def test_extract_responses_text_dict_item_content_entry_text_non_string() -> None:
# Branch 225->222: content entry is a dict but "text" value is not a str.
# These entries must be silently ignored.
response = ns(output=[{"content": [{"text": 42}, {"text": None}, {"text": "good"}]}])
assert client.extract_responses_text(response) == "good"
def test_extract_responses_text_object_item_content_entry_text_non_string() -> None:
# Branch 233->231: object item with content list where getattr(entry, "text") is not a str.
# The entry should be skipped; other valid entries should still be collected.
bad_entry = ns(text=99)
good_entry = ns(text="valid")
response = ns(output=[ns(content=[bad_entry, good_entry])])
assert client.extract_responses_text(response) == "valid"
def test_strip_think_content_case_insensitive_and_multiple() -> None:
+15 -7
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",
"api_token",
"model",
"reasoning_enabled",
"thinking",
"api_token",
"use_tooling_mcp",
"max_tokens",
"temperature",
@@ -30,9 +30,15 @@ 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
@@ -44,6 +50,8 @@ 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"
@@ -59,7 +67,7 @@ def test_execute_with_placeholder_model_uses_discovered_default(
server_url=" http://10.0.0.1:1234/v1/ ",
api_token="-",
model="<refresh models>",
reasoning_enabled=True,
thinking="on",
test_connectivity=True,
max_tokens=321,
temperature=0.6,
@@ -72,7 +80,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.reasoning_enabled is True
assert payload.thinking == "on"
assert payload.use_tooling_mcp is True
assert payload.max_tokens == 321
assert payload.temperature == 0.6
@@ -93,7 +101,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",
reasoning_enabled=False,
thinking="auto",
test_connectivity=False,
max_tokens=128,
temperature=0.5,
@@ -118,7 +126,7 @@ def test_execute_raises_when_selected_model_not_on_server(
server_url="http://127.0.0.1:1234",
api_token="-",
model="requested-model",
reasoning_enabled=False,
thinking="auto",
test_connectivity=True,
max_tokens=128,
temperature=0.5,
@@ -138,7 +146,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>",
reasoning_enabled=False,
thinking="auto",
test_connectivity=True,
max_tokens=128,
temperature=0.5,
+300
View File
@@ -0,0 +1,300 @@
"""
Tests targeting the remaining uncovered branches in client.py and image_to_text_node.py.
Coverage gaps addressed (as reported by pytest-cov on the main branch):
client.py : lines 117-118, 142, 207, 222->228, 224->223, 226->223, 234->232, 291->293
image_to_text_node: lines 112->114, 157, 181, 183->189
"""
from __future__ import annotations
from types import SimpleNamespace
import numpy as np
import pytest
import client
from helpers.fakes import FakeTensor, make_chat_completion, ns
from helpers.imports import import_repo_module
# ---------------------------------------------------------------------------
# client.py – coerce_seed: .item() call raises, falls back to plain value
# ---------------------------------------------------------------------------
class _BadItem:
"""Numpy-scalar-like object whose .item() always raises."""
def item(self):
raise RuntimeError("item() failed")
def test_coerce_seed_item_raises_falls_back_to_unsupported_type() -> None:
"""
When value has .item() but the call raises, coerce_seed catches the exception
(lines 117-118) and falls through to the type-check below. Since _BadItem is
not int/float/str/bytes, it must then hit the final raise (line 142).
"""
with pytest.raises(ValueError, match="Unsupported seed type"):
client.coerce_seed(_BadItem())
def test_coerce_seed_unsupported_type_raises() -> None:
"""
A plain object with no recognised numeric/string interface must raise the
'Unsupported seed type' error (line 142).
"""
with pytest.raises(ValueError, match="Unsupported seed type"):
client.coerce_seed(object())
with pytest.raises(ValueError, match="Unsupported seed type"):
client.coerce_seed({"a": 1})
# ---------------------------------------------------------------------------
# client.py – extract_chat_completion_text: content=[] returns ""
# ---------------------------------------------------------------------------
def test_extract_chat_completion_text_empty_list_content() -> None:
"""
When the message.content is an empty list the function should return "".
This exercises the branch at line 207 where content is a list but parts
ends up empty so "\n".join returns "".
"""
completion = make_chat_completion([])
assert client.extract_chat_completion_text(completion) == ""
def test_extract_chat_completion_text_list_with_no_usable_text() -> None:
"""
List content items where getattr(part, 'text', None) is None and the item
is also not a dict – they are silently skipped, exercising the branches
at lines 222->228 / 224->223 / 226->223.
"""
content = [
ns(other="no-text"), # has no .text, not a dict – skipped
ns(text=None), # text attr is None – skipped
{"other": "ignored"}, # dict without 'text' key – skipped
ns(text="kept"), # text attr is str – included
]
completion = make_chat_completion(content)
assert client.extract_chat_completion_text(completion) == "kept"
def test_extract_chat_completion_text_non_list_non_str_content() -> None:
"""
When message.content is neither a str nor a list (e.g. an int or None),
the function should return "" via the final branch at line 207.
"""
assert client.extract_chat_completion_text(make_chat_completion(42)) == ""
assert client.extract_chat_completion_text(make_chat_completion(None)) == ""
# ---------------------------------------------------------------------------
# client.py – extract_responses_text: output list with no content at all
# ---------------------------------------------------------------------------
def test_extract_responses_text_output_list_empty_items() -> None:
"""
output is a list but its items carry neither usable 'content' lists nor
a 'text' attribute, so parts stays empty and the function returns "".
This exercises the branch at line 234->232.
"""
response = ns(output_text="", output=[ns(content=None), ns()])
assert client.extract_responses_text(response) == ""
def test_extract_responses_text_output_none() -> None:
"""output attribute is None – falls through to return "" at the end."""
assert client.extract_responses_text(ns(output_text="", output=None)) == ""
# ---------------------------------------------------------------------------
# client.py – build_chat_messages_with_image: no system prompt (line 291->293)
# ---------------------------------------------------------------------------
def test_build_chat_messages_with_image_no_system_prompt() -> None:
"""
When system_prompt is blank/empty the system message must be omitted so
only the user message is present. This covers branch 291->293.
"""
messages = client.build_chat_messages_with_image(
system_prompt=" ",
user_prompt="describe it",
image_data_url="data:image/png;base64,abc==",
)
assert len(messages) == 1
assert messages[0]["role"] == "user"
user_content = messages[0]["content"]
assert user_content[0] == {"type": "text", "text": "describe it"}
assert user_content[1]["type"] == "image_url"
# None system_prompt is treated the same way.
messages_none = client.build_chat_messages_with_image(
system_prompt=None,
user_prompt="hello",
image_data_url="data:image/png;base64,xyz==",
)
assert len(messages_none) == 1
# ---------------------------------------------------------------------------
# image_to_text_node.py – _responses_kwargs: empty system_prompt (line 112->114)
# ---------------------------------------------------------------------------
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):
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,
max_tokens=128,
temperature=0.3,
timeout_seconds=30,
use_tooling_mcp=use_tooling_mcp,
)
def _single_image() -> FakeTensor:
return FakeTensor(np.array([[[1.0, 0.0, 0.0]]], dtype=np.float32))
def test_responses_kwargs_no_system_prompt_omits_instructions() -> None:
"""
When system_prompt is blank the 'instructions' key must NOT appear in
the returned kwargs. This covers the branch at line 112->114.
"""
image_node = _image_module()
kwargs = image_node.LMStudioImageToText._responses_kwargs(
connection=_connection_payload(),
image=_single_image(),
system_prompt="",
user_prompt="describe",
seed=1,
)
assert "instructions" not in kwargs
kwargs_whitespace = image_node.LMStudioImageToText._responses_kwargs(
connection=_connection_payload(),
image=_single_image(),
system_prompt=" ",
user_prompt="describe",
seed=1,
)
assert "instructions" not in kwargs_whitespace
# ---------------------------------------------------------------------------
# image_to_text_node.py – execute: responses returns empty text (line 157)
# ---------------------------------------------------------------------------
def test_execute_responses_empty_text_falls_back_to_chat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When the responses endpoint returns an empty string the node must raise
a ValueError internally and fall back to chat.completions.
This covers line 157 ('raise ValueError("responses endpoint returned no text output")').
"""
image_node = _image_module()
completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="chat fallback text"))]
)
fake_client = SimpleNamespace(
responses=SimpleNamespace(create=lambda **_: SimpleNamespace(output_text="")),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: completion)),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 5)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
output = image_node.LMStudioImageToText.execute(
connection=_connection_payload(),
image=_single_image(),
system_prompt="sys",
user_prompt="describe",
seed=-1,
)
assert output[0] == "chat fallback text"
assert "via chat.completions" in output.ui.text
assert "responses endpoint returned no text output" in output.ui.text
# ---------------------------------------------------------------------------
# image_to_text_node.py – execute: chat fallback also empty (line 181)
# ---------------------------------------------------------------------------
def test_execute_both_endpoints_empty_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When both the responses endpoint and the chat.completions fallback return
empty text, a ValueError must propagate to the caller.
This covers line 181 ('raise ValueError("chat.completions fallback returned no text output")').
"""
image_node = _image_module()
empty_completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=""))]
)
fake_client = SimpleNamespace(
responses=SimpleNamespace(create=lambda **_: SimpleNamespace(output_text="")),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: empty_completion)),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 3)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
with pytest.raises(ValueError, match="chat.completions fallback returned no text output"):
image_node.LMStudioImageToText.execute(
connection=_connection_payload(),
image=_single_image(),
system_prompt="sys",
user_prompt="describe",
seed=0,
)
# ---------------------------------------------------------------------------
# image_to_text_node.py – execute: chat fallback single image, no dropped-frames note (line 183->189)
# ---------------------------------------------------------------------------
def test_execute_chat_fallback_single_image_no_dropped_frames_note(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When the responses endpoint fails and the image is a *single* frame (not a
batch), the fallback status message must NOT mention dropped frames.
This covers the branch at line 183->189 where batch_size == 1.
"""
image_node = _image_module()
def raise_responses(**kwargs):
raise RuntimeError("responses unavailable")
completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="single fallback"))]
)
fake_client = SimpleNamespace(
responses=SimpleNamespace(create=raise_responses),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: completion)),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 7)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
output = image_node.LMStudioImageToText.execute(
connection=_connection_payload(),
image=_single_image(),
system_prompt="sys",
user_prompt="describe",
seed=0,
)
assert output[0] == "single fallback"
assert "via chat.completions" in output.ui.text
assert "dropped" not in output.ui.text
+376
View File
@@ -0,0 +1,376 @@
"""
Behavioral-contract tests for image encoding and node status messages.
Each test targets a specific mutation that existing coverage does not catch:
1. comfy_image_to_base64_png_url — image_url field key in _responses_kwargs content.
2. comfy_image_to_base64_png_url — RGBA (4-channel) tensors round-trip correctly.
3. comfy_image_to_base64_png_url — non-1x1 image preserves correct dimensions.
4. image_to_text_node.execute — status message includes the model name.
5. image_to_text_node._responses_kwargs — every image content entry has valid data URL.
6. text_gen_node.execute — status message includes the model name.
"""
from __future__ import annotations
import base64
from io import BytesIO
from types import SimpleNamespace
import numpy as np
import pytest
from PIL import Image
import client
from helpers.fakes import FakeTensor
from helpers.imports import import_repo_module
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _decode_data_url_png(data_url: str) -> Image.Image:
assert data_url.startswith("data:image/png;base64,"), (
f"Expected data:image/png;base64, prefix, got: {data_url[:40]!r}"
)
encoded = data_url.split(",", maxsplit=1)[1]
raw = base64.b64decode(encoded)
return Image.open(BytesIO(raw))
def _image_module():
return import_repo_module("image_to_text_node", force_reload=True)
def _text_module():
return import_repo_module("text_gen_node", force_reload=True)
def _connection_payload(model: str = "vision-model"):
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="auto",
max_tokens=128,
temperature=0.3,
timeout_seconds=30,
use_tooling_mcp=False,
)
def _single_image() -> FakeTensor:
return FakeTensor(np.array([[[1.0, 0.0, 0.0]]], dtype=np.float32))
def _batch_image() -> FakeTensor:
return FakeTensor(
np.array(
[
[[[1.0, 0.0, 0.0]]], # red
[[[0.0, 0.0, 1.0]]], # blue
],
dtype=np.float32,
)
)
# ---------------------------------------------------------------------------
# client.comfy_image_to_base64_png_url — RGBA (4-channel) round-trip
# ---------------------------------------------------------------------------
def test_comfy_image_to_base64_png_url_rgba_round_trip() -> None:
"""
A 4-channel (RGBA) float32 tensor must survive the encode-decode round-trip.
The encoded PNG must be decodable and preserve the RGB channels.
A mutation that accidentally dropped channels (e.g. slicing to [:,:,:3]) would
change the pixel values and break this assertion.
"""
# 1x1 RGBA image: R=1.0, G=0.5, B=0.0, A=0.8
rgba = FakeTensor(np.array([[[1.0, 0.5, 0.0, 0.8]]], dtype=np.float32))
url = client.comfy_image_to_base64_png_url(rgba)
img = _decode_data_url_png(url)
# PIL will create an RGBA image for 4-channel input.
r, g, b, a = img.getpixel((0, 0))
assert r == 255, f"Red channel: expected 255, got {r}"
assert g == 127 or g == 128, f"Green channel: expected ~127, got {g}" # float rounding
assert b == 0, f"Blue channel: expected 0, got {b}"
assert a == 204 or a == 203, f"Alpha channel: expected ~204, got {a}" # 0.8*255
def test_comfy_image_to_base64_png_url_correct_dimensions() -> None:
"""
The encoded PNG must preserve the exact width and height of the input tensor.
A mutation that transposed H and W (e.g. using .T) would swap width/height
and break this assertion.
"""
# 3 rows, 5 columns, 3 channels
h, w = 3, 5
arr = np.zeros((h, w, 3), dtype=np.float32)
tensor = FakeTensor(arr)
url = client.comfy_image_to_base64_png_url(tensor)
img = _decode_data_url_png(url)
assert img.size == (w, h), f"Expected ({w}, {h}), got {img.size}"
def test_comfy_image_to_base64_png_url_batch_uses_first_frame_exactly() -> None:
"""
For a batch tensor (ndim==4), only frame 0 must be encoded.
Frame 1 (blue) must NOT appear in the output.
A mutation that encoded frame 1 instead of frame 0 would change the pixel
from red to blue.
"""
batch = FakeTensor(
np.array(
[
[[[1.0, 0.0, 0.0]]], # frame 0: red
[[[0.0, 0.0, 1.0]]], # frame 1: blue
],
dtype=np.float32,
)
)
url = client.comfy_image_to_base64_png_url(batch)
img = _decode_data_url_png(url)
r, g, b = img.getpixel((0, 0))[:3]
assert r == 255 and g == 0 and b == 0, (
f"Expected red (255,0,0) from frame 0, got ({r},{g},{b})"
)
# ---------------------------------------------------------------------------
# image_to_text_node._responses_kwargs — image_url field key correctness
# ---------------------------------------------------------------------------
def test_responses_kwargs_image_content_field_key_is_image_url() -> None:
"""
Each image entry in the content list must use the key 'image_url', not 'url'
or any other variant. A mutation renaming the field would break LMStudio's
Responses API contract silently.
This test covers both the single (ndim==3) and batch (ndim==4) paths.
"""
image_node = _image_module()
# Single image (3D tensor from __getitem__ on batch)
single = FakeTensor(np.array([[[0.0, 1.0, 0.0]]], dtype=np.float32))
kwargs_single = image_node.LMStudioImageToText._responses_kwargs(
connection=_connection_payload(),
image=single,
system_prompt="sys",
user_prompt="describe",
seed=1,
)
single_content = kwargs_single["input"][0]["content"]
image_entry = single_content[1]
assert image_entry["type"] == "input_image"
assert "image_url" in image_entry, (
f"Expected 'image_url' key, found keys: {list(image_entry.keys())}"
)
assert image_entry["image_url"].startswith("data:image/png;base64,")
# Batch (4D tensor) — every image entry must have valid image_url
batch = _batch_image()
kwargs_batch = image_node.LMStudioImageToText._responses_kwargs(
connection=_connection_payload(),
image=batch,
system_prompt="sys",
user_prompt="describe",
seed=2,
)
batch_content = kwargs_batch["input"][0]["content"]
image_entries = [e for e in batch_content if e.get("type") == "input_image"]
assert len(image_entries) == 2, f"Expected 2 image entries, got {len(image_entries)}"
for i, entry in enumerate(image_entries):
assert "image_url" in entry, (
f"Image entry {i} missing 'image_url' key; keys: {list(entry.keys())}"
)
assert entry["image_url"].startswith("data:image/png;base64,"), (
f"Image entry {i} 'image_url' lacks data URL prefix: {entry['image_url'][:40]!r}"
)
def test_responses_kwargs_batch_images_are_distinct_data_urls() -> None:
"""
In a multi-frame batch, the data URLs for different frames must differ.
A mutation that passed the same frame index for all images would produce
identical URLs and break this assertion.
"""
image_node = _image_module()
batch = FakeTensor(
np.array(
[
[[[1.0, 0.0, 0.0]]], # red
[[[0.0, 0.0, 1.0]]], # blue
],
dtype=np.float32,
)
)
kwargs = image_node.LMStudioImageToText._responses_kwargs(
connection=_connection_payload(),
image=batch,
system_prompt="",
user_prompt="describe",
seed=3,
)
content = kwargs["input"][0]["content"]
image_urls = [e["image_url"] for e in content if e.get("type") == "input_image"]
assert len(image_urls) == 2
assert image_urls[0] != image_urls[1], (
"Both batch frames produced the same data URL — frame indexing is broken"
)
# ---------------------------------------------------------------------------
# image_to_text_node.execute — status message includes the model name
# ---------------------------------------------------------------------------
def test_execute_status_includes_model_name_responses_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The status message must embed the actual model name from the connection payload.
A mutation hardcoding a model name (e.g. always "vision-model") would pass
tests that don't check the name, but fail here when a different model is used.
"""
image_node = _image_module()
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: SimpleNamespace(output_text="some result")
),
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(AssertionError("no fallback"))
)
),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 11)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
output = image_node.LMStudioImageToText.execute(
connection=_connection_payload(model="my-custom-vision-model"),
image=_single_image(),
system_prompt="",
user_prompt="describe this",
seed=5,
)
assert "my-custom-vision-model" in output.ui.text, (
f"Model name not found in status: {output.ui.text!r}"
)
def test_execute_status_includes_model_name_fallback_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The model name must also appear in the status when chat.completions fallback is used.
"""
image_node = _image_module()
completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="fallback result"))]
)
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(RuntimeError("responses off"))
),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: completion)),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 22)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
output = image_node.LMStudioImageToText.execute(
connection=_connection_payload(model="fallback-model-xyz"),
image=_single_image(),
system_prompt="",
user_prompt="what is this",
seed=0,
)
assert "fallback-model-xyz" in output.ui.text, (
f"Model name not found in fallback status: {output.ui.text!r}"
)
# ---------------------------------------------------------------------------
# text_gen_node.execute — status message includes the model name
# ---------------------------------------------------------------------------
def _text_connection_payload(model: str = "text-model"):
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="auto",
max_tokens=256,
temperature=0.7,
timeout_seconds=30,
use_tooling_mcp=False,
)
def test_text_gen_execute_status_includes_model_name_responses_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The status message in LMStudioTextGen.execute must include the actual model name.
A mutation hardcoding the model name in the status template would break this.
"""
text_gen_node = _text_module()
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: SimpleNamespace(output_text="text output")
),
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(AssertionError("no fallback"))
)
),
)
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 33)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
output = text_gen_node.LMStudioTextGen.execute(
connection=_text_connection_payload(model="unique-text-model-abc"),
system_prompt="",
user_prompt="generate something",
seed=5,
)
assert "unique-text-model-abc" in output.ui.text, (
f"Model name not found in text_gen status: {output.ui.text!r}"
)
def test_text_gen_execute_status_includes_model_name_fallback_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The model name must appear in the status even when chat.completions fallback is used.
"""
text_gen_node = _text_module()
completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="text fallback answer"))]
)
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(RuntimeError("no responses"))
),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: completion)),
)
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 44)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
output = text_gen_node.LMStudioTextGen.execute(
connection=_text_connection_payload(model="special-fallback-model"),
system_prompt="",
user_prompt="ask something",
seed=0,
)
assert "special-fallback-model" in output.ui.text, (
f"Model name not found in fallback status: {output.ui.text!r}"
)
+50 -5
View File
@@ -13,14 +13,14 @@ def _image_module():
return import_repo_module("image_to_text_node", force_reload=True)
def _connection_payload(*, reasoning_enabled: bool = False, use_tooling_mcp: bool = False):
def _connection_payload(*, thinking: str = "auto", 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",
reasoning_enabled=reasoning_enabled,
thinking=thinking,
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(reasoning_enabled=True, use_tooling_mcp=True),
connection=_connection_payload(thinking="on", use_tooling_mcp=True),
image=_batch_image(),
system_prompt="sys",
user_prompt="describe",
@@ -82,6 +82,7 @@ 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"}
@@ -139,15 +140,22 @@ 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=lambda **kwargs: completion)),
chat=SimpleNamespace(completions=SimpleNamespace(create=create_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(),
connection=_connection_payload(thinking="off"),
image=_batch_image(),
system_prompt="sys",
user_prompt="describe",
@@ -158,3 +166,40 @@ 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(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Regression: when the model returns only <think> content (no visible text),
strip_think_content reduces the response to "". The node must raise
ValueError rather than silently returning an empty string.
"""
image_node = _image_module()
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: SimpleNamespace(output_text="<think>internal only</think>")
),
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(
AssertionError("chat fallback should not run")
)
)
),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 1)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
with pytest.raises(ValueError, match="All content was inside <think> blocks"):
image_node.LMStudioImageToText.execute(
connection=_connection_payload(),
image=_single_image(),
system_prompt="sys",
user_prompt="describe",
seed=1,
)
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
import argparse
import asyncio
import contextlib
import errno
import importlib.util
import logging
import sys
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "lan_forward.py"
def _load():
spec = importlib.util.spec_from_file_location("lan_forward_under_test", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
fwd = _load()
def _free_port() -> int:
import socket
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
async def _echo_server():
async def echo(reader, writer):
data = await reader.read() # until the client half-closes
writer.write(b"echo:" + data)
await writer.drain()
writer.close()
server = await asyncio.start_server(echo, "127.0.0.1", 0)
return server, server.sockets[0].getsockname()[1]
# --- parse_endpoint ---------------------------------------------------------
@pytest.mark.parametrize(
("value", "default_host", "expected"),
[
("192.168.1.10:1234", None, ("192.168.1.10", 1234)),
(" studio.local:8080 ", None, ("studio.local", 8080)),
("[fd00::7]:1234", None, ("fd00::7", 1234)),
("http://192.168.1.10:1234/v1", None, ("192.168.1.10", 1234)),
("http://studio.local", None, ("studio.local", 80)),
("https://studio.local/v1", None, ("studio.local", 443)),
("11234", "127.0.0.1", ("127.0.0.1", 11234)),
(":11234", "127.0.0.1", ("127.0.0.1", 11234)),
],
)
def test_parse_endpoint(value, default_host, expected) -> None:
assert fwd.parse_endpoint(value, default_host=default_host) == expected
@pytest.mark.parametrize(
("value", "default_host", "message"),
[
("studio.local", None, "expected host:port"),
("1234", None, "expected host:port"),
("", None, "expected host:port"),
("studio.local:abc", None, "invalid port"),
("studio.local:0", None, "out of range"),
("0", "127.0.0.1", "out of range"),
],
)
def test_parse_endpoint_rejects(value, default_host, message) -> None:
with pytest.raises(argparse.ArgumentTypeError, match=message):
fwd.parse_endpoint(value, default_host=default_host)
def test_is_loopback() -> None:
assert fwd.is_loopback("localhost")
assert fwd.is_loopback("127.0.0.1")
assert fwd.is_loopback("::1")
assert not fwd.is_loopback("192.168.1.10")
assert not fwd.is_loopback("studio.local")
# --- describe_connect_error -------------------------------------------------
def test_describe_connect_error(monkeypatch: pytest.MonkeyPatch) -> None:
assert "timed out" in fwd.describe_connect_error(asyncio.TimeoutError())
refused = OSError(errno.ECONNREFUSED, "refused")
assert "connection refused" in fwd.describe_connect_error(refused)
unreachable = OSError(errno.EHOSTUNREACH, "No route to host")
monkeypatch.setattr(sys, "platform", "darwin")
assert "Local Network" in fwd.describe_connect_error(unreachable)
monkeypatch.setattr(sys, "platform", "linux")
assert fwd.describe_connect_error(unreachable) == str(unreachable)
assert fwd.describe_connect_error(RuntimeError()) == "RuntimeError"
# --- relaying ---------------------------------------------------------------
def test_relays_both_directions_with_half_close() -> None:
async def scenario():
upstream, upstream_port = await _echo_server()
relay = await fwd.start(("127.0.0.1", 0), ("127.0.0.1", upstream_port), 2)
port = relay.sockets[0].getsockname()[1]
try:
reader, writer = await asyncio.open_connection("127.0.0.1", port)
payload = b"x" * (fwd.BUFFER_SIZE * 3) # more than one read
writer.write(payload)
await writer.drain()
writer.write_eof()
reply = await asyncio.wait_for(reader.read(), 5)
writer.close()
return reply, payload
finally:
relay.close()
upstream.close()
await relay.wait_closed()
await upstream.wait_closed()
reply, payload = asyncio.run(scenario())
assert reply == b"echo:" + payload
def test_unreachable_target_closes_the_client(caplog: pytest.LogCaptureFixture) -> None:
dead_port = _free_port()
async def scenario():
relay = await fwd.start(("127.0.0.1", 0), ("127.0.0.1", dead_port), 2)
port = relay.sockets[0].getsockname()[1]
try:
reader, writer = await asyncio.open_connection("127.0.0.1", port)
data = await asyncio.wait_for(reader.read(), 5)
writer.close()
return data
finally:
relay.close()
await relay.wait_closed()
with caplog.at_level(logging.INFO, logger="lan_forward"):
assert asyncio.run(scenario()) == b""
messages = "\n".join(record.getMessage() for record in caplog.records)
assert "is not reachable yet" in messages
assert "cannot reach" in messages
def test_probe_reports_reachable_and_timeout(monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture) -> None:
async def reachable():
upstream, upstream_port = await _echo_server()
relay = await fwd.start(("127.0.0.1", 0), ("127.0.0.1", upstream_port), 2)
relay.close()
upstream.close()
await relay.wait_closed()
await upstream.wait_closed()
with caplog.at_level(logging.INFO, logger="lan_forward"):
asyncio.run(reachable())
assert any("is reachable" in r.getMessage() for r in caplog.records)
async def slow(self):
raise asyncio.TimeoutError()
monkeypatch.setattr(fwd.Forwarder, "open_upstream", slow)
assert "timed out" in asyncio.run(fwd.Forwarder(("10.0.0.1", 1), 0.01).probe())
class _Writer:
def __init__(self, *, can_eof=True, eof_error=None, close_error=None):
self.data = b""
self.can_eof = can_eof
self.eof_error = eof_error
self.close_error = close_error
self.eof = False
self.closed = False
def write(self, data):
self.data += data
async def drain(self):
return None
def can_write_eof(self):
return self.can_eof
def write_eof(self):
if self.eof_error:
raise self.eof_error
self.eof = True
def close(self):
self.closed = True
async def wait_closed(self):
if self.close_error:
raise self.close_error
class _Reader:
def __init__(self, *chunks, error=None):
self.chunks = list(chunks)
self.error = error
async def read(self, _size=-1):
if self.chunks:
return self.chunks.pop(0)
if self.error:
raise self.error
return b""
def test_pipe_edge_cases() -> None:
writer = _Writer()
asyncio.run(fwd._pipe(_Reader(b"a", b"b", error=ConnectionResetError()), writer))
assert writer.data == b"ab" and writer.eof
no_eof = _Writer(can_eof=False)
asyncio.run(fwd._pipe(_Reader(b"a"), no_eof))
assert not no_eof.eof
failing_eof = _Writer(eof_error=RuntimeError("closed"))
asyncio.run(fwd._pipe(_Reader(), failing_eof))
def test_close_swallows_connection_errors() -> None:
writer = _Writer(close_error=ConnectionResetError())
asyncio.run(fwd._close(writer))
assert writer.closed
def test_serve_runs_until_cancelled(caplog: pytest.LogCaptureFixture) -> None:
async def scenario():
task = asyncio.ensure_future(fwd.serve(("127.0.0.1", 0), ("127.0.0.1", _free_port()), 1))
await asyncio.sleep(0.2)
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
with caplog.at_level(logging.INFO, logger="lan_forward"):
asyncio.run(scenario())
assert any("server_url to http://127.0.0.1:0" in r.getMessage() for r in caplog.records)
# --- command line -----------------------------------------------------------
def _fake_run(exc):
def run(coro):
coro.close()
if exc:
raise exc
return run
def test_main_rejects_bad_arguments() -> None:
with pytest.raises(SystemExit):
fwd.main([])
with pytest.raises(SystemExit):
fwd.main(["--target", "no-port"])
with pytest.raises(SystemExit):
fwd.main(["--target", "127.0.0.1:1234"]) # would forward to itself
def test_main_exit_codes(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
monkeypatch.setattr(fwd.asyncio, "run", _fake_run(KeyboardInterrupt()))
assert fwd.main(["--target", "192.168.1.10:1234", "--quiet"]) == 0
monkeypatch.setattr(fwd.asyncio, "run", _fake_run(OSError(errno.EADDRINUSE, "in use")))
with caplog.at_level(logging.INFO, logger="lan_forward"):
assert fwd.main(["--target", "192.168.1.10:1234", "--listen", "0.0.0.0:11234"]) == 1
messages = "\n".join(r.getMessage() for r in caplog.records)
assert "already in use" in messages
assert "exposes your LM Studio server" in messages
monkeypatch.setattr(fwd.asyncio, "run", _fake_run(OSError(errno.EACCES, "denied")))
with caplog.at_level(logging.INFO, logger="lan_forward"):
assert fwd.main(["--target", "192.168.1.10:1234", "--listen", "80"]) == 1
assert any("cannot listen" in r.getMessage() for r in caplog.records)
+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",
reasoning_enabled=False,
thinking="auto",
max_tokens=64,
temperature=0.5,
timeout_seconds=10,
+269
View File
@@ -0,0 +1,269 @@
from __future__ import annotations
import asyncio
import errno
import json
import socket
import sys
from types import SimpleNamespace
import pytest
from helpers.imports import import_repo_module
def _network():
return import_repo_module("network")
def _blocked_error() -> Exception:
"""The exception chain the OpenAI client produces when macOS denies a LAN connection."""
try:
try:
raise OSError(errno.EHOSTUNREACH, "No route to host")
except OSError as os_error:
raise ConnectionError("Connection error.") from os_error
except ConnectionError as exc:
return exc
# --- find_errno -------------------------------------------------------------
def test_find_errno_reads_oserror_cause_and_context() -> None:
network = _network()
assert network.find_errno(OSError(errno.ECONNREFUSED, "refused")) == errno.ECONNREFUSED
assert network.find_errno(_blocked_error()) == errno.EHOSTUNREACH
try:
try:
raise OSError(errno.EHOSTUNREACH, "No route to host")
except OSError:
raise RuntimeError("wrapped without from")
except RuntimeError as exc:
assert network.find_errno(exc) == errno.EHOSTUNREACH
def test_find_errno_falls_back_to_message_text() -> None:
network = _network()
assert network.find_errno(RuntimeError("httpcore: [Errno 65] No route to host")) == 65
# An OSError without an errno still gets its text checked.
assert network.find_errno(OSError("[Errno 61] Connection refused")) == 61
def test_find_errno_none_and_cycles() -> None:
network = _network()
assert network.find_errno(ValueError("nothing here")) is None
first, second = RuntimeError("a"), RuntimeError("b")
first.__cause__, second.__cause__ = second, first
assert network.find_errno(first) is None
# --- is_local_network_host ---------------------------------------------------
@pytest.mark.parametrize(
"host",
["10.0.0.5", "172.16.3.4", "192.168.1.10", "fe80::1%en0", "fd00::7",
"lmstudio.local", "lmstudio", "studio-box."],
)
def test_local_network_hosts(host: str) -> None:
assert _network().is_local_network_host(host) is True
@pytest.mark.parametrize("host", [None, "", "127.0.0.1", "::1", "8.8.8.8", "localhost"])
def test_not_local_network_hosts(host) -> None:
assert _network().is_local_network_host(host) is False
def test_hostnames_are_resolved(monkeypatch: pytest.MonkeyPatch) -> None:
network = _network()
def fake_getaddrinfo(host, port):
table = {"lan.example.com": "192.168.1.10", "public.example.com": "93.184.216.34"}
if host not in table:
raise socket.gaierror("unknown host")
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (table[host], 0))]
monkeypatch.setattr(network.socket, "getaddrinfo", fake_getaddrinfo)
assert network.is_local_network_host("lan.example.com") is True
assert network.is_local_network_host("public.example.com") is False
assert network.is_local_network_host("missing.example.com") is False
# --- local_network_hint -----------------------------------------------------
def test_hint_only_on_macos_for_blocked_lan_connections() -> None:
network = _network()
blocked = _blocked_error()
url = "http://192.168.1.10:1234"
assert network.local_network_hint(blocked, url, platform="linux") is None
refused = OSError(errno.ECONNREFUSED, "Connection refused")
assert network.local_network_hint(refused, url, platform="darwin") is None
assert network.local_network_hint(blocked, "http://8.8.8.8:1234", platform="darwin") is None
assert network.local_network_hint(blocked, url, platform="darwin") is not None
def test_hint_is_built_from_the_users_own_server_url() -> None:
network = _network()
hint = network.local_network_hint(
_blocked_error(), "http://192.168.50.23:4321", platform="darwin"
)
assert "Local Network" in hint
assert "http://192.168.50.23:4321" in hint
assert "--target 192.168.50.23:4321" in hint
assert "server_url to http://127.0.0.1:4321" in hint
assert str(network.FORWARDER_SCRIPT) in hint
assert network.FORWARDER_SCRIPT.is_file()
assert "certificate" not in hint
def test_hint_default_ports_ipv6_and_https() -> None:
network = _network()
blocked = _blocked_error()
http_hint = network.local_network_hint(blocked, "http://studio.local", platform="darwin")
assert "--target studio.local:80" in http_hint
https_hint = network.local_network_hint(blocked, "https://[fd00::7]", platform="darwin")
assert "--target [fd00::7]:443" in https_hint
assert "server_url to https://127.0.0.1:443" in https_hint
assert "certificate" in https_hint
def test_hint_defaults_to_the_running_platform(monkeypatch: pytest.MonkeyPatch) -> None:
network = _network()
monkeypatch.setattr(network, "sys", SimpleNamespace(platform="darwin"))
assert network.local_network_hint(_blocked_error(), "http://10.0.0.2:1234") is not None
monkeypatch.setattr(network, "sys", SimpleNamespace(platform="win32"))
assert network.local_network_hint(_blocked_error(), "http://10.0.0.2:1234") is None
# --- explain_network_errors -------------------------------------------------
def test_explain_network_errors_passes_through(monkeypatch: pytest.MonkeyPatch) -> None:
network = _network()
monkeypatch.setattr(network, "sys", SimpleNamespace(platform="darwin"))
with network.explain_network_errors("http://10.0.0.2:1234"):
pass
with pytest.raises(ValueError, match="unrelated"):
with network.explain_network_errors("http://10.0.0.2:1234"):
raise ValueError("unrelated")
def test_explain_network_errors_rewrites_blocked_connections(monkeypatch: pytest.MonkeyPatch) -> None:
network = _network()
monkeypatch.setattr(network, "sys", SimpleNamespace(platform="darwin"))
original = _blocked_error()
with pytest.raises(network.LocalNetworkBlockedError) as caught:
with network.explain_network_errors("http://10.0.0.2:1234"):
raise original
assert caught.value.__cause__ is original
assert isinstance(caught.value, ConnectionError)
assert "--target 10.0.0.2:1234" in str(caught.value)
def test_real_openai_client_error_chain_is_recognised(monkeypatch: pytest.MonkeyPatch) -> None:
"""The exact shape of the failure reported by users: openai -> httpx -> OSError(65)."""
httpx = pytest.importorskip("httpx")
openai = pytest.importorskip("openai")
network = _network()
monkeypatch.setattr(network, "sys", SimpleNamespace(platform="darwin"))
def handler(request):
try:
raise OSError(errno.EHOSTUNREACH, "No route to host")
except OSError as exc:
raise httpx.ConnectError("[Errno 65] No route to host", request=request) from exc
client = openai.OpenAI(
api_key="lm-studio",
base_url="http://192.168.1.10:1234/v1",
max_retries=0,
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
with pytest.raises(network.LocalNetworkBlockedError) as caught:
with network.explain_network_errors("http://192.168.1.10:1234"):
client.models.list()
assert isinstance(caught.value.__cause__, openai.APIConnectionError)
# --- wired into the nodes and routes ----------------------------------------
def _darwin(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(_network(), "sys", SimpleNamespace(platform="darwin"))
def _raise_blocked(**_kwargs):
raise _blocked_error()
def test_connect_node_explains_blocked_lan(monkeypatch: pytest.MonkeyPatch) -> None:
connect_node = import_repo_module("connect_node", force_reload=True)
_darwin(monkeypatch)
monkeypatch.setattr(connect_node, "get_server_models", _raise_blocked)
with pytest.raises(ConnectionError, match="Local Network"):
connect_node.LMStudioConnect.execute(
server_url="http://192.168.1.10:1234", api_token="-", model="m",
thinking="auto", test_connectivity=True, max_tokens=64,
temperature=0.5, timeout_seconds=5, use_tooling_mcp=False,
)
def test_test_route_explains_blocked_lan(monkeypatch: pytest.MonkeyPatch) -> None:
routes = import_repo_module("routes", force_reload=True)
_darwin(monkeypatch)
monkeypatch.setattr(routes, "get_server_models", _raise_blocked)
request = SimpleNamespace(query={"server_url": "http://192.168.1.10:1234"})
response = asyncio.run(routes._test_handler(request))
assert response.status == 502
error = json.loads(response.text)["error"]
assert "Local Network" in error
assert "--target 192.168.1.10:1234" in error
def test_text_gen_explains_blocked_lan(monkeypatch: pytest.MonkeyPatch) -> None:
text_gen_node = import_repo_module("text_gen_node", force_reload=True)
models = import_repo_module("models")
_darwin(monkeypatch)
fake_client = SimpleNamespace(
responses=SimpleNamespace(create=lambda **_: (_ for _ in ()).throw(_blocked_error())),
chat=SimpleNamespace(
completions=SimpleNamespace(create=lambda **_: (_ for _ in ()).throw(_blocked_error()))
),
)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
connection = models.LMStudioConnectionPayload(
server_url="http://192.168.1.10:1234", base_url="http://192.168.1.10:1234/v1",
api_key="lm-studio", model="m", thinking="off", max_tokens=64,
temperature=0.5, timeout_seconds=5, use_tooling_mcp=False,
)
with pytest.raises(ConnectionError, match="Local Network"):
text_gen_node.LMStudioTextGen.execute(
connection=connection, system_prompt="", user_prompt="hi", seed=1,
)
def test_other_platforms_keep_the_original_error(monkeypatch: pytest.MonkeyPatch) -> None:
connect_node = import_repo_module("connect_node", force_reload=True)
monkeypatch.setattr(_network(), "sys", SimpleNamespace(platform="linux"))
monkeypatch.setattr(connect_node, "get_server_models", _raise_blocked)
with pytest.raises(ConnectionError) as caught:
connect_node.LMStudioConnect.execute(
server_url="http://192.168.1.10:1234", api_token="-", model="m",
thinking="auto", test_connectivity=True, max_tokens=64,
temperature=0.5, timeout_seconds=5, use_tooling_mcp=False,
)
assert str(caught.value) == "Connection error."
assert sys.platform # the real platform is untouched
+83 -8
View File
@@ -11,18 +11,18 @@ def _text_module():
return import_repo_module("text_gen_node", force_reload=True)
def _connection_payload():
def _connection_payload(*, thinking: str = "auto", use_tooling_mcp: bool = False, model: str = "model-a"):
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-a",
reasoning_enabled=False,
model=model,
thinking=thinking,
max_tokens=111,
temperature=0.4,
timeout_seconds=30,
use_tooling_mcp=False,
use_tooling_mcp=use_tooling_mcp,
)
@@ -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()
connection = _connection_payload()
connection = connection.__class__(**{**connection.__dict__, "reasoning_enabled": True, "use_tooling_mcp": True})
# thinking="on" forces reasoning on (effort + enable_thinking:true kwarg).
connection = _connection_payload(thinking="on", use_tooling_mcp=True)
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
connection=connection,
@@ -54,11 +54,27 @@ 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,
@@ -68,9 +84,24 @@ 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"):
@@ -119,21 +150,28 @@ 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=lambda **kwargs: completion)),
chat=SimpleNamespace(completions=SimpleNamespace(create=create_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(),
connection=_connection_payload(thinking="off"),
system_prompt="sys",
user_prompt="hello",
seed=0,
@@ -142,6 +180,8 @@ 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(
@@ -168,3 +208,38 @@ def test_execute_raises_if_both_endpoints_return_no_text(
user_prompt="hello",
seed=1,
)
def test_execute_raises_when_all_text_is_think_blocks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Regression: when the model returns only <think> content (no visible text),
strip_think_content reduces the response to "". The node must raise
ValueError rather than silently returning an empty string.
"""
text_gen_node = _text_module()
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: SimpleNamespace(output_text="<think>internal only</think>")
),
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(
AssertionError("chat fallback should not run")
)
)
),
)
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 1)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
with pytest.raises(ValueError, match="All content was inside <think> blocks"):
text_gen_node.LMStudioTextGen.execute(
connection=_connection_payload(),
system_prompt="sys",
user_prompt="hello",
seed=1,
)
+72
View File
@@ -0,0 +1,72 @@
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
+32 -8
View File
@@ -5,16 +5,21 @@ 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
from .network import explain_network_errors
class LMStudioTextGen(io.ComfyNode):
@@ -87,8 +92,13 @@ class LMStudioTextGen(io.ComfyNode):
}
if system_prompt.strip():
kwargs["instructions"] = system_prompt
if connection.reasoning_enabled:
mode = resolve_thinking_mode(connection.thinking, connection.model)
if mode == THINK_MODE_FORCE:
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.
@@ -136,19 +146,33 @@ class LMStudioTextGen(io.ComfyNode):
via_endpoint = "chat.completions"
fallback_reason = str(responses_error)
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,
mode = resolve_thinking_mode(connection.thinking, connection.model)
messages = apply_thinking_prefill(
build_chat_messages(system_prompt, user_prompt), mode
)
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}
with explain_network_errors(connection.server_url):
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")
text = strip_think_content(text)
if not text:
raise ValueError(
"All content was inside <think> blocks; no visible text was produced."
)
status = f"Model '{connection.model}' via {via_endpoint} (seed={resolved_seed})."
if fallback_reason:
+83 -112
View File
@@ -30,6 +30,12 @@ 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 [];
@@ -47,68 +53,46 @@ function normalizeModelList(models) {
return out;
}
function ensureModelDropdown(node) {
if (node.__lmstudioModelDropdown && node.__lmstudioModelTextWidget) {
return {
comboWidget: node.__lmstudioModelDropdown,
textWidget: node.__lmstudioModelTextWidget,
};
// 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;
}
const modelTextWidget = getWidget(node, "model");
if (!modelTextWidget) {
return null;
const current = String(widget.value ?? "").trim();
const options = normalizeModelList(widget.options.values || []);
if (options.length === 0) {
options.push(MODEL_PLACEHOLDER);
}
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);
if (current && current !== MODEL_PLACEHOLDER && !options.includes(current)) {
options.unshift(current);
}
node.__lmstudioModelDropdown = comboWidget;
node.__lmstudioModelTextWidget = modelTextWidget;
return { comboWidget, textWidget: modelTextWidget };
widget.options.values = options;
}
function syncDropdownFromText(node) {
const refs = ensureModelDropdown(node);
if (!refs) {
function applyModelOptions(node, models) {
const widget = getModelWidget(node);
if (!widget?.options) {
return;
}
const { comboWidget, textWidget } = refs;
const currentModel = String(textWidget.value ?? "").trim();
const options = normalizeModelList(comboWidget?.options?.values || []);
if (!currentModel) {
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];
}
if (!options.includes(currentModel)) {
options.unshift(currentModel);
comboWidget.options.values = options;
}
comboWidget.value = currentModel;
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);
}
function buildQuery(node) {
@@ -147,49 +131,40 @@ async function fetchJson(path) {
return payload;
}
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);
}
function notifySuccess(message) {
function notify(severity, message) {
const summary = severity === "error" ? "LMStudio — Error" : "LMStudio";
if (typeof app.extensionManager?.toast?.add === "function") {
app.extensionManager.toast.add({
severity: "success",
summary: "LMStudio",
severity,
summary,
detail: message,
life: 3500,
life: severity === "error" ? 6000 : 3500,
});
} else if (severity === "error") {
console.error(`[LMStudio] ${message}`);
} else {
console.info(`[LMStudio] ${message}`);
}
}
// 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 fitNodeToWidgets(node) {
if (typeof node.computeSize !== "function" || typeof node.setSize !== "function") {
return;
@@ -267,13 +242,14 @@ function attachButtons(node) {
return;
}
ensureModelDropdown(node);
syncDropdownFromText(node);
ensureSelectionInOptions(node);
const refreshModels = async () => {
const query = buildQuery(node);
const payload = await fetchJson(`/lmstudio/models?${query.toString()}`);
applyModelOptions(node, payload.models || []);
const models = payload.models || [];
applyModelOptions(node, models);
notify("success", `Loaded ${models.length} model(s). Pick one from the Model dropdown.`);
};
const testConnection = async () => {
@@ -281,30 +257,26 @@ function attachButtons(node) {
const payload = await fetchJson(`/lmstudio/test?${query.toString()}`);
const models = payload.models || [];
applyModelOptions(node, models);
const message = payload.message || `Connected. ${models.length} model(s) available.`;
notifySuccess(message);
notify("success", payload.message || `Connected. ${models.length} model(s) available.`);
};
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 refreshButton = node.addWidget("button", "🔄 Refresh Models", null, () =>
runWithButtonFeedback(
refreshButton,
"Refreshing…",
refreshModels,
"Model refresh 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}`);
}
});
const testButton = node.addWidget("button", "🔌 Test Connection", null, () =>
runWithButtonFeedback(
testButton,
"Testing…",
testConnection,
"Connectivity test failed"
)
);
node.__lmstudioButtonsAttached = true;
}
@@ -322,8 +294,7 @@ app.registerExtension({
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
ensureModelDropdown(this);
syncDropdownFromText(this);
ensureSelectionInOptions(this);
};
}