Compare commits

...
Author SHA1 Message Date
Gerald LonlasandClaude Opus 4.8 1ebfc32609 test(client): cover all defensive branches in extract_responses_text
Add four targeted tests hitting the previously uncovered branches in
`client.extract_responses_text` (lines 221–233): dict items with a
non-list content field, content list entries that are not dicts, dict
entries whose text value is not a str, and object items where
`getattr(entry, "text")` is non-str.

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

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

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

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:00:00 +08:00
5 changed files with 356 additions and 37 deletions
+4 -14
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import base64
import json
import math
import re
import secrets
@@ -233,9 +232,10 @@ def extract_responses_text(response: Any) -> str:
text = getattr(entry, "text", None)
if isinstance(text, str):
parts.append(text)
text = getattr(item, "text", None)
if isinstance(text, str):
parts.append(text)
else:
text = getattr(item, "text", None)
if isinstance(text, str):
parts.append(text)
if parts:
return "\n".join(parts)
@@ -243,16 +243,6 @@ 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)
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
test = [
"pytest>=8.0.0",
"pytest-cov>=5.0.0",
"aiohttp>=3.9.0",
]
[tool.comfy]
-8
View File
@@ -14,14 +14,6 @@ def make_chat_completion(content: Any) -> SimpleNamespace:
return ns(choices=[ns(message=ns(content=content))])
def make_response_with_output_text(text: str) -> SimpleNamespace:
return ns(output_text=text)
def make_response_with_output_items(*items: Any) -> SimpleNamespace:
return ns(output=list(items))
class FakeTensor:
def __init__(self, array: np.ndarray):
self._array = np.asarray(array, dtype=np.float32)
+39 -15
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import base64
from io import BytesIO
import json
import sys
import types
@@ -183,23 +182,48 @@ def test_extract_responses_text_variants() -> None:
assert client.extract_responses_text(ns(output_text="", output=[])) == ""
def test_dump_openai_response_branches() -> None:
class WithModelDump:
def model_dump(self):
return {"a": 1}
def test_extract_responses_text_no_duplicate_when_item_has_content_and_text() -> None:
# An output item that has BOTH a content list and a top-level text attribute
# should only yield text from the content list; the item-level text field
# must NOT be appended a second time (regression guard for the duplicate-text bug).
item_with_both = ns(content=[ns(text="the-answer")], text="the-answer")
response = ns(output=[item_with_both])
result = client.extract_responses_text(response)
assert result == "the-answer", (
f"Expected 'the-answer' but got {result!r} — "
"item.text was appended even though content was already processed"
)
class WithToDict:
def to_dict(self):
return {"b": 2}
class Plain:
def __str__(self) -> str:
return "plain-response"
assert json.loads(client.dump_openai_response(WithModelDump())) == {"a": 1}
assert json.loads(client.dump_openai_response(WithToDict())) == {"b": 2}
assert json.loads(client.dump_openai_response({"c": 3})) == {"c": 3}
assert client.dump_openai_response(Plain()) == "plain-response"
def test_extract_responses_text_dict_item_non_list_content_is_skipped() -> None:
# Branch 221->227: dict item where "content" is not a list.
# The function should skip it (continue) and return empty string — not crash.
response = ns(output=[{"content": "not-a-list"}, {"content": {"nested": "dict"}}])
assert client.extract_responses_text(response) == ""
def test_extract_responses_text_dict_item_content_list_with_non_dict_entries() -> None:
# Branch 223->222: content list contains non-dict entries (e.g. plain strings).
# Those entries must be silently ignored; only dict entries with a str "text" key count.
response = ns(output=[{"content": ["string-entry", 42, None, {"text": "kept"}]}])
assert client.extract_responses_text(response) == "kept"
def test_extract_responses_text_dict_item_content_entry_text_non_string() -> None:
# Branch 225->222: content entry is a dict but "text" value is not a str.
# These entries must be silently ignored.
response = ns(output=[{"content": [{"text": 42}, {"text": None}, {"text": "good"}]}])
assert client.extract_responses_text(response) == "good"
def test_extract_responses_text_object_item_content_entry_text_non_string() -> None:
# Branch 233->231: object item with content list where getattr(entry, "text") is not a str.
# The entry should be skipped; other valid entries should still be collected.
bad_entry = ns(text=99)
good_entry = ns(text="valid")
response = ns(output=[ns(content=[bad_entry, good_entry])])
assert client.extract_responses_text(response) == "valid"
def test_strip_think_content_case_insensitive_and_multiple() -> None:
+312
View File
@@ -0,0 +1,312 @@
"""
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