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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:10:42 +08:00

225 lines
7.9 KiB
Python

from __future__ import annotations
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):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="LMStudio_ImageToText",
display_name="LMStudio - Image To Text",
category="LMStudio",
description="Generate text from image(s) and prompts using an LMStudio vision-capable model.",
inputs=[
ParamConnection.Input(
id="connection",
display_name="Connection",
tooltip="Connection output from LMStudio - Connect node.",
),
io.Image.Input(
id="image",
display_name="Image",
tooltip="Input image tensor.",
),
io.String.Input(
id="system_prompt",
display_name="System Prompt (LM Souls)",
multiline=True,
default="You are LM Souls, an expert image analyst and prompt writer.",
tooltip="System instructions that define assistant behavior.",
),
io.String.Input(
id="user_prompt",
display_name="User Prompt",
multiline=True,
default="Describe this image and provide an optimized generation prompt.",
tooltip="User instruction for image understanding.",
),
io.Int.Input(
id="seed",
display_name="Seed",
default=-1,
min=-1,
max=0x7FFFFFFFFFFFFFFF,
control_after_generate=io.ControlAfterGenerate.randomize,
tooltip="-1 uses a secure random seed.",
),
],
outputs=[
io.String.Output(
id="response_text",
display_name="Response",
tooltip="Generated text output.",
),
],
)
@classmethod
def validate_inputs(cls, user_prompt: str | None = None) -> bool | str:
# user_prompt is often linked from another node; defer strict checks to execute().
return True
@classmethod
def _responses_kwargs(
cls,
*,
connection: LMStudioConnectionPayload,
image,
system_prompt: str,
user_prompt: str,
seed: int,
) -> dict[str, Any]:
content: list[dict[str, Any]] = [{"type": "input_text", "text": user_prompt}]
if image.ndim == 4:
for idx in range(image.shape[0]):
content.append(
{
"type": "input_image",
"image_url": comfy_image_to_base64_png_url(image[idx]),
}
)
else:
content.append(
{
"type": "input_image",
"image_url": comfy_image_to_base64_png_url(image),
}
)
kwargs: dict[str, Any] = {
"model": connection.model,
"input": [{"role": "user", "content": content}],
"temperature": connection.temperature,
"max_output_tokens": connection.max_tokens,
"seed": seed,
}
if system_prompt.strip():
kwargs["instructions"] = system_prompt
mode = resolve_thinking_mode(connection.thinking, connection.model)
if mode == THINK_MODE_FORCE:
kwargs["reasoning"] = {"effort": "medium"}
template_kwargs = thinking_chat_template_kwargs(mode)
if template_kwargs is not None:
kwargs["extra_body"] = {"chat_template_kwargs": template_kwargs}
if connection.use_tooling_mcp:
kwargs["metadata"] = {"lmstudio_tooling_mcp_requested": "true"}
return kwargs
@classmethod
def execute(
cls,
connection: LMStudioConnectionPayload,
image,
system_prompt: str,
user_prompt: str,
seed: int,
) -> io.NodeOutput:
system_prompt = system_prompt or ""
user_prompt = user_prompt or ""
if not user_prompt.strip():
raise ValueError("user_prompt must not be empty")
resolved_seed = resolve_request_seed(seed)
client = create_openai_client(
server_url=connection.server_url,
api_key=connection.api_key,
timeout_seconds=connection.timeout_seconds,
)
via_endpoint = "responses"
fallback_reason: str | None = None
try:
response = client.responses.create(
**cls._responses_kwargs(
connection=connection,
image=image,
system_prompt=system_prompt,
user_prompt=user_prompt,
seed=resolved_seed,
)
)
text = extract_responses_text(response).strip()
if not text:
raise ValueError("responses endpoint returned no text output")
except Exception as responses_error:
via_endpoint = "chat.completions"
fallback_reason = str(responses_error)
is_batch = getattr(image, "ndim", 0) == 4
batch_size = image.shape[0] if is_batch else 1
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")
if batch_size > 1:
fallback_reason += (
f" Note: chat.completions fallback only supports one image; "
f"{batch_size - 1} additional frame(s) were dropped."
)
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:
status += f" Fallback reason: {fallback_reason}"
return io.NodeOutput(
text,
ui=ui.PreviewText(status),
)