* feat: full per-provider model lists with release-time auto-refresh and dedicated custom model field * feat: add setup help widget to API node (Windows/Linux API key instructions)
361 lines
17 KiB
Python
361 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
PKG = HERE.parent
|
|
MODULE_PATH = PKG / 'nodes' / 'openai_compatible_api.py'
|
|
if str(PKG) not in sys.path:
|
|
sys.path.insert(0, str(PKG))
|
|
|
|
output_cleaner = types.ModuleType('AILab_OutputCleaner')
|
|
class OutputCleanConfig:
|
|
def __init__(self, **kwargs):
|
|
self.__dict__.update(kwargs)
|
|
output_cleaner.OutputCleanConfig = OutputCleanConfig
|
|
output_cleaner.clean_model_output = lambda text, config=None: text.replace('<think>x</think>', '').strip()
|
|
sys.modules['AILab_OutputCleaner'] = output_cleaner
|
|
|
|
stream_display = types.ModuleType('AILab_StreamDisplay')
|
|
stream_display.compose_streamed_model_output = lambda reasoning, content: (
|
|
f'<think>\n{reasoning}\n</think>\n\n{content}' if reasoning and content
|
|
else f'<think>\n{reasoning}\n</think>' if reasoning else content
|
|
)
|
|
stream_display.extract_stream_token = lambda chunk: {
|
|
'reasoning': ((chunk.get('choices') or [{}])[0].get('delta') or {}).get('reasoning_content', ''),
|
|
'content': ((chunk.get('choices') or [{}])[0].get('delta') or {}).get('content', ''),
|
|
}
|
|
stream_display.get_thinking_stream_display = lambda: None
|
|
sys.modules['AILab_StreamDisplay'] = stream_display
|
|
|
|
SPEC = importlib.util.spec_from_file_location('thinkingllm_openai_compatible_api_test', MODULE_PATH)
|
|
assert SPEC and SPEC.loader
|
|
api_node = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(api_node)
|
|
|
|
class SecurityTests(unittest.TestCase):
|
|
def profile(self, **overrides):
|
|
raw = {
|
|
'provider': 'Test',
|
|
'base_url': 'https://example.test/v1',
|
|
'auth': 'bearer_env',
|
|
'api_key_env': 'TEST_API_KEY',
|
|
}
|
|
raw.update(overrides)
|
|
return api_node._normalize_profile('Production', raw)
|
|
|
|
def test_no_workflow_secret_or_endpoint_inputs(self):
|
|
inputs = api_node.ThinkingLLMOpenAICompatibleAPI.INPUT_TYPES()
|
|
names = set(inputs.get('required', {})) | set(inputs.get('optional', {}))
|
|
self.assertIn('api_profile', names)
|
|
self.assertIn('image', names)
|
|
self.assertIn('image_url', names)
|
|
self.assertNotIn('video', names)
|
|
self.assertNotIn('video_url', names)
|
|
for forbidden in ('api_key', 'api_key_env', 'base_url', 'headers'):
|
|
self.assertNotIn(forbidden, names)
|
|
|
|
def test_model_name_is_a_curated_combo(self):
|
|
inputs = api_node.ThinkingLLMOpenAICompatibleAPI.INPUT_TYPES()
|
|
model_def = inputs['required']['model_name']
|
|
options = model_def[0]
|
|
self.assertIsInstance(options, list)
|
|
self.assertGreaterEqual(len(options), 1)
|
|
# The dropdown should carry real curated models, not a placeholder.
|
|
self.assertNotIn('provider/model-id', options)
|
|
# OrcaRouter's catalog includes the free Qwen model.
|
|
self.assertIn('qwen/qwen3.8-27b-free', api_node._catalog_models_for_profile('OrcaRouter'))
|
|
|
|
def test_custom_model_field_overrides_dropdown(self):
|
|
inputs = api_node.ThinkingLLMOpenAICompatibleAPI.INPUT_TYPES()
|
|
self.assertIn('custom_model_name', inputs.get('optional', {}))
|
|
p = self.profile()
|
|
self.assertEqual(api_node._validate_model(p, 'qwen/qwen3.8-27b-free'), 'qwen/qwen3.8-27b-free')
|
|
self.assertEqual(api_node._validate_model(p, 'any/custom-id'), 'any/custom-id')
|
|
|
|
def test_authenticated_profiles_require_https(self):
|
|
with self.assertRaisesRegex(ValueError, 'must use HTTPS'):
|
|
api_node._normalize_profile('Unsafe', {
|
|
'base_url': 'http://example.test/v1',
|
|
'auth': 'bearer_env',
|
|
'api_key_env': 'TEST_KEY',
|
|
})
|
|
|
|
def test_plain_http_non_loopback_requires_explicit_opt_in(self):
|
|
with self.assertRaisesRegex(ValueError, 'Plain HTTP'):
|
|
api_node._normalize_profile('LAN', {
|
|
'base_url': 'http://192.168.1.10:8000/v1',
|
|
'auth': 'none',
|
|
})
|
|
p = api_node._normalize_profile('LAN', {
|
|
'base_url': 'http://192.168.1.10:8000/v1',
|
|
'auth': 'none',
|
|
'allow_insecure_http': True,
|
|
})
|
|
self.assertTrue(p['allow_insecure_http'])
|
|
|
|
def test_loopback_http_allowed(self):
|
|
p = api_node._normalize_profile('Local', {
|
|
'base_url': 'http://127.0.0.1:8000/v1',
|
|
'auth': 'none',
|
|
})
|
|
self.assertEqual(p['base_url'], 'http://127.0.0.1:8000/v1')
|
|
|
|
def test_unknown_profile_fields_fail_closed(self):
|
|
with self.assertRaisesRegex(ValueError, 'unknown fields'):
|
|
self.profile(max_token_limit=10)
|
|
|
|
def test_deprecated_boolean_extra_body_switch_rejected(self):
|
|
with self.assertRaisesRegex(ValueError, 'deprecated allow_extra_body'):
|
|
self.profile(allow_extra_body=True)
|
|
|
|
def test_exact_extra_body_allowlist(self):
|
|
p = self.profile(allowed_extra_body_fields=['reasoning'])
|
|
parsed = api_node._parse_extra_json('{"reasoning":{"effort":"high"}}', allowed_fields=p['allowed_extra_body_fields'])
|
|
self.assertIn('reasoning', parsed)
|
|
with self.assertRaisesRegex(ValueError, 'not allowed'):
|
|
api_node._parse_extra_json('{"n":4}', allowed_fields=p['allowed_extra_body_fields'])
|
|
with self.assertRaisesRegex(ValueError, 'protected request fields'):
|
|
api_node._parse_extra_json('{"model":"evil"}', allowed_fields=['model'])
|
|
|
|
def test_extra_json_size_limit(self):
|
|
p = self.profile(allowed_extra_body_fields=['x'])
|
|
payload = '{"x":"' + ('a' * api_node.MAX_EXTRA_BODY_JSON_CHARS) + '"}'
|
|
with self.assertRaisesRegex(ValueError, 'safety limit'):
|
|
api_node._parse_extra_json(payload, allowed_fields=p['allowed_extra_body_fields'])
|
|
|
|
def test_nonfinite_extra_json_rejected(self):
|
|
with self.assertRaisesRegex(ValueError, 'Non-finite'):
|
|
api_node._parse_extra_json('{"reasoning":NaN}', allowed_fields=['reasoning'])
|
|
|
|
def test_model_and_limits(self):
|
|
p = self.profile(allowed_models=['approved/model'], max_tokens_limit=2048, max_input_chars=20, max_timeout_seconds=30)
|
|
self.assertEqual(api_node._validate_model(p, 'approved/model'), 'approved/model')
|
|
with self.assertRaisesRegex(ValueError, 'not allowed'):
|
|
api_node._validate_model(p, 'other/model')
|
|
with self.assertRaisesRegex(ValueError, 'control characters'):
|
|
api_node._validate_model_identifier('bad\x1bmodel')
|
|
with self.assertRaisesRegex(ValueError, 'exceeds server-side limit'):
|
|
api_node._validate_max_tokens(p, 2049)
|
|
with self.assertRaisesRegex(ValueError, 'max_input_chars'):
|
|
api_node._validate_input_text(p, '1234567890', '12345678901')
|
|
with self.assertRaisesRegex(ValueError, 'exceeds server-side limit'):
|
|
api_node._validate_timeout(p, 31)
|
|
|
|
def test_sampling_rejects_nan_and_wrong_types(self):
|
|
with self.assertRaisesRegex(ValueError, 'finite'):
|
|
api_node._validate_sampling(float('nan'), 0.9, 1, 0)
|
|
with self.assertRaisesRegex(ValueError, 'seed must be an integer'):
|
|
api_node._validate_sampling(0.5, 0.9, 1.2, 0)
|
|
with self.assertRaisesRegex(ValueError, 'temperature must be a numeric'):
|
|
api_node._validate_sampling('0.5', 0.9, 1, 0)
|
|
|
|
def test_thinking_budget_has_server_side_cap(self):
|
|
p = self.profile(max_thinking_budget=4096)
|
|
self.assertEqual(api_node._validate_thinking_budget(p, 4096), 4096)
|
|
with self.assertRaisesRegex(ValueError, 'exceeds server-side limit'):
|
|
api_node._validate_thinking_budget(p, 4097)
|
|
|
|
def test_allowed_models_requires_strings(self):
|
|
with self.assertRaisesRegex(ValueError, 'list of strings'):
|
|
self.profile(allowed_models=[123])
|
|
|
|
def test_profile_booleans_are_strict(self):
|
|
with self.assertRaisesRegex(ValueError, 'JSON boolean'):
|
|
self.profile(send_seed='false')
|
|
with self.assertRaisesRegex(ValueError, 'JSON boolean'):
|
|
self.profile(allow_images='true')
|
|
|
|
def test_environment_variable_name_is_validated(self):
|
|
with self.assertRaisesRegex(ValueError, 'environment-variable name'):
|
|
self.profile(api_key_env='BAD-NAME')
|
|
|
|
def test_missing_key_does_not_expose_env_name_in_client_error(self):
|
|
p = self.profile(api_key_env='THINKINGLLM_TEST_SECRET_KEY')
|
|
with mock.patch.dict(os.environ, {}, clear=False):
|
|
os.environ.pop('THINKINGLLM_TEST_SECRET_KEY', None)
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
api_node._authorization_headers(p)
|
|
self.assertNotIn('THINKINGLLM_TEST_SECRET_KEY', str(ctx.exception))
|
|
|
|
def test_redirects_are_rejected(self):
|
|
handler = api_node._NoRedirectHandler()
|
|
req = __import__('urllib.request').request.Request('https://example.test/v1/chat/completions')
|
|
self.assertIsNone(handler.redirect_request(req, None, 302, 'Found', {}, 'https://evil.test/steal'))
|
|
|
|
def test_terminal_copy_strips_controls(self):
|
|
value = api_node._terminal_display_safe('hello\x1b[31mred\x07\rworld')
|
|
self.assertNotIn('\x1b', value)
|
|
self.assertNotIn('\x07', value)
|
|
self.assertNotIn('\r', value)
|
|
self.assertIn('[31mredworld', value)
|
|
|
|
def test_openrouter_reasoning_details_are_captured(self):
|
|
chunk = {
|
|
'choices': [{
|
|
'delta': {
|
|
'reasoning_details': [
|
|
{'type': 'reasoning.text', 'text': 'step one '},
|
|
{'type': 'reasoning.summary', 'summary': 'step two'},
|
|
],
|
|
'content': 'answer',
|
|
}
|
|
}]
|
|
}
|
|
token = api_node._extract_stream_token_extended(chunk)
|
|
self.assertEqual(token['reasoning'], 'step one step two')
|
|
self.assertEqual(token['content'], 'answer')
|
|
|
|
def test_stream_error_payload_detected(self):
|
|
self.assertIn('429', api_node._provider_error_from_payload({'error': {'code': 429, 'message': 'rate limit'}}))
|
|
|
|
def test_remote_node_always_reruns(self):
|
|
self.assertTrue(math.isnan(api_node.ThinkingLLMOpenAICompatibleAPI.IS_CHANGED()))
|
|
|
|
def test_image_support_is_fail_closed_for_custom_profiles(self):
|
|
p = self.profile()
|
|
self.assertFalse(p['allow_images'])
|
|
self.assertFalse(p['allow_image_url'])
|
|
with self.assertRaisesRegex(ValueError, 'Image input is disabled'):
|
|
api_node._validate_image_url(p, 'https://example.com/image.png')
|
|
|
|
def test_image_url_requires_image_capability_and_https(self):
|
|
with self.assertRaisesRegex(ValueError, 'cannot enable allow_image_url'):
|
|
self.profile(allow_image_url=True)
|
|
p = self.profile(allow_images=True, allow_image_url=True)
|
|
signed = 'https://example.com/image.png?signature=abc123'
|
|
self.assertEqual(api_node._validate_image_url(p, signed), signed)
|
|
with self.assertRaisesRegex(ValueError, 'public HTTPS'):
|
|
api_node._validate_image_url(p, 'http://example.com/image.png')
|
|
with self.assertRaisesRegex(ValueError, 'public HTTPS'):
|
|
api_node._validate_image_url(p, 'data:image/png;base64,AAAA')
|
|
|
|
def test_comfyui_image_is_encoded_as_png_base64(self):
|
|
import numpy as np
|
|
p = self.profile(allow_images=True, max_image_pixels=4, max_image_bytes=10000)
|
|
image = np.zeros((1, 2, 2, 3), dtype=np.float32)
|
|
image[..., 0] = 1.0
|
|
data_url = api_node._image_to_data_url(p, image)
|
|
self.assertTrue(data_url.startswith('data:image/png;base64,'))
|
|
png = base64.b64decode(data_url.split(',', 1)[1])
|
|
self.assertTrue(png.startswith(b'\x89PNG\r\n\x1a\n'))
|
|
|
|
def test_image_size_and_batch_limits_are_enforced(self):
|
|
import numpy as np
|
|
p = self.profile(allow_images=True, max_image_pixels=3, max_image_bytes=10000)
|
|
with self.assertRaisesRegex(ValueError, 'max_image_pixels'):
|
|
api_node._image_to_data_url(p, np.zeros((1, 2, 2, 3), dtype=np.float32))
|
|
p2 = self.profile(allow_images=True)
|
|
with self.assertRaisesRegex(ValueError, 'exactly one IMAGE'):
|
|
api_node._image_to_data_url(p2, np.zeros((2, 1, 1, 3), dtype=np.float32))
|
|
|
|
def test_image_and_image_url_are_alternatives(self):
|
|
p = self.profile(allow_images=True, allow_image_url=True)
|
|
with self.assertRaisesRegex(ValueError, 'either the IMAGE input or image_url'):
|
|
api_node._build_user_content(p, 'describe', image=object(), image_url='https://example.com/x.png')
|
|
|
|
def test_multimodal_content_uses_text_first_then_image_url(self):
|
|
p = self.profile(allow_images=True, allow_image_url=True)
|
|
content, supplied = api_node._build_user_content(p, 'describe', image_url='https://example.com/x.png')
|
|
self.assertTrue(supplied)
|
|
self.assertEqual(content[0], {'type': 'text', 'text': 'describe'})
|
|
self.assertEqual(content[1]['type'], 'image_url')
|
|
self.assertEqual(content[1]['image_url']['url'], 'https://example.com/x.png')
|
|
|
|
def test_image_rejection_message_is_actionable(self):
|
|
p = self.profile(allow_images=True)
|
|
message = api_node._image_rejection_message(p, 'text-only/model', 400)
|
|
self.assertIn('Image input was rejected', message)
|
|
self.assertIn('vision-capable', message)
|
|
|
|
def test_builtins_enable_standard_image_transport(self):
|
|
profiles = api_node._load_api_profiles()
|
|
self.assertTrue(profiles['OpenRouter']['allow_images'])
|
|
self.assertTrue(profiles['OpenRouter']['allow_image_url'])
|
|
|
|
def test_builtins_can_be_disabled(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
with mock.patch.dict(os.environ, {
|
|
api_node.DISABLE_BUILTINS_ENV: '1',
|
|
api_node.PROFILE_FILE_ENV: str(Path(tmp) / 'missing.json'),
|
|
}, clear=False):
|
|
self.assertEqual(api_node._load_api_profiles(), {})
|
|
|
|
def test_invalid_custom_override_does_not_fall_back_to_builtin(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / 'profiles.json'
|
|
path.write_text(json.dumps({'profiles': {'OpenRouter': {
|
|
'provider': 'OpenRouter',
|
|
'base_url': 'https://openrouter.ai/api/v1',
|
|
'auth': 'bearer_env',
|
|
'api_key_env': 'OPENROUTER_API_KEY',
|
|
'max_token_limit': 1
|
|
}}}), encoding='utf-8')
|
|
with mock.patch.dict(os.environ, {api_node.PROFILE_FILE_ENV: str(path)}, clear=False):
|
|
profiles = api_node._load_api_profiles()
|
|
self.assertNotIn('OpenRouter', profiles)
|
|
|
|
def test_custom_profile_file_has_configuration_not_secret(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / 'profiles.json'
|
|
path.write_text(json.dumps({'profiles': {'Production': {
|
|
'provider': 'Test',
|
|
'base_url': 'https://example.test/v1',
|
|
'auth': 'bearer_env',
|
|
'api_key_env': 'TEST_API_KEY',
|
|
'allowed_models': ['approved/model'],
|
|
'max_tokens_limit': 1024,
|
|
'max_input_chars': 10000,
|
|
'max_timeout_seconds': 60,
|
|
'allow_images': True,
|
|
'allow_image_url': False,
|
|
'max_image_pixels': 16777216,
|
|
'max_image_bytes': 20000000,
|
|
}}}), encoding='utf-8')
|
|
with mock.patch.dict(os.environ, {
|
|
api_node.DISABLE_BUILTINS_ENV: '1',
|
|
api_node.PROFILE_FILE_ENV: str(path),
|
|
}, clear=False):
|
|
profiles = api_node._load_api_profiles()
|
|
self.assertEqual(list(profiles), ['Production'])
|
|
self.assertEqual(profiles['Production']['api_key_env'], 'TEST_API_KEY')
|
|
self.assertTrue(profiles['Production']['allow_images'])
|
|
self.assertNotIn('api_key', profiles['Production'])
|
|
|
|
def test_catalog_is_ux_only_and_never_becomes_an_allowlist(self):
|
|
# The curated catalog must not be interpreted as a security boundary.
|
|
catalog = api_node._load_model_catalog()
|
|
self.assertIsInstance(catalog, dict)
|
|
self.assertIn('OrcaRouter', catalog)
|
|
orca = catalog['OrcaRouter']
|
|
self.assertIsInstance(orca.get('models'), list)
|
|
self.assertIn('qwen/qwen3.8-27b-free', api_node._catalog_models_for_profile('OrcaRouter'))
|
|
|
|
def test_catalog_missing_or_broken_is_failure_tolerant(self):
|
|
with mock.patch.object(api_node, 'CATALOG_PATH', Path('does/not/exist.json')):
|
|
api_node._CATALOG_CACHE = None
|
|
self.assertEqual(api_node._load_model_catalog(), {})
|
|
self.assertEqual(api_node._catalog_models_for_profile('OrcaRouter'), [])
|
|
api_node._CATALOG_CACHE = None
|
|
|
|
def test_catalog_does_not_replace_allowed_models(self):
|
|
# Even if a profile has no allowlist, a request for a non-listed model must
|
|
# be passed through unchanged — the catalog is UI only.
|
|
p = self.profile()
|
|
self.assertIsNone(p['allowed_models'])
|
|
self.assertEqual(api_node._validate_model(p, 'qwen/qwen3.8-27b-free'), 'qwen/qwen3.8-27b-free')
|
|
self.assertEqual(api_node._validate_model(p, 'anything/else'), 'anything/else')
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|