Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2301e15111 |
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
@@ -232,10 +233,9 @@ def extract_responses_text(response: Any) -> str:
|
|||||||
text = getattr(entry, "text", None)
|
text = getattr(entry, "text", None)
|
||||||
if isinstance(text, str):
|
if isinstance(text, str):
|
||||||
parts.append(text)
|
parts.append(text)
|
||||||
else:
|
text = getattr(item, "text", None)
|
||||||
text = getattr(item, "text", None)
|
if isinstance(text, str):
|
||||||
if isinstance(text, str):
|
parts.append(text)
|
||||||
parts.append(text)
|
|
||||||
|
|
||||||
if parts:
|
if parts:
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
@@ -243,6 +243,16 @@ def extract_responses_text(response: Any) -> str:
|
|||||||
return ""
|
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_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_START_RE = re.compile(r"<think>.*$", flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
|||||||
+21
-15
@@ -164,21 +164,27 @@ class LMStudioImageToText(io.ComfyNode):
|
|||||||
first_image = image[0] if is_batch else image
|
first_image = image[0] if is_batch else image
|
||||||
image_data_url = comfy_image_to_base64_png_url(first_image)
|
image_data_url = comfy_image_to_base64_png_url(first_image)
|
||||||
|
|
||||||
completion = client.chat.completions.create(
|
try:
|
||||||
model=connection.model,
|
completion = client.chat.completions.create(
|
||||||
messages=build_chat_messages_with_image(
|
model=connection.model,
|
||||||
system_prompt=system_prompt,
|
messages=build_chat_messages_with_image(
|
||||||
user_prompt=user_prompt,
|
system_prompt=system_prompt,
|
||||||
image_data_url=image_data_url,
|
user_prompt=user_prompt,
|
||||||
),
|
image_data_url=image_data_url,
|
||||||
seed=resolved_seed,
|
),
|
||||||
temperature=connection.temperature,
|
seed=resolved_seed,
|
||||||
max_tokens=connection.max_tokens,
|
temperature=connection.temperature,
|
||||||
n=1,
|
max_tokens=connection.max_tokens,
|
||||||
)
|
n=1,
|
||||||
text = extract_chat_completion_text(completion).strip()
|
)
|
||||||
if not text:
|
text = extract_chat_completion_text(completion).strip()
|
||||||
raise ValueError("chat.completions fallback returned no text output")
|
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:
|
if batch_size > 1:
|
||||||
fallback_reason += (
|
fallback_reason += (
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ dependencies = [
|
|||||||
test = [
|
test = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-cov>=5.0.0",
|
"pytest-cov>=5.0.0",
|
||||||
"aiohttp>=3.9.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.comfy]
|
[tool.comfy]
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ def make_chat_completion(content: Any) -> SimpleNamespace:
|
|||||||
return ns(choices=[ns(message=ns(content=content))])
|
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:
|
class FakeTensor:
|
||||||
def __init__(self, array: np.ndarray):
|
def __init__(self, array: np.ndarray):
|
||||||
self._array = np.asarray(array, dtype=np.float32)
|
self._array = np.asarray(array, dtype=np.float32)
|
||||||
|
|||||||
+15
-39
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
|
||||||
@@ -182,48 +183,23 @@ def test_extract_responses_text_variants() -> None:
|
|||||||
assert client.extract_responses_text(ns(output_text="", output=[])) == ""
|
assert client.extract_responses_text(ns(output_text="", output=[])) == ""
|
||||||
|
|
||||||
|
|
||||||
def test_extract_responses_text_no_duplicate_when_item_has_content_and_text() -> None:
|
def test_dump_openai_response_branches() -> None:
|
||||||
# An output item that has BOTH a content list and a top-level text attribute
|
class WithModelDump:
|
||||||
# should only yield text from the content list; the item-level text field
|
def model_dump(self):
|
||||||
# must NOT be appended a second time (regression guard for the duplicate-text bug).
|
return {"a": 1}
|
||||||
item_with_both = ns(content=[ns(text="the-answer")], text="the-answer")
|
|
||||||
response = ns(output=[item_with_both])
|
|
||||||
result = client.extract_responses_text(response)
|
|
||||||
assert result == "the-answer", (
|
|
||||||
f"Expected 'the-answer' but got {result!r} — "
|
|
||||||
"item.text was appended even though content was already processed"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
class WithToDict:
|
||||||
|
def to_dict(self):
|
||||||
|
return {"b": 2}
|
||||||
|
|
||||||
|
class Plain:
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "plain-response"
|
||||||
|
|
||||||
def test_extract_responses_text_dict_item_non_list_content_is_skipped() -> None:
|
assert json.loads(client.dump_openai_response(WithModelDump())) == {"a": 1}
|
||||||
# Branch 221->227: dict item where "content" is not a list.
|
assert json.loads(client.dump_openai_response(WithToDict())) == {"b": 2}
|
||||||
# The function should skip it (continue) and return empty string — not crash.
|
assert json.loads(client.dump_openai_response({"c": 3})) == {"c": 3}
|
||||||
response = ns(output=[{"content": "not-a-list"}, {"content": {"nested": "dict"}}])
|
assert client.dump_openai_response(Plain()) == "plain-response"
|
||||||
assert client.extract_responses_text(response) == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_responses_text_dict_item_content_list_with_non_dict_entries() -> None:
|
|
||||||
# Branch 223->222: content list contains non-dict entries (e.g. plain strings).
|
|
||||||
# Those entries must be silently ignored; only dict entries with a str "text" key count.
|
|
||||||
response = ns(output=[{"content": ["string-entry", 42, None, {"text": "kept"}]}])
|
|
||||||
assert client.extract_responses_text(response) == "kept"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_responses_text_dict_item_content_entry_text_non_string() -> None:
|
|
||||||
# Branch 225->222: content entry is a dict but "text" value is not a str.
|
|
||||||
# These entries must be silently ignored.
|
|
||||||
response = ns(output=[{"content": [{"text": 42}, {"text": None}, {"text": "good"}]}])
|
|
||||||
assert client.extract_responses_text(response) == "good"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_responses_text_object_item_content_entry_text_non_string() -> None:
|
|
||||||
# Branch 233->231: object item with content list where getattr(entry, "text") is not a str.
|
|
||||||
# The entry should be skipped; other valid entries should still be collected.
|
|
||||||
bad_entry = ns(text=99)
|
|
||||||
good_entry = ns(text="valid")
|
|
||||||
response = ns(output=[ns(content=[bad_entry, good_entry])])
|
|
||||||
assert client.extract_responses_text(response) == "valid"
|
|
||||||
|
|
||||||
|
|
||||||
def test_strip_think_content_case_insensitive_and_multiple() -> None:
|
def test_strip_think_content_case_insensitive_and_multiple() -> None:
|
||||||
|
|||||||
@@ -1,312 +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(*, 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",
|
|
||||||
reasoning_enabled=reasoning_enabled,
|
|
||||||
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 _batch_image() -> FakeTensor:
|
|
||||||
return FakeTensor(
|
|
||||||
np.array(
|
|
||||||
[
|
|
||||||
[[[1.0, 0.0, 0.0]]],
|
|
||||||
[[[0.0, 0.0, 1.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
|
|
||||||
@@ -158,3 +158,41 @@ def test_execute_chat_fallback_reports_dropped_frames(
|
|||||||
assert "via chat.completions" in output.ui.text
|
assert "via chat.completions" in output.ui.text
|
||||||
assert "responses unavailable" in output.ui.text
|
assert "responses unavailable" in output.ui.text
|
||||||
assert "additional frame(s) were dropped" in output.ui.text
|
assert "additional frame(s) were dropped" in output.ui.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_chains_both_errors_when_chat_completions_also_raises(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
image_node = _image_module()
|
||||||
|
|
||||||
|
def raise_responses(**kwargs):
|
||||||
|
raise RuntimeError("responses boom")
|
||||||
|
|
||||||
|
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=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"
|
||||||
|
|||||||
@@ -161,10 +161,50 @@ 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, "create_openai_client", lambda **_: fake_client)
|
||||||
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 55)
|
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(
|
text_gen_node.LMStudioTextGen.execute(
|
||||||
connection=_connection_payload(),
|
connection=_connection_payload(),
|
||||||
system_prompt="sys",
|
system_prompt="sys",
|
||||||
user_prompt="hello",
|
user_prompt="hello",
|
||||||
seed=1,
|
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_chains_both_errors_when_chat_completions_also_raises(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
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=raise_responses),
|
||||||
|
chat=SimpleNamespace(completions=SimpleNamespace(create=raise_chat)),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
|
||||||
|
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 55)
|
||||||
|
|
||||||
|
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"
|
||||||
|
|||||||
+17
-11
@@ -136,17 +136,23 @@ class LMStudioTextGen(io.ComfyNode):
|
|||||||
via_endpoint = "chat.completions"
|
via_endpoint = "chat.completions"
|
||||||
fallback_reason = str(responses_error)
|
fallback_reason = str(responses_error)
|
||||||
|
|
||||||
completion = client.chat.completions.create(
|
try:
|
||||||
model=connection.model,
|
completion = client.chat.completions.create(
|
||||||
messages=build_chat_messages(system_prompt, user_prompt),
|
model=connection.model,
|
||||||
seed=resolved_seed,
|
messages=build_chat_messages(system_prompt, user_prompt),
|
||||||
temperature=connection.temperature,
|
seed=resolved_seed,
|
||||||
max_tokens=connection.max_tokens,
|
temperature=connection.temperature,
|
||||||
n=1,
|
max_tokens=connection.max_tokens,
|
||||||
)
|
n=1,
|
||||||
text = extract_chat_completion_text(completion).strip()
|
)
|
||||||
if not text:
|
text = extract_chat_completion_text(completion).strip()
|
||||||
raise ValueError("chat.completions fallback returned no text output")
|
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)
|
text = strip_think_content(text)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user