Files
Claude e07979233c Audit fixes: API currency, dead widgets, crash paths, and a test suite
Audited all three nodes against the current Groq and OpenRouter APIs and
checked that each widget does what its tooltip claims. Every defect below
was reproduced first and is covered by a regression test.

API currency:
- Groq: send max_completion_tokens; max_tokens is deprecated for chat
  completions. The widget was already named for the new field.
- Groq: drop Whisper/Orpheus from the chat dropdown. They are served by
  /audio/transcriptions and /audio/speech and can never answer a
  chat-completions request. Also filtered from the dynamic fetch and
  rejected with an explanatory message if entered manually.

Widgets that did not do what they claimed:
- Groq dynamic model fetching was unreachable: _fetch_groq_models() was
  only ever called with api_key=None, so the dropdown always showed the
  static list despite the tooltip promising a live one. execute() now
  passes the key, warming the cache so a ComfyUI Refresh rebuilds it.
- The random/increment/decrement seed modes were inert. The seed is
  derived inside execute(), so ComfyUI's output cache served the previous
  response and the node never re-ran. Both chat nodes now implement
  fingerprint_inputs() (v3's IS_CHANGED).
- '--- Category ---' separator rows were selectable and were sent as a
  model id, producing an opaque 400. Now rejected up front.
- OpenRouter vision gating checked only the free-model list, so a paid
  vision model reached via Manual Input was hard-refused under a message
  labelled "Warning". Capability now comes from architecture metadata
  across the whole catalogue; unknown ids are passed through.

Crashes and confusing errors:
- An IMAGE batch > 1 failed with "must be 3D after squeezing"; both nodes
  now take the first frame.
- OpenRouter Models crashed on null context_length and null pricing.
- A JSON array in additional_params failed inside dict.update() as an
  "Unexpected Error"; both nodes now require an object and say so.
- A malformed 200 body was retried as a network error, because
  requests.exceptions.JSONDecodeError subclasses RequestException and the
  handler below it was unreachable. Reordered.
- Failed model-list fetches now back off 60s instead of re-requesting on
  every execution and every /object_info refresh.

Housekeeping:
- OpenRouter Models no longer demands an API key for a public endpoint.
- Removed the __init__.py ImportError fallback that re-imported the same
  failing modules, and WEB_DIRECTORY pointing at a missing ./web.
- De-duplicated the Groq help text and the README, and replaced the
  hand-maintained OpenRouter free-model list with a note that the
  dropdown is API-driven.
- Bounded the seed counter dicts.

Testing:
- Replaced two ad-hoc scripts (12 assertions, manual invocation) with a
  140-test pytest suite. tests/comfy_stub.py stands in for
  comfy_api.latest and an autouse fixture blocks network access, so it
  runs offline without API keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1FfbCXMzgYERJPdFmU6c4
2026-08-19 10:31:39 +00:00

185 lines
6.3 KiB
Python

"""Tests for the OpenRouter Models query node."""
import pytest
import openrouter_models
from conftest import FakeResponse
OpenRouterModels = openrouter_models.OpenRouterModels
CATALOGUE = {"data": [
{"id": "z/free-model", "name": "Zed Free", "description": "a free one",
"context_length": 8000, "pricing": {"prompt": "0", "completion": "0",
"image": "0", "request": "0"}},
{"id": "a/paid-gpt", "name": "Alpha GPT", "description": "a paid gpt",
"context_length": 128000, "pricing": {"prompt": "0.00003", "completion": "0.00006",
"image": "0", "request": "0"}},
{"id": "m/odd-model", "name": "Odd", "description": "broken metadata",
"context_length": None, "pricing": {"prompt": None, "completion": "0"}},
]}
def run(payload=CATALOGUE, status=200, **overrides):
kwargs = dict(api_key="", filter_text="", sort_by="name", sort_order="ascending")
kwargs.update(overrides)
return OpenRouterModels.execute(**kwargs)
@pytest.fixture
def catalogue(monkeypatch):
seen = {}
def fake_get(url, headers=None, timeout=None):
seen["url"] = url
seen["headers"] = headers or {}
return FakeResponse(200, CATALOGUE)
monkeypatch.setattr(openrouter_models.requests, "get", fake_get)
return seen
# --------------------------------------------------------------------------
# Requests
# --------------------------------------------------------------------------
def test_api_key_is_optional(catalogue):
"""The models catalogue is public; requiring a key was a false barrier."""
out = run()
assert out.args[0] != ""
assert "Authorization" not in catalogue["headers"]
def test_api_key_is_sent_when_supplied(catalogue):
run(api_key=" secret ")
assert catalogue["headers"]["Authorization"] == "Bearer secret"
def test_validate_inputs_does_not_require_a_key():
assert OpenRouterModels.validate_inputs(api_key="") is True
def test_endpoint(catalogue):
run()
assert catalogue["url"] == "https://openrouter.ai/api/v1/models"
# --------------------------------------------------------------------------
# Filtering
# --------------------------------------------------------------------------
def test_free_filter_uses_pricing_not_text(catalogue):
out = run(filter_text="free")
assert "z/free-model" in out.args[0]
assert "a/paid-gpt" not in out.args[0]
def test_text_filter_matches_id_name_and_description(catalogue):
assert "a/paid-gpt" in run(filter_text="gpt").args[0]
assert "z/free-model" in run(filter_text="Zed").args[0]
assert "a/paid-gpt" in run(filter_text="a paid").args[0]
def test_filter_terms_are_anded(catalogue):
out = run(filter_text="free gpt")
assert "No models found" in out.args[0]
def test_empty_filter_returns_everything(catalogue):
out = run(filter_text=" ")
assert "Found 3 models" in out.args[1]
# --------------------------------------------------------------------------
# Sorting — must survive nulls in the catalogue
# --------------------------------------------------------------------------
@pytest.mark.parametrize("sort_by", ["name", "pricing", "context_length"])
@pytest.mark.parametrize("sort_order", ["ascending", "descending"])
def test_sorting_never_crashes_on_null_fields(catalogue, sort_by, sort_order):
out = run(sort_by=sort_by, sort_order=sort_order)
assert "Error" not in out.args[1]
assert "Found 3 models" in out.args[1]
def test_sort_by_name_ascending(catalogue):
body = run(sort_by="name").args[0]
assert body.index("Alpha GPT") < body.index("Odd") < body.index("Zed Free")
def test_sort_by_context_length_descending(catalogue):
body = run(sort_by="context_length", sort_order="descending").args[0]
assert body.index("Alpha GPT") < body.index("Zed Free")
def test_null_pricing_does_not_crash_the_free_filter(catalogue):
out = run(filter_text="free")
assert "Error" not in out.args[1]
# --------------------------------------------------------------------------
# Coercion helpers
# --------------------------------------------------------------------------
@pytest.mark.parametrize("value,expected", [("1.5", 1.5), (None, 0.0), ("", 0.0), ("x", 0.0), (2, 2.0)])
def test_as_float(value, expected):
assert openrouter_models._as_float(value) == expected
@pytest.mark.parametrize("value,expected", [(5, 5), ("7", 7), (None, 0), ("x", 0)])
def test_as_int(value, expected):
assert openrouter_models._as_int(value) == expected
def test_is_free_model():
assert openrouter_models._is_free_model({"pricing": {"prompt": "0", "completion": "0"}})
assert not openrouter_models._is_free_model({"pricing": {"prompt": "0.1", "completion": "0"}})
assert openrouter_models._is_free_model({})
# --------------------------------------------------------------------------
# Error handling
# --------------------------------------------------------------------------
@pytest.mark.parametrize("status,expected", [
(401, "Invalid API key"),
(429, "Rate limit exceeded"),
(500, "service error"),
(418, "status code 418"),
])
def test_http_errors_are_reported(monkeypatch, status, expected):
monkeypatch.setattr(openrouter_models.requests, "get",
lambda *a, **k: FakeResponse(status, {}))
assert expected in run().args[1]
def test_timeout_is_reported(monkeypatch):
import requests
def timeout(*args, **kwargs):
raise requests.exceptions.Timeout()
monkeypatch.setattr(openrouter_models.requests, "get", timeout)
assert "timed out" in run().args[1]
def test_connection_error_is_reported(monkeypatch):
import requests
def down(*args, **kwargs):
raise requests.exceptions.ConnectionError()
monkeypatch.setattr(openrouter_models.requests, "get", down)
assert "Connection failed" in run().args[1]
def test_malformed_body_is_reported(monkeypatch):
monkeypatch.setattr(openrouter_models.requests, "get",
lambda *a, **k: FakeResponse(200, raise_json=True))
assert "Invalid JSON response" in run().args[1]
def test_null_data_is_handled(monkeypatch):
monkeypatch.setattr(openrouter_models.requests, "get",
lambda *a, **k: FakeResponse(200, {"data": None}))
out = run()
assert "No models found" in out.args[0]