Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2301e15111 |
@@ -8,15 +8,11 @@ Small ComfyUI custom nodes to connect to a remote LMStudio server through OpenAI
|
||||
|
||||
- **LMStudio - Connect**
|
||||
- Connect to a remote LMStudio URL
|
||||
- Pick the model from a dropdown populated by **Refresh Models**
|
||||
- **Test connectivity** to the server
|
||||
- **Thinking / Reasoning** control — `auto` / `on` / `off`:
|
||||
- `auto` — defer to the model, but force reasoning on for families that stay
|
||||
silent by default (Gemma). Qwen3-style models already think by default.
|
||||
- `on` — force reasoning on (`enable_thinking: true`).
|
||||
- `off` — suppress reasoning so the model answers directly and doesn't burn
|
||||
its token budget on a hidden `<think>` trace.
|
||||
- Advanced: API token, Tooling / MCP toggle, max tokens, temperature, timeout
|
||||
- Set API token
|
||||
- Pull/load model list automatically (Refresh Models)
|
||||
- Test connectivity to server
|
||||
- Supports LM **Reasonning** mode toggle
|
||||
- Supports Tooling / MCP toggle
|
||||
|
||||
- **LMStudio - Text Gen**
|
||||
- System prompt + user prompt
|
||||
@@ -37,49 +33,6 @@ 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%`):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
@@ -22,13 +23,6 @@ DEFAULT_API_KEY_PLACEHOLDER = "-"
|
||||
DEFAULT_API_KEY_FALLBACK = "lm-studio"
|
||||
MODEL_PLACEHOLDER = "<refresh models>"
|
||||
|
||||
# User-facing thinking/reasoning choices exposed on the Connect node.
|
||||
THINKING_AUTO = "auto"
|
||||
THINKING_ON = "on"
|
||||
THINKING_OFF = "off"
|
||||
THINKING_OPTIONS = [THINKING_AUTO, THINKING_ON, THINKING_OFF]
|
||||
DEFAULT_THINKING = THINKING_AUTO
|
||||
|
||||
|
||||
def normalize_server_url(server_url: str) -> str:
|
||||
value = (server_url or "").strip()
|
||||
@@ -41,9 +35,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 (case-insensitive).
|
||||
# Ensure no trailing slash and no duplicate /v1.
|
||||
value = value.rstrip("/")
|
||||
if value.lower().endswith("/v1"):
|
||||
if value.endswith("/v1"):
|
||||
value = value[:-3]
|
||||
return value
|
||||
|
||||
@@ -239,10 +233,9 @@ def extract_responses_text(response: Any) -> str:
|
||||
text = getattr(entry, "text", None)
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
else:
|
||||
text = getattr(item, "text", None)
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
text = getattr(item, "text", None)
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
|
||||
if parts:
|
||||
return "\n".join(parts)
|
||||
@@ -250,90 +243,30 @@ 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 and any
|
||||
orphan </think> closing tags left by nested or malformed think blocks.
|
||||
Also removes dangling <think> blocks without a closing tag.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
cleaned = _THINK_BLOCK_RE.sub("", text)
|
||||
cleaned = _THINK_START_RE.sub("", cleaned)
|
||||
# After the above two passes, any remaining </think> tags are orphaned
|
||||
# closing tags produced by nested blocks (e.g. <think><think>…</think></think>).
|
||||
# The non-greedy block regex only removes up to the *first* </think>, leaving
|
||||
# the outer one behind; strip it here so it never appears in visible output.
|
||||
cleaned = _THINK_CLOSE_RE.sub("", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
|
||||
+30
-71
@@ -5,20 +5,13 @@ 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
|
||||
|
||||
|
||||
@@ -31,8 +24,7 @@ class LMStudioConnect(io.ComfyNode):
|
||||
category="LMStudio",
|
||||
description=(
|
||||
"Creates a reusable LMStudio connection for downstream nodes. "
|
||||
"Set the Server URL, then click 'Refresh Models' to load and pick a model. "
|
||||
"'Test Connection' verifies the server is reachable."
|
||||
"Use the refresh/test buttons on this node to fetch models from the remote server."
|
||||
),
|
||||
inputs=[
|
||||
io.String.Input(
|
||||
@@ -40,52 +32,41 @@ class LMStudioConnect(io.ComfyNode):
|
||||
display_name="Server URL",
|
||||
default="http://127.0.0.1:1234",
|
||||
placeholder="http://10.168.168.7:1234",
|
||||
tooltip="LMStudio server address, e.g. http://127.0.0.1:1234 (no /v1).",
|
||||
),
|
||||
io.Combo.Input(
|
||||
id="model",
|
||||
display_name="Model",
|
||||
options=[MODEL_PLACEHOLDER],
|
||||
default=MODEL_PLACEHOLDER,
|
||||
tooltip=(
|
||||
"Loaded model to use. Click 'Refresh Models' to populate this "
|
||||
"dropdown from the server, then pick one."
|
||||
),
|
||||
),
|
||||
io.Combo.Input(
|
||||
id="thinking",
|
||||
display_name="Thinking / Reasoning",
|
||||
options=list(THINKING_OPTIONS),
|
||||
default=DEFAULT_THINKING,
|
||||
tooltip=(
|
||||
"Controls the model's reasoning phase.\n"
|
||||
"• auto — let the model decide; Gemma-family models are nudged on "
|
||||
"(they stay silent otherwise).\n"
|
||||
"• on — force reasoning on (enable_thinking:true).\n"
|
||||
"• off — suppress reasoning so the model answers directly, saving tokens."
|
||||
),
|
||||
tooltip="LMStudio server URL (without /v1).",
|
||||
),
|
||||
io.String.Input(
|
||||
id="api_token",
|
||||
display_name="API Token",
|
||||
default="-",
|
||||
placeholder="Leave '-' for lm-studio",
|
||||
tooltip="Bearer token for the LMStudio OpenAI-compatible API. Leave '-' for local servers.",
|
||||
advanced=True,
|
||||
tooltip="Bearer token for LMStudio OpenAI-compatible API.",
|
||||
),
|
||||
io.String.Input(
|
||||
id="model",
|
||||
display_name="Model",
|
||||
default=MODEL_PLACEHOLDER,
|
||||
tooltip=(
|
||||
"Model id. The web extension renders this field as a dropdown and syncs the selected model value."
|
||||
),
|
||||
),
|
||||
io.Boolean.Input(
|
||||
id="reasoning_enabled",
|
||||
display_name="Enable Reasoning",
|
||||
default=False,
|
||||
tooltip="Enable model reasoning hints when using the responses endpoint.",
|
||||
),
|
||||
io.Boolean.Input(
|
||||
id="use_tooling_mcp",
|
||||
display_name="Use Tooling / MCP",
|
||||
default=False,
|
||||
tooltip=(
|
||||
"Signal intent to use MCP tooling. Only useful when the target "
|
||||
"model/session is configured for tool-enabled responses."
|
||||
"Expose intent to use MCP tooling. Useful only when your target model/session is configured "
|
||||
"for tool-enabled responses."
|
||||
),
|
||||
advanced=True,
|
||||
),
|
||||
io.Int.Input(
|
||||
id="max_tokens",
|
||||
display_name="Max Tokens",
|
||||
display_name="Max Token",
|
||||
default=DEFAULT_MAX_TOKENS,
|
||||
min=1,
|
||||
max=1_000_000,
|
||||
@@ -134,17 +115,7 @@ class LMStudioConnect(io.ComfyNode):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_inputs(
|
||||
cls,
|
||||
server_url: str,
|
||||
timeout_seconds: int,
|
||||
max_tokens: int,
|
||||
model: str | None = None,
|
||||
) -> bool | str:
|
||||
# `model` is accepted here purely so ComfyUI skips its built-in combo
|
||||
# "value not in list" check: the real option list is only known at
|
||||
# runtime (fetched from the server), so the schema ships a placeholder.
|
||||
# Actual model availability is validated in execute().
|
||||
def validate_inputs(cls, server_url: str, timeout_seconds: int, max_tokens: int) -> bool | str:
|
||||
try:
|
||||
normalize_server_url(server_url)
|
||||
except ValueError as exc:
|
||||
@@ -162,7 +133,7 @@ class LMStudioConnect(io.ComfyNode):
|
||||
server_url: str,
|
||||
api_token: str | None,
|
||||
model: str,
|
||||
thinking: str,
|
||||
reasoning_enabled: bool,
|
||||
test_connectivity: bool,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
@@ -175,12 +146,11 @@ class LMStudioConnect(io.ComfyNode):
|
||||
|
||||
should_probe_models = test_connectivity or model_name in {"", MODEL_PLACEHOLDER}
|
||||
if should_probe_models:
|
||||
with explain_network_errors(normalized_server_url):
|
||||
models = get_server_models(
|
||||
server_url=normalized_server_url,
|
||||
api_key=api_token,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
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:
|
||||
@@ -201,32 +171,21 @@ class LMStudioConnect(io.ComfyNode):
|
||||
base_url=f"{normalized_server_url}/v1",
|
||||
api_key=normalize_api_key(api_token),
|
||||
model=model_name,
|
||||
thinking=thinking,
|
||||
reasoning_enabled=reasoning_enabled,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
timeout_seconds=timeout_seconds,
|
||||
use_tooling_mcp=use_tooling_mcp,
|
||||
)
|
||||
|
||||
think_mode = resolve_thinking_mode(thinking, model_name)
|
||||
think_label = {
|
||||
THINK_MODE_FORCE: "on",
|
||||
THINK_MODE_SUPPRESS: "off",
|
||||
THINK_MODE_DEFAULT: "model default",
|
||||
}[think_mode]
|
||||
thinking_note = f" Thinking: {thinking} ({think_label})."
|
||||
|
||||
model_count = len(models)
|
||||
if should_probe_models:
|
||||
status = (
|
||||
f"Connected to {normalized_server_url}. "
|
||||
f"Found {model_count} model(s). Using '{model_name}'.{thinking_note}"
|
||||
f"Found {model_count} model(s). Using '{model_name}'."
|
||||
)
|
||||
else:
|
||||
status = (
|
||||
f"Connection prepared for {normalized_server_url}. "
|
||||
f"Using '{model_name}'.{thinking_note}"
|
||||
)
|
||||
status = f"Connection prepared for {normalized_server_url}. Using '{model_name}'."
|
||||
|
||||
return io.NodeOutput(
|
||||
payload,
|
||||
|
||||
+22
-42
@@ -5,21 +5,16 @@ 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):
|
||||
@@ -116,14 +111,8 @@ class LMStudioImageToText(io.ComfyNode):
|
||||
}
|
||||
if system_prompt.strip():
|
||||
kwargs["instructions"] = system_prompt
|
||||
|
||||
mode = resolve_thinking_mode(connection.thinking, connection.model)
|
||||
if mode == THINK_MODE_FORCE:
|
||||
if connection.reasoning_enabled:
|
||||
kwargs["reasoning"] = {"effort": "medium"}
|
||||
template_kwargs = thinking_chat_template_kwargs(mode)
|
||||
if template_kwargs is not None:
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": template_kwargs}
|
||||
|
||||
if connection.use_tooling_mcp:
|
||||
kwargs["metadata"] = {"lmstudio_tooling_mcp_requested": "true"}
|
||||
|
||||
@@ -175,32 +164,27 @@ class LMStudioImageToText(io.ComfyNode):
|
||||
first_image = image[0] if is_batch else image
|
||||
image_data_url = comfy_image_to_base64_png_url(first_image)
|
||||
|
||||
mode = resolve_thinking_mode(connection.thinking, connection.model)
|
||||
messages = apply_thinking_prefill(
|
||||
build_chat_messages_with_image(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
image_data_url=image_data_url,
|
||||
),
|
||||
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")
|
||||
try:
|
||||
completion = client.chat.completions.create(
|
||||
model=connection.model,
|
||||
messages=build_chat_messages_with_image(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
image_data_url=image_data_url,
|
||||
),
|
||||
seed=resolved_seed,
|
||||
temperature=connection.temperature,
|
||||
max_tokens=connection.max_tokens,
|
||||
n=1,
|
||||
)
|
||||
text = extract_chat_completion_text(completion).strip()
|
||||
if not text:
|
||||
raise ValueError("chat.completions fallback returned no text output")
|
||||
except Exception as chat_error:
|
||||
raise RuntimeError(
|
||||
f"Both endpoints failed. responses error: {fallback_reason}. "
|
||||
f"chat.completions error: {chat_error}"
|
||||
) from chat_error
|
||||
|
||||
if batch_size > 1:
|
||||
fallback_reason += (
|
||||
@@ -209,10 +193,6 @@ 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:
|
||||
|
||||
@@ -7,7 +7,7 @@ class LMStudioConnectionPayload:
|
||||
base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
thinking: str
|
||||
reasoning_enabled: bool
|
||||
max_tokens: int
|
||||
temperature: float
|
||||
timeout_seconds: int
|
||||
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
"""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
|
||||
@@ -13,7 +13,6 @@ dependencies = [
|
||||
test = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=5.0.0",
|
||||
"aiohttp>=3.9.0",
|
||||
]
|
||||
|
||||
[tool.comfy]
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -30,12 +29,11 @@ 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)
|
||||
with explain_network_errors(normalized_url):
|
||||
models = get_server_models(
|
||||
server_url=normalized_url,
|
||||
api_key=api_token,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
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:
|
||||
@@ -66,12 +64,11 @@ 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)
|
||||
with explain_network_errors(normalized_url):
|
||||
models = get_server_models(
|
||||
server_url=normalized_url,
|
||||
api_key=api_token,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
#!/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())
|
||||
@@ -102,7 +102,6 @@ def _install_comfy_stubs() -> None:
|
||||
Int=_FieldType,
|
||||
Float=_FieldType,
|
||||
Image=_FieldType,
|
||||
Combo=_FieldType,
|
||||
Custom=_custom_type,
|
||||
)
|
||||
ui_module = types.SimpleNamespace(
|
||||
|
||||
@@ -14,6 +14,14 @@ 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)
|
||||
|
||||
@@ -1,545 +0,0 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
@@ -64,18 +64,3 @@ def test_strip_think_content_removes_dangling_start_tag() -> None:
|
||||
# strip() removes the trailing real newline left after the dangling block is excised.
|
||||
text = "Visible output\n<think>unfinished"
|
||||
assert client.strip_think_content(text) == "Visible output"
|
||||
|
||||
|
||||
def test_strip_think_content_nested_think_tags_no_orphan_close() -> None:
|
||||
# When a think block contains nested <think> tags (e.g. a model quoting the tag
|
||||
# in its own reasoning), the non-greedy regex matches up to the *first* </think>,
|
||||
# which would leave the outer closing tag as an orphan in the visible output.
|
||||
# The fix strips any remaining </think> tags after the block/dangling passes.
|
||||
assert client.strip_think_content("<think><think>nested</think></think>") == ""
|
||||
# Regression: visible text surrounding nested blocks must be preserved intact.
|
||||
result = client.strip_think_content(
|
||||
"Before <think>reasoning <think>deep</think></think> after"
|
||||
)
|
||||
assert "</think>" not in result
|
||||
assert "Before" in result
|
||||
assert "after" in result
|
||||
|
||||
+15
-51
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
@@ -29,18 +30,6 @@ 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"
|
||||
|
||||
@@ -194,48 +183,23 @@ def test_extract_responses_text_variants() -> None:
|
||||
assert client.extract_responses_text(ns(output_text="", output=[])) == ""
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
def test_dump_openai_response_branches() -> None:
|
||||
class WithModelDump:
|
||||
def model_dump(self):
|
||||
return {"a": 1}
|
||||
|
||||
class WithToDict:
|
||||
def to_dict(self):
|
||||
return {"b": 2}
|
||||
|
||||
class Plain:
|
||||
def __str__(self) -> str:
|
||||
return "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"
|
||||
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_strip_think_content_case_insensitive_and_multiple() -> None:
|
||||
|
||||
@@ -17,9 +17,9 @@ def test_define_schema_has_expected_order_and_defaults() -> None:
|
||||
input_ids = [entry.id for entry in schema.inputs]
|
||||
assert input_ids == [
|
||||
"server_url",
|
||||
"model",
|
||||
"thinking",
|
||||
"api_token",
|
||||
"model",
|
||||
"reasoning_enabled",
|
||||
"use_tooling_mcp",
|
||||
"max_tokens",
|
||||
"temperature",
|
||||
@@ -30,15 +30,9 @@ def test_define_schema_has_expected_order_and_defaults() -> None:
|
||||
by_id = {entry.id: entry for entry in schema.inputs}
|
||||
assert by_id["server_url"].default == "http://127.0.0.1:1234"
|
||||
assert by_id["api_token"].default == "-"
|
||||
assert by_id["model"].default == "<refresh models>"
|
||||
assert by_id["model"].options == ["<refresh models>"]
|
||||
assert by_id["thinking"].default == "auto"
|
||||
assert by_id["thinking"].options == ["auto", "on", "off"]
|
||||
assert by_id["max_tokens"].default == 1024
|
||||
assert by_id["temperature"].default == 0.7
|
||||
assert by_id["timeout_seconds"].default == 600
|
||||
assert by_id["api_token"].advanced is True
|
||||
assert by_id["use_tooling_mcp"].advanced is True
|
||||
assert by_id["max_tokens"].advanced is True
|
||||
assert by_id["temperature"].advanced is True
|
||||
assert by_id["timeout_seconds"].advanced is True
|
||||
@@ -50,8 +44,6 @@ def test_validate_inputs() -> None:
|
||||
cls = connect_node.LMStudioConnect
|
||||
|
||||
assert cls.validate_inputs("http://127.0.0.1:1234", 10, 128) is True
|
||||
# `model` is accepted (and ignored) so ComfyUI skips its combo validation.
|
||||
assert cls.validate_inputs("http://127.0.0.1:1234", 10, 128, model="any-model") is True
|
||||
assert "http://" in str(cls.validate_inputs("localhost:1234", 10, 128))
|
||||
assert cls.validate_inputs("http://127.0.0.1:1234", 0, 128) == "timeout_seconds must be >= 1"
|
||||
assert cls.validate_inputs("http://127.0.0.1:1234", 10, 0) == "max_tokens must be >= 1"
|
||||
@@ -67,7 +59,7 @@ def test_execute_with_placeholder_model_uses_discovered_default(
|
||||
server_url=" http://10.0.0.1:1234/v1/ ",
|
||||
api_token="-",
|
||||
model="<refresh models>",
|
||||
thinking="on",
|
||||
reasoning_enabled=True,
|
||||
test_connectivity=True,
|
||||
max_tokens=321,
|
||||
temperature=0.6,
|
||||
@@ -80,7 +72,7 @@ def test_execute_with_placeholder_model_uses_discovered_default(
|
||||
assert payload.server_url == "http://10.0.0.1:1234"
|
||||
assert payload.base_url == "http://10.0.0.1:1234/v1"
|
||||
assert payload.model == "m1"
|
||||
assert payload.thinking == "on"
|
||||
assert payload.reasoning_enabled is True
|
||||
assert payload.use_tooling_mcp is True
|
||||
assert payload.max_tokens == 321
|
||||
assert payload.temperature == 0.6
|
||||
@@ -101,7 +93,7 @@ def test_execute_without_probe_keeps_selected_model(monkeypatch: pytest.MonkeyPa
|
||||
server_url="http://127.0.0.1:1234",
|
||||
api_token="token",
|
||||
model="manually-selected-model",
|
||||
thinking="auto",
|
||||
reasoning_enabled=False,
|
||||
test_connectivity=False,
|
||||
max_tokens=128,
|
||||
temperature=0.5,
|
||||
@@ -126,7 +118,7 @@ def test_execute_raises_when_selected_model_not_on_server(
|
||||
server_url="http://127.0.0.1:1234",
|
||||
api_token="-",
|
||||
model="requested-model",
|
||||
thinking="auto",
|
||||
reasoning_enabled=False,
|
||||
test_connectivity=True,
|
||||
max_tokens=128,
|
||||
temperature=0.5,
|
||||
@@ -146,7 +138,7 @@ def test_execute_raises_when_no_model_selected_and_none_discovered(
|
||||
server_url="http://127.0.0.1:1234",
|
||||
api_token="-",
|
||||
model="<refresh models>",
|
||||
thinking="auto",
|
||||
reasoning_enabled=False,
|
||||
test_connectivity=True,
|
||||
max_tokens=128,
|
||||
temperature=0.5,
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,376 +0,0 @@
|
||||
"""
|
||||
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}"
|
||||
)
|
||||
@@ -13,14 +13,14 @@ def _image_module():
|
||||
return import_repo_module("image_to_text_node", force_reload=True)
|
||||
|
||||
|
||||
def _connection_payload(*, thinking: str = "auto", use_tooling_mcp: bool = False):
|
||||
def _connection_payload(*, reasoning_enabled: bool = False, use_tooling_mcp: bool = False):
|
||||
models = import_repo_module("models", force_reload=True)
|
||||
return models.LMStudioConnectionPayload(
|
||||
server_url="http://127.0.0.1:1234",
|
||||
base_url="http://127.0.0.1:1234/v1",
|
||||
api_key="token",
|
||||
model="vision-model",
|
||||
thinking=thinking,
|
||||
reasoning_enabled=reasoning_enabled,
|
||||
max_tokens=128,
|
||||
temperature=0.3,
|
||||
timeout_seconds=30,
|
||||
@@ -73,7 +73,7 @@ def test_responses_kwargs_single_and_batch() -> None:
|
||||
assert single_content[1]["type"] == "input_image"
|
||||
|
||||
kwargs_batch = image_node.LMStudioImageToText._responses_kwargs(
|
||||
connection=_connection_payload(thinking="on", use_tooling_mcp=True),
|
||||
connection=_connection_payload(reasoning_enabled=True, use_tooling_mcp=True),
|
||||
image=_batch_image(),
|
||||
system_prompt="sys",
|
||||
user_prompt="describe",
|
||||
@@ -82,7 +82,6 @@ def test_responses_kwargs_single_and_batch() -> None:
|
||||
batch_content = kwargs_batch["input"][0]["content"]
|
||||
assert len(batch_content) == 3
|
||||
assert kwargs_batch["reasoning"] == {"effort": "medium"}
|
||||
assert kwargs_batch["extra_body"] == {"chat_template_kwargs": {"enable_thinking": True}}
|
||||
assert kwargs_batch["metadata"] == {"lmstudio_tooling_mcp_requested": "true"}
|
||||
|
||||
|
||||
@@ -140,22 +139,15 @@ def test_execute_chat_fallback_reports_dropped_frames(
|
||||
completion = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="fallback vision result"))]
|
||||
)
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
def create_completion(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return completion
|
||||
|
||||
fake_client = SimpleNamespace(
|
||||
responses=SimpleNamespace(create=raise_responses),
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create_completion)),
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: completion)),
|
||||
)
|
||||
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 99)
|
||||
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
|
||||
|
||||
# thinking="off" exercises the suppression prefill + enable_thinking:false path.
|
||||
output = image_node.LMStudioImageToText.execute(
|
||||
connection=_connection_payload(thinking="off"),
|
||||
connection=_connection_payload(),
|
||||
image=_batch_image(),
|
||||
system_prompt="sys",
|
||||
user_prompt="describe",
|
||||
@@ -166,40 +158,41 @@ 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(
|
||||
def test_execute_chains_both_errors_when_chat_completions_also_raises(
|
||||
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)
|
||||
def raise_responses(**kwargs):
|
||||
raise RuntimeError("responses boom")
|
||||
|
||||
with pytest.raises(ValueError, match="All content was inside <think> blocks"):
|
||||
def raise_chat(**kwargs):
|
||||
raise RuntimeError("chat boom")
|
||||
|
||||
fake_client = SimpleNamespace(
|
||||
responses=SimpleNamespace(create=raise_responses),
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=raise_chat)),
|
||||
)
|
||||
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 41)
|
||||
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
|
||||
monkeypatch.setattr(
|
||||
image_node, "comfy_image_to_base64_png_url", lambda _img: "data:image/png;base64,xxx"
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
image_node.LMStudioImageToText.execute(
|
||||
connection=_connection_payload(),
|
||||
image=_single_image(),
|
||||
system_prompt="sys",
|
||||
user_prompt="describe",
|
||||
seed=1,
|
||||
seed=0,
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "Both endpoints failed" in message
|
||||
assert "responses boom" in message
|
||||
assert "chat boom" in message
|
||||
assert isinstance(exc_info.value.__cause__, RuntimeError)
|
||||
assert str(exc_info.value.__cause__) == "chat boom"
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
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)
|
||||
@@ -14,7 +14,7 @@ def test_connection_payload_is_frozen_dataclass() -> None:
|
||||
base_url="http://127.0.0.1:1234/v1",
|
||||
api_key="-",
|
||||
model="m1",
|
||||
thinking="auto",
|
||||
reasoning_enabled=False,
|
||||
max_tokens=64,
|
||||
temperature=0.5,
|
||||
timeout_seconds=10,
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
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
|
||||
+32
-67
@@ -11,18 +11,18 @@ def _text_module():
|
||||
return import_repo_module("text_gen_node", force_reload=True)
|
||||
|
||||
|
||||
def _connection_payload(*, thinking: str = "auto", use_tooling_mcp: bool = False, model: str = "model-a"):
|
||||
def _connection_payload():
|
||||
models = import_repo_module("models", force_reload=True)
|
||||
return models.LMStudioConnectionPayload(
|
||||
server_url="http://127.0.0.1:1234",
|
||||
base_url="http://127.0.0.1:1234/v1",
|
||||
api_key="token",
|
||||
model=model,
|
||||
thinking=thinking,
|
||||
model="model-a",
|
||||
reasoning_enabled=False,
|
||||
max_tokens=111,
|
||||
temperature=0.4,
|
||||
timeout_seconds=30,
|
||||
use_tooling_mcp=use_tooling_mcp,
|
||||
use_tooling_mcp=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ def test_define_schema_and_validate_inputs() -> None:
|
||||
|
||||
def test_responses_kwargs_include_optional_fields() -> None:
|
||||
text_gen_node = _text_module()
|
||||
# thinking="on" forces reasoning on (effort + enable_thinking:true kwarg).
|
||||
connection = _connection_payload(thinking="on", use_tooling_mcp=True)
|
||||
connection = _connection_payload()
|
||||
connection = connection.__class__(**{**connection.__dict__, "reasoning_enabled": True, "use_tooling_mcp": True})
|
||||
|
||||
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
|
||||
connection=connection,
|
||||
@@ -54,27 +54,11 @@ def test_responses_kwargs_include_optional_fields() -> None:
|
||||
assert kwargs["seed"] == 12
|
||||
assert kwargs["instructions"] == "sys"
|
||||
assert kwargs["reasoning"] == {"effort": "medium"}
|
||||
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": True}}
|
||||
assert kwargs["metadata"] == {"lmstudio_tooling_mcp_requested": "true"}
|
||||
|
||||
|
||||
def test_responses_kwargs_suppress_thinking_when_off() -> None:
|
||||
text_gen_node = _text_module()
|
||||
# thinking="off" suppresses reasoning: enable_thinking:false, no reasoning effort.
|
||||
connection = _connection_payload(thinking="off")
|
||||
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
|
||||
connection=connection,
|
||||
system_prompt="sys",
|
||||
user_prompt="user",
|
||||
seed=5,
|
||||
)
|
||||
assert "reasoning" not in kwargs
|
||||
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
|
||||
def test_responses_kwargs_omit_optional_fields_when_disabled() -> None:
|
||||
text_gen_node = _text_module()
|
||||
# "auto" on a non-Gemma model defers to the template: nothing sent.
|
||||
connection = _connection_payload()
|
||||
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
|
||||
connection=connection,
|
||||
@@ -84,24 +68,9 @@ def test_responses_kwargs_omit_optional_fields_when_disabled() -> None:
|
||||
)
|
||||
assert "instructions" not in kwargs
|
||||
assert "reasoning" not in kwargs
|
||||
assert "extra_body" not in kwargs
|
||||
assert "metadata" not in kwargs
|
||||
|
||||
|
||||
def test_responses_kwargs_auto_forces_thinking_for_gemma() -> None:
|
||||
text_gen_node = _text_module()
|
||||
# "auto" on a Gemma model forces thinking on (Gemma stays silent otherwise).
|
||||
connection = _connection_payload(thinking="auto", model="google/gemma-3-12b")
|
||||
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
|
||||
connection=connection,
|
||||
system_prompt="sys",
|
||||
user_prompt="user",
|
||||
seed=7,
|
||||
)
|
||||
assert kwargs["reasoning"] == {"effort": "medium"}
|
||||
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": True}}
|
||||
|
||||
|
||||
def test_execute_rejects_empty_user_prompt() -> None:
|
||||
text_gen_node = _text_module()
|
||||
with pytest.raises(ValueError, match="user_prompt must not be empty"):
|
||||
@@ -150,28 +119,21 @@ def test_execute_uses_responses_endpoint_and_strips_think(
|
||||
|
||||
def test_execute_falls_back_to_chat_completions(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
text_gen_node = _text_module()
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
def raise_responses(**kwargs):
|
||||
raise RuntimeError("responses disabled")
|
||||
|
||||
completion = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Fallback answer"))])
|
||||
|
||||
def create_completion(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return completion
|
||||
|
||||
fake_client = SimpleNamespace(
|
||||
responses=SimpleNamespace(create=raise_responses),
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create_completion)),
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: completion)),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 99)
|
||||
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
|
||||
|
||||
# thinking="off" exercises the suppression prefill + enable_thinking:false path.
|
||||
output = text_gen_node.LMStudioTextGen.execute(
|
||||
connection=_connection_payload(thinking="off"),
|
||||
connection=_connection_payload(),
|
||||
system_prompt="sys",
|
||||
user_prompt="hello",
|
||||
seed=0,
|
||||
@@ -180,8 +142,6 @@ def test_execute_falls_back_to_chat_completions(monkeypatch: pytest.MonkeyPatch)
|
||||
assert output[0] == "Fallback answer"
|
||||
assert "via chat.completions" in output.ui.text
|
||||
assert "responses disabled" in output.ui.text
|
||||
assert captured_kwargs["messages"][-1] == {"role": "assistant", "content": "<think></think>"}
|
||||
assert captured_kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
|
||||
def test_execute_raises_if_both_endpoints_return_no_text(
|
||||
@@ -201,7 +161,7 @@ def test_execute_raises_if_both_endpoints_return_no_text(
|
||||
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
|
||||
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 55)
|
||||
|
||||
with pytest.raises(ValueError, match="chat\\.completions fallback returned no text output"):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
text_gen_node.LMStudioTextGen.execute(
|
||||
connection=_connection_payload(),
|
||||
system_prompt="sys",
|
||||
@@ -209,37 +169,42 @@ def test_execute_raises_if_both_endpoints_return_no_text(
|
||||
seed=1,
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "Both endpoints failed" in message
|
||||
assert "responses endpoint returned no text output" in message
|
||||
assert "chat.completions fallback returned no text output" in message
|
||||
|
||||
def test_execute_raises_when_all_text_is_think_blocks(
|
||||
|
||||
def test_execute_chains_both_errors_when_chat_completions_also_raises(
|
||||
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()
|
||||
|
||||
def raise_responses(**kwargs):
|
||||
raise RuntimeError("responses boom")
|
||||
|
||||
def raise_chat(**kwargs):
|
||||
raise RuntimeError("chat boom")
|
||||
|
||||
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")
|
||||
)
|
||||
)
|
||||
),
|
||||
responses=SimpleNamespace(create=raise_responses),
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=raise_chat)),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 1)
|
||||
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
|
||||
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 55)
|
||||
|
||||
with pytest.raises(ValueError, match="All content was inside <think> blocks"):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
text_gen_node.LMStudioTextGen.execute(
|
||||
connection=_connection_payload(),
|
||||
system_prompt="sys",
|
||||
user_prompt="hello",
|
||||
seed=1,
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "Both endpoints failed" in message
|
||||
assert "responses boom" in message
|
||||
assert "chat boom" in message
|
||||
assert isinstance(exc_info.value.__cause__, RuntimeError)
|
||||
assert str(exc_info.value.__cause__) == "chat boom"
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers.imports import import_repo_module
|
||||
|
||||
|
||||
def _client():
|
||||
return import_repo_module("client", force_reload=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model, expected",
|
||||
[
|
||||
("google/gemma-3-12b", True),
|
||||
("Gemma2-9B", True),
|
||||
("gemma", True),
|
||||
("qwen3-8b", False),
|
||||
("llama-3.1-8b", False),
|
||||
("", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_model_thinks_off_by_default(model, expected) -> None:
|
||||
client = _client()
|
||||
assert client.model_thinks_off_by_default(model) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"thinking, model, expected",
|
||||
[
|
||||
# Explicit off/on always win regardless of family.
|
||||
("off", "qwen3-8b", "suppress"),
|
||||
("off", "gemma-3", "suppress"),
|
||||
("on", "gemma-3", "force"),
|
||||
("on", "qwen3-8b", "force"),
|
||||
# auto defers for thinking-by-default families, forces Gemma.
|
||||
("auto", "qwen3-8b", "default"),
|
||||
("auto", "google/gemma-3-12b", "force"),
|
||||
# Normalization: case/whitespace and unknown/blank fall back to auto.
|
||||
(" OFF ", "qwen", "suppress"),
|
||||
("", "qwen", "default"),
|
||||
(None, "gemma-3", "force"),
|
||||
],
|
||||
)
|
||||
def test_resolve_thinking_mode(thinking, model, expected) -> None:
|
||||
client = _client()
|
||||
assert client.resolve_thinking_mode(thinking, model) == expected
|
||||
|
||||
|
||||
def test_thinking_chat_template_kwargs() -> None:
|
||||
client = _client()
|
||||
assert client.thinking_chat_template_kwargs(client.THINK_MODE_SUPPRESS) == {
|
||||
"enable_thinking": False
|
||||
}
|
||||
assert client.thinking_chat_template_kwargs(client.THINK_MODE_FORCE) == {
|
||||
"enable_thinking": True
|
||||
}
|
||||
assert client.thinking_chat_template_kwargs(client.THINK_MODE_DEFAULT) is None
|
||||
|
||||
|
||||
def test_apply_thinking_prefill_only_on_suppress() -> None:
|
||||
client = _client()
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
|
||||
suppressed = client.apply_thinking_prefill(messages, client.THINK_MODE_SUPPRESS)
|
||||
assert suppressed[-1] == {"role": "assistant", "content": "<think></think>"}
|
||||
# Original list is not mutated.
|
||||
assert messages == [{"role": "user", "content": "hi"}]
|
||||
|
||||
for mode in (client.THINK_MODE_FORCE, client.THINK_MODE_DEFAULT):
|
||||
assert client.apply_thinking_prefill(messages, mode) == messages
|
||||
+18
-36
@@ -5,21 +5,16 @@ 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):
|
||||
@@ -92,13 +87,8 @@ class LMStudioTextGen(io.ComfyNode):
|
||||
}
|
||||
if system_prompt.strip():
|
||||
kwargs["instructions"] = system_prompt
|
||||
|
||||
mode = resolve_thinking_mode(connection.thinking, connection.model)
|
||||
if mode == THINK_MODE_FORCE:
|
||||
if connection.reasoning_enabled:
|
||||
kwargs["reasoning"] = {"effort": "medium"}
|
||||
template_kwargs = thinking_chat_template_kwargs(mode)
|
||||
if template_kwargs is not None:
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": template_kwargs}
|
||||
|
||||
# MCP/tooling requires additional tool definitions and server details.
|
||||
# Keep an explicit metadata signal for future extension without sending invalid tool payloads.
|
||||
@@ -146,33 +136,25 @@ class LMStudioTextGen(io.ComfyNode):
|
||||
via_endpoint = "chat.completions"
|
||||
fallback_reason = str(responses_error)
|
||||
|
||||
mode = resolve_thinking_mode(connection.thinking, connection.model)
|
||||
messages = apply_thinking_prefill(
|
||||
build_chat_messages(system_prompt, user_prompt), mode
|
||||
)
|
||||
completion_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")
|
||||
try:
|
||||
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,
|
||||
)
|
||||
text = extract_chat_completion_text(completion).strip()
|
||||
if not text:
|
||||
raise ValueError("chat.completions fallback returned no text output")
|
||||
except Exception as chat_error:
|
||||
raise RuntimeError(
|
||||
f"Both endpoints failed. responses error: {fallback_reason}. "
|
||||
f"chat.completions error: {chat_error}"
|
||||
) from chat_error
|
||||
|
||||
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:
|
||||
|
||||
+115
-86
@@ -30,12 +30,6 @@ function getWidget(node, name) {
|
||||
return node.widgets?.find((widget) => widget?.name === name);
|
||||
}
|
||||
|
||||
function getModelWidget(node) {
|
||||
// The backend now ships `model` as a native combo, so there is exactly one
|
||||
// model widget — no injected duplicate. Match by id first, display name second.
|
||||
return getWidget(node, "model") || getWidget(node, "Model");
|
||||
}
|
||||
|
||||
function normalizeModelList(models) {
|
||||
if (!Array.isArray(models)) {
|
||||
return [];
|
||||
@@ -53,46 +47,68 @@ function normalizeModelList(models) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Keep the currently-selected model visible even before a refresh: a workflow
|
||||
// reloaded from disk restores the saved value, but the combo only knows the
|
||||
// placeholder until the server is queried again.
|
||||
function ensureSelectionInOptions(node) {
|
||||
const widget = getModelWidget(node);
|
||||
if (!widget?.options) {
|
||||
return;
|
||||
function ensureModelDropdown(node) {
|
||||
if (node.__lmstudioModelDropdown && node.__lmstudioModelTextWidget) {
|
||||
return {
|
||||
comboWidget: node.__lmstudioModelDropdown,
|
||||
textWidget: node.__lmstudioModelTextWidget,
|
||||
};
|
||||
}
|
||||
const current = String(widget.value ?? "").trim();
|
||||
const options = normalizeModelList(widget.options.values || []);
|
||||
if (options.length === 0) {
|
||||
options.push(MODEL_PLACEHOLDER);
|
||||
|
||||
const modelTextWidget = getWidget(node, "model");
|
||||
if (!modelTextWidget) {
|
||||
return null;
|
||||
}
|
||||
if (current && current !== MODEL_PLACEHOLDER && !options.includes(current)) {
|
||||
options.unshift(current);
|
||||
|
||||
modelTextWidget.hidden = true;
|
||||
const currentModel = String(modelTextWidget.value ?? "").trim();
|
||||
const initialValues = currentModel && currentModel !== MODEL_PLACEHOLDER
|
||||
? [currentModel]
|
||||
: [MODEL_PLACEHOLDER];
|
||||
|
||||
const comboWidget = node.addWidget(
|
||||
"combo",
|
||||
"Model",
|
||||
initialValues[0],
|
||||
(value) => {
|
||||
modelTextWidget.value = String(value ?? "").trim();
|
||||
},
|
||||
{ values: initialValues }
|
||||
);
|
||||
|
||||
// Keep visible ordering aligned with backend schema: insert combo at the original model slot position.
|
||||
const modelIndex = node.widgets?.indexOf(modelTextWidget) ?? -1;
|
||||
const comboIndex = node.widgets?.indexOf(comboWidget) ?? -1;
|
||||
if (modelIndex >= 0 && comboIndex >= 0 && comboIndex !== modelIndex) {
|
||||
node.widgets.splice(comboIndex, 1);
|
||||
node.widgets.splice(modelIndex, 0, comboWidget);
|
||||
}
|
||||
widget.options.values = options;
|
||||
|
||||
node.__lmstudioModelDropdown = comboWidget;
|
||||
node.__lmstudioModelTextWidget = modelTextWidget;
|
||||
|
||||
return { comboWidget, textWidget: modelTextWidget };
|
||||
}
|
||||
|
||||
function applyModelOptions(node, models) {
|
||||
const widget = getModelWidget(node);
|
||||
if (!widget?.options) {
|
||||
function syncDropdownFromText(node) {
|
||||
const refs = ensureModelDropdown(node);
|
||||
if (!refs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = String(widget.value ?? "").trim();
|
||||
let nextValues = normalizeModelList(models);
|
||||
if (current && current !== MODEL_PLACEHOLDER && !nextValues.includes(current)) {
|
||||
nextValues.unshift(current);
|
||||
}
|
||||
if (nextValues.length === 0) {
|
||||
nextValues = [MODEL_PLACEHOLDER];
|
||||
const { comboWidget, textWidget } = refs;
|
||||
const currentModel = String(textWidget.value ?? "").trim();
|
||||
const options = normalizeModelList(comboWidget?.options?.values || []);
|
||||
|
||||
if (!currentModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
widget.options.values = nextValues;
|
||||
widget.value = current && nextValues.includes(current) ? current : nextValues[0];
|
||||
widget.callback?.(widget.value);
|
||||
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
node.graph?.setDirtyCanvas?.(true, true);
|
||||
if (!options.includes(currentModel)) {
|
||||
options.unshift(currentModel);
|
||||
comboWidget.options.values = options;
|
||||
}
|
||||
comboWidget.value = currentModel;
|
||||
}
|
||||
|
||||
function buildQuery(node) {
|
||||
@@ -131,37 +147,46 @@ async function fetchJson(path) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function notify(severity, message) {
|
||||
const summary = severity === "error" ? "LMStudio — Error" : "LMStudio";
|
||||
if (typeof app.extensionManager?.toast?.add === "function") {
|
||||
app.extensionManager.toast.add({
|
||||
severity,
|
||||
summary,
|
||||
detail: message,
|
||||
life: severity === "error" ? 6000 : 3500,
|
||||
});
|
||||
} else if (severity === "error") {
|
||||
console.error(`[LMStudio] ${message}`);
|
||||
} else {
|
||||
console.info(`[LMStudio] ${message}`);
|
||||
function applyModelOptions(node, models) {
|
||||
const refs = ensureModelDropdown(node);
|
||||
if (!refs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { comboWidget, textWidget } = refs;
|
||||
const currentModel = String(textWidget.value ?? "").trim();
|
||||
|
||||
let nextValues = normalizeModelList(models);
|
||||
if (currentModel && currentModel !== MODEL_PLACEHOLDER && !nextValues.includes(currentModel)) {
|
||||
nextValues.unshift(currentModel);
|
||||
}
|
||||
if (nextValues.length === 0) {
|
||||
nextValues = [MODEL_PLACEHOLDER];
|
||||
}
|
||||
|
||||
comboWidget.options.values = nextValues;
|
||||
|
||||
const selected = currentModel && nextValues.includes(currentModel)
|
||||
? currentModel
|
||||
: nextValues[0];
|
||||
|
||||
comboWidget.value = selected;
|
||||
textWidget.value = selected;
|
||||
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
node.graph?.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
// Run an async button handler with a "…" progress label and unified error toasts,
|
||||
// so the user gets feedback on the node instead of a blocking window.alert.
|
||||
async function runWithButtonFeedback(button, busyLabel, action, failPrefix) {
|
||||
const originalLabel = button.name;
|
||||
button.name = busyLabel;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
const message = error?.message || String(error);
|
||||
console.error(`[LMStudio] ${failPrefix}`, error);
|
||||
notify("error", `${failPrefix}: ${message}`);
|
||||
} finally {
|
||||
button.name = originalLabel;
|
||||
button.disabled = false;
|
||||
function notifySuccess(message) {
|
||||
if (typeof app.extensionManager?.toast?.add === "function") {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "success",
|
||||
summary: "LMStudio",
|
||||
detail: message,
|
||||
life: 3500,
|
||||
});
|
||||
} else {
|
||||
console.info(`[LMStudio] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,14 +267,13 @@ function attachButtons(node) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureSelectionInOptions(node);
|
||||
ensureModelDropdown(node);
|
||||
syncDropdownFromText(node);
|
||||
|
||||
const refreshModels = async () => {
|
||||
const query = buildQuery(node);
|
||||
const payload = await fetchJson(`/lmstudio/models?${query.toString()}`);
|
||||
const models = payload.models || [];
|
||||
applyModelOptions(node, models);
|
||||
notify("success", `Loaded ${models.length} model(s). Pick one from the Model dropdown.`);
|
||||
applyModelOptions(node, payload.models || []);
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
@@ -257,26 +281,30 @@ function attachButtons(node) {
|
||||
const payload = await fetchJson(`/lmstudio/test?${query.toString()}`);
|
||||
const models = payload.models || [];
|
||||
applyModelOptions(node, models);
|
||||
notify("success", payload.message || `Connected. ${models.length} model(s) available.`);
|
||||
|
||||
const message = payload.message || `Connected. ${models.length} model(s) available.`;
|
||||
notifySuccess(message);
|
||||
};
|
||||
|
||||
const refreshButton = node.addWidget("button", "🔄 Refresh Models", null, () =>
|
||||
runWithButtonFeedback(
|
||||
refreshButton,
|
||||
"Refreshing…",
|
||||
refreshModels,
|
||||
"Model refresh failed"
|
||||
)
|
||||
);
|
||||
node.addWidget("button", "Refresh Models", null, async () => {
|
||||
try {
|
||||
await refreshModels();
|
||||
} catch (error) {
|
||||
const message = error?.message || String(error);
|
||||
console.error("[LMStudio] Model refresh failed", error);
|
||||
window.alert(`Model refresh failed: ${message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const testButton = node.addWidget("button", "🔌 Test Connection", null, () =>
|
||||
runWithButtonFeedback(
|
||||
testButton,
|
||||
"Testing…",
|
||||
testConnection,
|
||||
"Connectivity test failed"
|
||||
)
|
||||
);
|
||||
node.addWidget("button", "Test Connection", null, async () => {
|
||||
try {
|
||||
await testConnection();
|
||||
} catch (error) {
|
||||
const message = error?.message || String(error);
|
||||
console.error("[LMStudio] Connectivity test failed", error);
|
||||
window.alert(`Connectivity test failed: ${message}`);
|
||||
}
|
||||
});
|
||||
|
||||
node.__lmstudioButtonsAttached = true;
|
||||
}
|
||||
@@ -294,7 +322,8 @@ app.registerExtension({
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
onConfigure?.apply(this, arguments);
|
||||
ensureSelectionInOptions(this);
|
||||
ensureModelDropdown(this);
|
||||
syncDropdownFromText(this);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user