Files
df18925e8d Repair OpenRouter image, video and audio integration
Preserve existing workflows while adding capability-based catalogs, dedicated
image requests, resumable native video, validated audio and explicit chat tiers.
Cover compatibility, billing-sensitive retries, credentials and cancellation.

Co-authored-by: Arthur Reboul Salze <22814805+ArthurReboulSalze@users.noreply.github.com>
Co-authored-by: Dookie <me@djdookie.de>
2026-10-01 22:22:39 -05:00

307 lines
17 KiB
Python

import sys
import types
import unittest
import inspect
import io
from unittest.mock import Mock, patch
from .test_request_timeout import load_node_module
class NodeIntegrationTests(unittest.TestCase):
def setUp(self):
self.module = load_node_module()
self.package = self.module.__package__
catalog = types.ModuleType(f"{self.package}.openrouter_catalog")
catalog.get_model = Mock(return_value={})
catalog.get_catalog = Mock(return_value={"chat": [], "image": [], "video": []})
catalog.require_model = Mock(return_value={"supported_parameters": {}, "architecture": {
"input_modalities": ["text", "image", "audio"], "output_modalities": ["text"],
}})
self.catalog = catalog
catalog_patch = patch.dict(sys.modules, {catalog.__name__: catalog})
catalog_patch.start()
self.addCleanup(catalog_patch.stop)
self.node = self.module.OpenRouterNode()
self.node.fetch_credits = Mock(return_value="Remaining: $1.000")
self.node.count_tokens = Mock(return_value=1)
self.args = dict(api_key="test-key", system_prompt="system", user_message_box="hello",
model="example/model", web_search=False, cheapest=False, fastest=False,
temperature=1, pdf_engine="auto", chat_mode=False)
response = Mock()
response.json.return_value = {"choices": [{"message": {"content": "done"}}],
"usage": {"cost": .01}, "service_tier": "flex"}
self.module.requests.post.return_value = response
def test_existing_output_positions_and_new_optional_inputs(self):
self.assertEqual(self.node.RETURN_TYPES, ("STRING", "IMAGE", "STRING", "STRING", "VIDEO"))
schema = self.node.INPUT_TYPES()
self.assertEqual(list(schema["required"]), ["api_key", "system_prompt", "user_message_box", "model",
"web_search", "cheapest", "fastest", "aspect_ratio", "image_resolution", "reasoning_effort",
"seed", "temperature", "pdf_engine", "chat_mode", "request_timeout"])
self.assertEqual(schema["optional"]["request_type"][1]["default"], "chat")
self.assertEqual(schema["hidden"], {"unique_id": "UNIQUE_ID"})
def test_explicit_tier_does_not_replace_cheapest_routing(self):
result = self.node.generate_response(**{**self.args, "cheapest": True}, service_tier="flex")
payload = self.module.requests.post.call_args.kwargs["json"]
self.assertEqual(payload["model"], "example/model:floor")
self.assertEqual(payload["service_tier"], "flex")
self.assertIn("Service tier: flex", result[2])
self.assertEqual(len(result), 5)
def test_auto_tier_is_omitted(self):
self.node.generate_response(**self.args)
self.assertNotIn("service_tier", self.module.requests.post.call_args.kwargs["json"])
def test_connected_empty_prompt_overrides_internal_prompt(self):
self.node.generate_response(**self.args, user_message_input="")
self.assertEqual(self.module.requests.post.call_args.kwargs["json"]["messages"][-1]["content"], "")
def test_invalid_audio_and_pdf_never_submit(self):
audio = types.ModuleType(f"{self.package}.openrouter_audio")
audio.prepare_audio = Mock(side_effect=ValueError("invalid audio"))
with patch.dict(sys.modules, {audio.__name__: audio}):
result = self.node.generate_response(**self.args, audio_data={})
self.assertIn("invalid audio", result[0])
self.module.requests.post.assert_not_called()
self.node.generate_response(**self.args, pdf_data={})
self.module.requests.post.assert_not_called()
def test_audio_attached_to_chat_content(self):
audio = types.ModuleType(f"{self.package}.openrouter_audio")
audio.prepare_audio = Mock(return_value={"block": {"type": "input_audio", "input_audio": {"data": "test", "format": "wav"}}})
with patch.dict(sys.modules, {audio.__name__: audio}):
self.node.generate_response(**self.args, audio_data={"bytes": b"test"})
content = self.module.requests.post.call_args.kwargs["json"]["messages"][-1]["content"]
self.assertEqual(content[1]["type"], "input_audio")
def test_audio_rejected_before_post_for_known_text_only_model(self):
self.catalog.require_model.return_value = {"architecture": {"input_modalities": ["text", "image"]}}
result = self.node.generate_response(**self.args, audio_data={"bytes": b"test"})
self.assertIn("does not support audio", result[0])
self.module.requests.post.assert_not_called()
def test_chat_waits_for_capabilities_once_before_image_payload(self):
self.catalog.get_model.return_value = {"supported_parameters": {
"resolution": {"type": "enum", "values": ["1K", "2K"]},
"aspect_ratio": {"type": "enum", "values": ["16:9"]},
}}
self.catalog.require_model.return_value = {"id": "google/gemini-3-pro-image", "architecture": {
"input_modalities": ["text", "image"], "output_modalities": ["text", "image"],
}}
self.node.generate_response(**self.args, aspect_ratio="16:9", image_resolution="2K")
self.catalog.require_model.assert_called_once_with("chat", "example/model")
self.catalog.get_model.assert_called_once_with("image", "google/gemini-3-pro-image")
payload = self.module.requests.post.call_args.kwargs["json"]
self.assertEqual(payload["modalities"], ["text", "image"])
self.assertEqual(payload["image_config"], {"aspect_ratio": "16:9", "image_size": "2K"})
def test_gpt_chat_images_omit_inherited_gemini_resolution(self):
self.catalog.require_model.return_value = {"id": "openai/gpt-5.4-image-2", "architecture": {
"output_modalities": ["text", "image"],
}}
self.catalog.get_model.return_value = {"supported_parameters": {
"aspect_ratio": {"type": "enum", "values": ["1:1", "16:9"]},
}}
self.node.generate_response(**self.args, image_resolution="1K", aspect_ratio="16:9")
payload = self.module.requests.post.call_args.kwargs["json"]
self.assertEqual(payload["image_config"], {"aspect_ratio": "16:9"})
self.assertNotIn("image_size", payload["image_config"])
def test_gpt_chat_images_reject_explicit_unsupported_settings_before_post(self):
self.catalog.require_model.return_value = {"id": "openai/gpt-5.4-image-2", "architecture": {
"output_modalities": ["text", "image"],
}}
self.catalog.get_model.return_value = {"supported_parameters": {
"aspect_ratio": {"type": "enum", "values": ["1:1", "16:9"]},
}}
for controls in ({"image_resolution": "2K"}, {"aspect_ratio": "1:8"}):
with self.subTest(controls=controls):
result = self.node.generate_response(**self.args, **controls)
self.assertIn("Error:", result[0])
self.module.requests.post.assert_not_called()
def test_gemini_chat_images_preserve_1k_and_normalize_old_half_k_value(self):
self.catalog.require_model.return_value = {"id": "google/gemini-3.1-flash-image-preview", "architecture": {
"output_modalities": ["text", "image"],
}}
self.catalog.get_model.return_value = {"supported_parameters": {
"resolution": {"type": "enum", "values": ["512", "1K", "2K", "4K"]},
}}
for value, expected in (("1K", "1K"), ("0.5K", "512"), ("512", "512")):
with self.subTest(resolution=value):
self.node.generate_response(**self.args, image_resolution=value)
payload = self.module.requests.post.call_args.kwargs["json"]
self.assertEqual(payload["image_config"], {"image_size": expected})
def test_unknown_chat_image_counterpart_allows_defaults_only(self):
self.catalog.require_model.return_value = {"id": "openrouter/auto", "architecture": {
"output_modalities": ["text", "image"],
}}
self.catalog.get_model.side_effect = ValueError("no image counterpart")
self.node.generate_response(**self.args)
self.assertNotIn("image_config", self.module.requests.post.call_args.kwargs["json"])
self.module.requests.post.reset_mock()
result = self.node.generate_response(**self.args, aspect_ratio="16:9")
self.assertIn("Cannot verify", result[0])
self.module.requests.post.assert_not_called()
def test_chat_catalog_lookup_preserves_free_variant_before_routing_suffixes(self):
self.catalog.require_model.side_effect = ValueError("routing suffix not catalog ID")
self.catalog.get_model.return_value = {"architecture": {
"input_modalities": ["text"], "output_modalities": ["text"],
}}
self.node.generate_response(**{**self.args, "model": "vendor/model:free:floor:online"})
self.catalog.require_model.assert_called_once_with("chat", "vendor/model:free:floor:online")
self.catalog.get_model.assert_called_once_with("chat", "vendor/model:free")
self.module.requests.post.assert_called_once()
def test_exact_catalog_variant_is_not_stripped(self):
self.node.generate_response(**{**self.args, "model": "vendor/model:free"})
self.catalog.require_model.assert_called_once_with("chat", "vendor/model:free")
self.catalog.get_model.assert_not_called()
def test_unavailable_media_metadata_fails_before_submission(self):
self.catalog.require_model.side_effect = ValueError("discovery unavailable")
for kwargs in ({"audio_data": {}}, {"image_1": object()}, {"aspect_ratio": "16:9"}, {"image_resolution": "2K"}):
with self.subTest(inputs=list(kwargs)):
result = self.node.generate_response(**self.args, **kwargs)
self.assertIn("Cannot verify", result[0])
self.module.requests.post.assert_not_called()
def test_plain_custom_chat_fallback_waits_for_lookup(self):
self.catalog.require_model.side_effect = ValueError("custom ID not in public catalog")
result = self.node.generate_response(**self.args)
self.catalog.require_model.assert_called_once_with("chat", "example/model")
self.module.requests.post.assert_called_once()
self.assertEqual(result[0], "done")
def test_custom_validation_does_not_disable_static_enum_and_number_checks(self):
parameters = inspect.signature(self.node.VALIDATE_INPUTS).parameters
self.assertFalse(any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters.values()))
for name in ("seed", "request_type", "service_tier", "video_mode", "request_timeout"):
self.assertNotIn(name, parameters)
def test_null_chat_content_returns_a_string(self):
self.module.requests.post.return_value.json.return_value = {
"choices": [{"message": {"content": None}}], "usage": {},
}
result = self.node.generate_response(**self.args)
self.assertEqual(result[0], "")
def test_video_resume_bypasses_unrelated_inputs(self):
video = types.ModuleType(f"{self.package}.openrouter_video")
video.generate_video = Mock(return_value={"video": "native-video", "job_id": "job-123", "text": "done"})
with patch.dict(sys.modules, {video.__name__: video}):
result = self.node.generate_response(**self.args, request_type="video", video_job_id="job-123",
pdf_data={}, audio_data={}, image_1=object())
self.assertEqual(result[4], "native-video")
self.assertEqual(video.generate_video.call_args.kwargs["reference_urls"], [])
self.module.requests.post.assert_not_called()
def test_video_resume_cache_bypasses_audio_validation_and_reruns_polling(self):
audio = types.ModuleType(f"{self.package}.openrouter_audio")
audio.audio_fingerprint = Mock(side_effect=ValueError("stale audio"))
with patch.dict(sys.modules, {audio.__name__: audio}):
first = self.node.IS_CHANGED(**self.args, request_type="video", video_job_id="job-123", audio_data={})
second = self.node.IS_CHANGED(**self.args, request_type="video", video_job_id="job-123", audio_data={})
audio.audio_fingerprint.assert_not_called()
self.assertNotEqual(first, second, "resuming a pending job must not reuse a cached timeout result")
def test_new_video_validation_error_does_not_offer_an_old_job(self):
self.node.last_video_job_id = "previous-job"
with self.assertRaises(RuntimeError) as caught:
self.node.generate_response(**self.args, request_type="video", audio_data={})
self.assertNotIn("previous-job", str(caught.exception))
self.assertNotIn("resume with", str(caught.exception))
self.module.requests.post.assert_not_called()
def test_video_failure_stops_graph_with_visible_resume_id(self):
video = types.ModuleType(f"{self.package}.openrouter_video")
def generate(*args, **kwargs):
kwargs["on_job"]("job-123")
raise ValueError("test-key data:image/png;base64,AAAA download timed out")
video.generate_video = generate
with patch.dict(sys.modules, {video.__name__: video}):
with self.assertRaisesRegex(RuntimeError, "video_job_id=job-123") as caught:
self.node.generate_response(**self.args, request_type="video")
self.assertEqual(caught.exception.openrouter_job_id, "job-123")
self.assertNotIn("test-key", str(caught.exception))
self.assertNotIn("AAAA", str(caught.exception))
self.assertTrue(caught.exception.__suppress_context__)
self.node.fetch_credits.assert_not_called()
def test_video_job_event_precedes_native_interruption(self):
class ComfyInterrupt(BaseException):
pass
interrupt = ComfyInterrupt()
video = types.ModuleType(f"{self.package}.openrouter_video")
server = types.ModuleType("server")
instance = types.SimpleNamespace(client_id="client-1", send_sync=Mock())
server.PromptServer = types.SimpleNamespace(instance=instance)
def generate(*args, **kwargs):
kwargs["on_job"]("job-123")
instance.send_sync.assert_called_once_with(
"openrouter.video_job", {"node_id": "12:34", "job_id": "job-123"}, sid="client-1")
raise interrupt
video.generate_video = generate
with patch.dict(sys.modules, {video.__name__: video, "server": server}):
with self.assertRaises(ComfyInterrupt) as caught:
self.node.generate_response(**self.args, request_type="video", unique_id="12:34")
self.assertIs(caught.exception, interrupt)
self.assertEqual(self.node.last_video_job_id, "job-123")
self.node.fetch_credits.assert_not_called()
def test_failed_video_event_delivery_keeps_job_and_logs_recovery(self):
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(instance=types.SimpleNamespace(
client_id="client-1", send_sync=Mock(side_effect=RuntimeError("test-key"))))
with patch.dict(sys.modules, {"server": server}), self.assertLogs(self.module.__name__, level="WARNING") as logs:
self.node._remember_video_job("job-123", "12")
self.assertEqual(self.node.last_video_job_id, "job-123")
self.assertIn("video_job_id=job-123", " ".join(logs.output))
self.assertNotIn("test-key", " ".join(logs.output))
def test_audio_cache_changes_when_fingerprint_changes_and_contains_no_key(self):
audio = types.ModuleType(f"{self.package}.openrouter_audio")
audio.audio_fingerprint = Mock(side_effect=["first", "second"])
with patch.dict(sys.modules, {audio.__name__: audio}):
first = self.node.IS_CHANGED(**self.args, audio_data={"value": 1})
second = self.node.IS_CHANGED(**self.args, audio_data={"value": 2})
self.assertNotEqual(first, second)
self.assertNotIn("test-key", repr(first))
def test_chat_ignores_hidden_video_and_native_image_controls(self):
self.node.generate_response(**self.args, video_duration="15", video_mode="reference_images",
image_quality="high", image_background="transparent")
payload = self.module.requests.post.call_args.kwargs["json"]
for field in ("duration", "video_mode", "quality", "background"):
self.assertNotIn(field, payload)
def test_native_image_output_preserves_transparent_pixels(self):
import numpy as np
import torch
from PIL import Image
encoded = io.BytesIO()
Image.new("RGBA", (2, 2), (10, 20, 30, 40)).save(encoded, format="PNG")
images = types.ModuleType(f"{self.package}.openrouter_images")
images.generate_images = Mock(return_value={"images": [encoded.getvalue()], "usage": {}})
with patch.dict(sys.modules, {images.__name__: images}), patch.multiple(
self.module, torch=torch, np=np, Image=Image,
):
result = self.node.generate_response(**self.args, request_type="image", image_background="transparent")
self.assertEqual(tuple(result[1].shape), (1, 2, 2, 4))
self.assertAlmostEqual(result[1][0, 0, 0, 3].item(), 40 / 255, places=6)
self.module.requests.post.assert_not_called()
def test_secret_and_media_redaction(self):
text = self.node._safe_error(ValueError('test-key data:image/png;base64,AAAA'), "test-key")
self.assertNotIn("test-key", text)
self.assertNotIn("AAAA", text)
if __name__ == "__main__":
unittest.main()