Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4729c79200 | ||
|
|
ce756a3f62 | ||
|
|
afc4f22c95 | ||
|
|
c6acde1b18 | ||
|
|
abcc248ed2 | ||
|
|
dc1dd8a756 | ||
|
|
2167a0e132 | ||
|
|
fb9d55619b | ||
|
|
02f76cca84 | ||
|
|
24abaadf40 | ||
|
|
88cae3b5b5 | ||
|
|
a59b419ca4 | ||
|
|
2e58d2440c | ||
|
|
a2bbcd92fa | ||
|
|
ac6de2611d | ||
|
|
db01daff20 | ||
|
|
7e94e9336f | ||
|
|
2827c2c850 | ||
|
|
f8a5010fa5 | ||
|
|
7fe0c2de9c | ||
|
|
00283107a0 | ||
|
|
21b75d3b58 | ||
|
|
581d8920e1 | ||
|
|
7743e8e702 | ||
|
|
cfea91b016 | ||
|
|
a7506afe68 | ||
|
|
ec7cd7f84d | ||
|
|
6a46a78deb | ||
|
|
e0985b4f7b | ||
|
|
28bac44b51 | ||
|
|
753d66fadb | ||
|
|
dfdd1fa015 | ||
|
|
b0144e6ec3 | ||
|
|
c448e422c7 | ||
|
|
850c96460b | ||
|
|
b02c849b74 | ||
|
|
368637c6f0 | ||
|
|
d2a0780f58 | ||
|
|
89322d6202 | ||
|
|
c78d95d72f | ||
|
|
5c6e00cdf4 | ||
|
|
cda5382a5e | ||
|
|
52803a4ac8 | ||
|
|
b1a69dacfe | ||
|
|
33dec1631b | ||
|
|
4d22234d46 | ||
|
|
e9e3631797 | ||
|
|
4982bd6ec9 | ||
|
|
c38178cdb2 | ||
|
|
8131fb54fa | ||
|
|
71c0fa46c8 | ||
|
|
7daf320d24 | ||
|
|
2c64d28d66 | ||
|
|
a03a4a492c | ||
|
|
b3ee439729 | ||
|
|
782d78b611 | ||
|
|
7d1b9c028b | ||
|
|
2b9ca76d41 | ||
|
|
86ac1cee2e | ||
|
|
b8d1c30484 | ||
|
|
56c18a78ff | ||
|
|
110fe6973a | ||
|
|
3b5a6752ec | ||
|
|
f112bafdd7 | ||
|
|
a58079cee4 | ||
|
|
a99d342c2c | ||
|
|
4f7f3905c8 | ||
|
|
cdf110e3e5 | ||
|
|
52503f95f8 | ||
|
|
07b88246c2 | ||
|
|
ea25a8726f | ||
|
|
21e90d0ba4 |
@@ -0,0 +1,58 @@
|
||||
name: API security tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "nodes/openai_compatible_api.py"
|
||||
- "tests/test_openai_api_security.py"
|
||||
- "thinkingllm_api_profiles.example.json"
|
||||
- "web/api_model_catalogs.json"
|
||||
- "tools/check_api_model_catalogs.py"
|
||||
- "tools/update_api_model_catalogs.py"
|
||||
- "docs/OPENAI_COMPATIBLE_API.md"
|
||||
- ".gitignore"
|
||||
- ".github/workflows/api-security-tests.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "nodes/openai_compatible_api.py"
|
||||
- "tests/test_openai_api_security.py"
|
||||
- "thinkingllm_api_profiles.example.json"
|
||||
- "web/api_model_catalogs.json"
|
||||
- "tools/check_api_model_catalogs.py"
|
||||
- "tools/update_api_model_catalogs.py"
|
||||
- "docs/OPENAI_COMPATIBLE_API.md"
|
||||
- ".gitignore"
|
||||
- ".github/workflows/api-security-tests.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-security:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install minimal image test dependencies
|
||||
run: python -m pip install --disable-pip-version-check --quiet numpy pillow
|
||||
|
||||
- name: Compile API node
|
||||
run: python -m py_compile nodes/openai_compatible_api.py
|
||||
|
||||
- name: Compile catalog tools
|
||||
run: python -m py_compile tools/check_api_model_catalogs.py tools/update_api_model_catalogs.py
|
||||
|
||||
- name: Validate API model catalogs
|
||||
run: python tools/check_api_model_catalogs.py
|
||||
|
||||
- name: Run API security regression tests
|
||||
run: python tests/test_openai_api_security.py
|
||||
@@ -18,7 +18,62 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Check registry version
|
||||
id: registry_version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
node_id="$(python3 - <<'PY'
|
||||
import tomllib
|
||||
with open("pyproject.toml", "rb") as handle:
|
||||
data = tomllib.load(handle)
|
||||
print(data["project"]["name"])
|
||||
PY
|
||||
)"
|
||||
version="$(python3 - <<'PY'
|
||||
import tomllib
|
||||
with open("pyproject.toml", "rb") as handle:
|
||||
data = tomllib.load(handle)
|
||||
print(data["project"]["version"])
|
||||
PY
|
||||
)"
|
||||
|
||||
url="https://api.comfy.org/nodes/${node_id}/versions"
|
||||
http_code="$(curl -sS -o registry_versions.json -w "%{http_code}" "${url}")"
|
||||
|
||||
if [ "${http_code}" = "404" ]; then
|
||||
exists="false"
|
||||
elif [ "${http_code}" -ge 200 ] && [ "${http_code}" -lt 300 ]; then
|
||||
exists="$(python3 - "${version}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
version = sys.argv[1]
|
||||
with open("registry_versions.json", "r", encoding="utf-8") as handle:
|
||||
versions = json.load(handle)
|
||||
print("true" if any(item.get("version") == version for item in versions) else "false")
|
||||
PY
|
||||
)"
|
||||
else
|
||||
cat registry_versions.json
|
||||
echo "Registry version lookup failed with HTTP ${http_code}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "node_id=${node_id}" >> "${GITHUB_OUTPUT}"
|
||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||
echo "exists=${exists}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [ "${exists}" = "true" ]; then
|
||||
echo "::notice::${node_id} ${version} is already published in the Comfy registry; skipping duplicate publish."
|
||||
fi
|
||||
- name: Publish Custom Node
|
||||
if: steps.registry_version.outputs.exists != 'true'
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
- name: Already published
|
||||
if: steps.registry_version.outputs.exists == 'true'
|
||||
run: |
|
||||
echo "${{ steps.registry_version.outputs.node_id }} ${{ steps.registry_version.outputs.version }} is already live in the Comfy registry."
|
||||
|
||||
@@ -9,8 +9,39 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Refresh and validate model catalogs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Refresh API model catalogs from providers
|
||||
run: python tools/update_api_model_catalogs.py
|
||||
|
||||
- name: Validate API model catalogs
|
||||
run: python tools/check_api_model_catalogs.py
|
||||
|
||||
- name: Commit refreshed catalogs to main
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add web/api_model_catalogs.json
|
||||
git diff --cached --quiet || git commit -m "chore: refresh API model catalogs for ${GITHUB_REF_NAME}"
|
||||
# The tag points at a commit on main; push the catalog refresh there.
|
||||
git push origin HEAD:main
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check
|
||||
if: ${{ github.repository_owner == 'goodguy1963' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
|
||||
@@ -29,6 +29,13 @@ venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Local environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
thinkingllm_api_profiles*.json
|
||||
!thinkingllm_api_profiles.example.json
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -48,6 +55,7 @@ Thumbs.db
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
.firecrawl/
|
||||
|
||||
# ComfyUI specific
|
||||
models/
|
||||
|
||||
+58
-9
@@ -2,6 +2,8 @@ import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
OUTPUT_CLEANER_VERSION = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputCleanConfig:
|
||||
@@ -20,6 +22,17 @@ _CODE_FENCE_RE = re.compile(r"^\s*```[\w-]*\s*$", re.IGNORECASE)
|
||||
_THINK_BLOCK_RE = re.compile(r"<think[^>]*>.*?</think>", flags=re.IGNORECASE | re.DOTALL)
|
||||
_THINK_OPEN_RE = re.compile(r"<think[^>]*>", flags=re.IGNORECASE)
|
||||
_THINK_CLOSE_RE = re.compile(r"</think\s*>", flags=re.IGNORECASE)
|
||||
_CHANNEL_OPEN_RE = re.compile(
|
||||
r"(?:<\|?start\|?>\s*assistant\s*)?"
|
||||
r"<\|?channel\|?>\s*(?P<channel>analysis|thinking|reasoning|final)\s*"
|
||||
r"<\|?message\|?>",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_CHANNEL_END_RE = re.compile(r"<\|?end\|?>", flags=re.IGNORECASE)
|
||||
_MIRRORED_REASONING_WITH_FINAL_RE = re.compile(
|
||||
r"^<\|channel>.*?<channel\|>(?P<final>\s*\S[\s\S]*)$",
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_MARKER_RE = re.compile(
|
||||
r"(?im)^\s*(final|final answer|answer|output|result|prompt)\s*[:\-]\s*",
|
||||
)
|
||||
@@ -27,13 +40,13 @@ _IM_TOKEN_RE = re.compile(
|
||||
r"(?i)<\|?im_(start|end)\|?>|<im_(start|end)>|<\|endoftext\|>",
|
||||
)
|
||||
_PLANNING_RE = re.compile(
|
||||
r"(?is)\b("
|
||||
r"(?im)^\s*(?:[-*]\s*)?(?:\*\*)?\s*(?:"
|
||||
r"final\s+plan\b|"
|
||||
r"final\s+check\b|"
|
||||
r"i\s+(should|need|must|will|want|am\s+going\s+to|have\s+to)\b|"
|
||||
r"i\s+(?:should|need|must|will|want|am\s+going\s+to|have\s+to)\b|"
|
||||
r"let's\b|"
|
||||
r"first\b|next\b|then\b|"
|
||||
r"wait\b|"
|
||||
r"first\s*[,.:]|next\s*[,.:]|then\s*[,.:]|"
|
||||
r"wait\s*[,.:]|"
|
||||
r"ready\s+to\s+write\b|"
|
||||
r"writing\s+the\s+prompt\b|"
|
||||
r"so\s+i\s+need\s+to\b|"
|
||||
@@ -53,13 +66,15 @@ def clean_model_output(text: str, config: OutputCleanConfig | None = None) -> st
|
||||
cleaned = _IM_TOKEN_RE.sub("", cleaned).strip()
|
||||
|
||||
if cfg.strip_think:
|
||||
cleaned = _strip_channel_protocol(cleaned)
|
||||
cleaned = _THINK_BLOCK_RE.sub("", cleaned)
|
||||
cleaned = _THINK_CLOSE_RE.sub("", cleaned)
|
||||
if _THINK_OPEN_RE.search(cleaned):
|
||||
cleaned = _THINK_OPEN_RE.sub("", cleaned)
|
||||
parts = re.split(r"\n\s*\n", cleaned, maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
cleaned = parts[1]
|
||||
unclosed_think = _THINK_OPEN_RE.search(cleaned)
|
||||
if unclosed_think:
|
||||
# When generation ends before </think>, no final answer exists.
|
||||
# Prompt-shaped examples inside that unfinished reasoning must not
|
||||
# be exposed as RESPONSE.
|
||||
cleaned = cleaned[:unclosed_think.start()]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
cleaned = _IM_TOKEN_RE.sub("", cleaned).strip()
|
||||
@@ -96,6 +111,40 @@ def clean_model_output(text: str, config: OutputCleanConfig | None = None) -> st
|
||||
return cleaned
|
||||
|
||||
|
||||
def _strip_channel_protocol(text: str) -> str:
|
||||
"""Remove reasoning only when the whole output starts as a channel transcript."""
|
||||
candidate = (text or "").strip()
|
||||
channel_opens = list(_CHANNEL_OPEN_RE.finditer(candidate))
|
||||
if channel_opens and channel_opens[0].start() == 0:
|
||||
final_segments: list[str] = []
|
||||
non_channel_text: list[str] = []
|
||||
|
||||
for index, channel_open in enumerate(channel_opens):
|
||||
chunk_end = (
|
||||
channel_opens[index + 1].start()
|
||||
if index + 1 < len(channel_opens)
|
||||
else len(candidate)
|
||||
)
|
||||
chunk = candidate[channel_open.end():chunk_end]
|
||||
channel_end = _CHANNEL_END_RE.search(chunk)
|
||||
content = chunk[:channel_end.start()] if channel_end else chunk
|
||||
|
||||
if channel_open.group("channel").lower() == "final":
|
||||
final_segments.append(content.strip())
|
||||
elif channel_end:
|
||||
non_channel_text.append(chunk[channel_end.end():])
|
||||
|
||||
if final_segments:
|
||||
return final_segments[-1]
|
||||
return "".join(non_channel_text).strip()
|
||||
|
||||
mirrored = _MIRRORED_REASONING_WITH_FINAL_RE.fullmatch(candidate)
|
||||
if mirrored:
|
||||
return mirrored.group("final").strip()
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def _extract_from_json(text: str, mode: str) -> str | None:
|
||||
candidate = text.strip()
|
||||
if not candidate:
|
||||
|
||||
+114
-1862
File diff suppressed because it is too large
Load Diff
+437
-79
File diff suppressed because it is too large
Load Diff
@@ -27,44 +27,66 @@ import torch
|
||||
from huggingface_hub import snapshot_download
|
||||
from AILab_LlamaCppInstaller import ensure_llama_cpp_backend
|
||||
|
||||
import folder_paths
|
||||
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output, prompt_output_guard
|
||||
from AILab_OutputCleaner import OUTPUT_CLEANER_VERSION, OutputCleanConfig, clean_model_output, prompt_output_guard
|
||||
from comfy.model_management import throw_exception_if_processing_interrupted
|
||||
from comfy.model_management import throw_exception_if_processing_interrupted
|
||||
|
||||
# Import cache functions from main module
|
||||
# Import shared contracts directly; AILab_QwenVL remains a compatibility facade.
|
||||
import sys
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
from AILab_StreamDisplay import (
|
||||
StreamDegenerationError,
|
||||
StreamDegenerationGuard,
|
||||
TerminalStreamDisplay,
|
||||
compose_streamed_model_output,
|
||||
extract_stream_token,
|
||||
strip_degenerate_repetition,
|
||||
)
|
||||
from AILab_QwenVL import (
|
||||
PROMPT_CACHE,
|
||||
apply_qwen_soft_thinking_directive,
|
||||
build_node_input_signature,
|
||||
from thinkingllm_core.hf_models import ensure_cuda_vram_headroom
|
||||
from thinkingllm_core.model_access import (
|
||||
download_hf_file_to_path,
|
||||
ensure_cuda_vram_headroom,
|
||||
enforce_model_access,
|
||||
mark_catalog_files_installed,
|
||||
model_catalog_options,
|
||||
resolve_model_catalog_name,
|
||||
)
|
||||
from thinkingllm_core.prompt_contracts import (
|
||||
VIDEO_DURATION_INPUT,
|
||||
apply_qwen_soft_thinking_directive,
|
||||
apply_video_duration_context,
|
||||
ensure_video_prompt_duration,
|
||||
estimate_qwen_text_tokens,
|
||||
get_cache_key,
|
||||
resolve_qwen_thinking_mode,
|
||||
resolve_video_duration,
|
||||
validate_minimax_source_duration,
|
||||
)
|
||||
from thinkingllm_core.prompt_state import (
|
||||
PROMPT_CACHE,
|
||||
_build_workflow_fingerprint,
|
||||
_make_node_state_key,
|
||||
build_node_input_signature,
|
||||
get_alternative_cache_key,
|
||||
save_prompt_cache,
|
||||
get_cache_key,
|
||||
get_node_saved_prompt,
|
||||
get_node_saved_prompt_with_seed,
|
||||
resolve_qwen_thinking_mode,
|
||||
set_node_saved_prompt,
|
||||
load_node_prompt_state,
|
||||
_make_node_state_key,
|
||||
_build_workflow_fingerprint,
|
||||
log_llm_input,
|
||||
save_prompt_cache,
|
||||
set_node_saved_prompt,
|
||||
)
|
||||
from AILab_QwenVL_GGUF import (
|
||||
construct_llama_safely,
|
||||
read_gguf_architecture,
|
||||
register_active_gguf_loader,
|
||||
release_other_gguf_loaders,
|
||||
_filter_kwargs_for_callable,
|
||||
_find_existing_local_file_in_dirs,
|
||||
_gguf_search_dirs,
|
||||
)
|
||||
from AILab_QwenVL_GGUF import construct_llama_safely, read_gguf_architecture, register_active_gguf_loader, release_other_gguf_loaders, _filter_kwargs_for_callable
|
||||
|
||||
|
||||
GGUF_PROMPT_TOOLTIPS = {
|
||||
"model_name": "GGUF text model from config or auto-detected local files. First run downloads the selected GGUF when it is not already on disk.",
|
||||
"model_name": "GGUF text model from config or auto-detected local files. [installed] means the catalog model was found in a configured GGUF/LLM location; [local] means an uncatalogued local model. Missing GGUF files download on first use.",
|
||||
"prompt_text": "Prompt text to enhance. Leave blank to emit the selected preset instruction as the base prompt.",
|
||||
"preset_system_prompt": "Preset enhancement style. Use Custom Only when you want custom_system_prompt to fully control the instruction.",
|
||||
"custom_system_prompt": "Optional extra instruction. Required when using Custom Only; otherwise it is prepended to the selected style.",
|
||||
@@ -72,13 +94,14 @@ GGUF_PROMPT_TOOLTIPS = {
|
||||
"temperature": "Sampling randomness. Lower is more stable; higher is more varied.",
|
||||
"top_p": "Nucleus sampling cutoff. Lower values restrict token choice; 0.9 is a balanced default.",
|
||||
"repetition_penalty": "Values above 1.0 reduce repeated phrases in the enhanced prompt.",
|
||||
"english_output": "Ask the model to return the final enhanced prompt in English.",
|
||||
"english_output": "Ask the model to return the final enhanced prompt in English. MiniMax H3 presets already require English and bypass the generic paragraph translation pass so their official field structure remains intact.",
|
||||
"device": "auto prefers GPU when available. If generation is unexpectedly slow, run tools/check_llama_backend.py to verify llama.cpp GPU offload.",
|
||||
"keep_model_loaded": "Keep the GGUF model in memory after generation so repeated prompt enhancement skips model loading.",
|
||||
"seed": "Sampling seed. Reusing it with identical inputs can reuse the saved prompt result.",
|
||||
"keep_last_prompt": "Return the saved per-node prompt instead of generating a new one when available.",
|
||||
"stream_tokens_to_terminal": "Show clean wrapped generated tokens in the ComfyUI terminal. Streaming bypasses fixed-seed reuse for a fresh run.",
|
||||
"enable_thinking": "Enable model reasoning/thinking when supported. The node still returns the cleaned final prompt, so reasoning may be hidden or empty.",
|
||||
"auto_finalization_retry": "If enabled, runs an extra LLM completion when the first output is empty or reasoning-only. Disabled by default so one node execution performs one generation pass.",
|
||||
"hf_token": "Optional Hugging Face access token for private or gated GGUF downloads. It is passed only to the download call, never logged or cached, and the in-memory copy is dropped after the download attempt. Clear this field before saving or sharing workflows.",
|
||||
}
|
||||
|
||||
@@ -104,7 +127,7 @@ def _quant_from_filename(filename: str) -> str | None:
|
||||
E.g. 'gemma-4-E4B-it-Q4_K_M.gguf' -> 'Q4'."""
|
||||
from pathlib import Path
|
||||
name = Path(filename).stem.upper()
|
||||
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q2_K'):
|
||||
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q3_K_S', 'Q2_K'):
|
||||
if q in name:
|
||||
return _QUANT_CANONICAL.get(q, q)
|
||||
return None
|
||||
@@ -114,10 +137,14 @@ _QUANT_CANONICAL = {
|
||||
'Q8_0': 'Q8', 'Q6_K': 'Q6',
|
||||
'Q5_K_M': 'Q5', 'Q5_K_S': 'Q5',
|
||||
'Q4_K_M': 'Q4', 'Q4_K_S': 'Q4',
|
||||
'Q3_K_M': 'Q3', 'Q2_K': 'Q2',
|
||||
'Q3_K_M': 'Q3', 'Q3_K_S': 'Q3', 'Q2_K': 'Q2',
|
||||
}
|
||||
|
||||
_EMPTY_THINK_RE = re.compile(r"<think[^>]*>\s*</think>", flags=re.IGNORECASE | re.DOTALL)
|
||||
_LEADING_CLOSED_THINK_RE = re.compile(
|
||||
r"^\s*(?:<think[^>]*>.*?</think>\s*)+",
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_STREAM_HEARTBEAT_INTERVAL_SECONDS = 3.0
|
||||
_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS = 3
|
||||
_STREAM_POLL_INTERVAL_SECONDS = 0.25
|
||||
@@ -161,6 +188,64 @@ def _prompt_output_is_usable(cleaned_text: str) -> bool:
|
||||
return bool(cleaned_text and not _looks_like_prompt_planning(cleaned_text))
|
||||
|
||||
|
||||
def _preset_output_is_usable(cleaned_text: str, preset_name: str) -> bool:
|
||||
"""Accept cleaned video output without imposing a second model schema gate."""
|
||||
resolved = resolve_video_duration(preset_name, 5.0)
|
||||
if resolved:
|
||||
# OutputCleaner already removes completed thinking blocks and returns
|
||||
# an empty string for unfinished ones. The target video model can
|
||||
# tolerate minor field and formatting irregularities, so rejecting a
|
||||
# non-empty final prompt here only creates false empty outputs.
|
||||
return bool((cleaned_text or "").strip())
|
||||
return _prompt_output_is_usable(cleaned_text)
|
||||
|
||||
|
||||
def _merge_assistant_prefill(content_text: str, assistant_prefill: str) -> str:
|
||||
"""Restore a chat-template prefill without duplicating a model-emitted field.
|
||||
|
||||
Some Qwen GGUF chat templates return a literal empty ``<think>`` block
|
||||
before repeating the assistant prefill. Checking only the first visible
|
||||
characters made us prepend the field a second time, so an otherwise valid
|
||||
MiniMax result was rejected. Inspect the first final-answer text after any
|
||||
closed leading think block and insert the prefill there only when missing.
|
||||
"""
|
||||
content = (content_text or "").strip()
|
||||
prefill = (assistant_prefill or "").strip()
|
||||
if not content or not prefill:
|
||||
return content
|
||||
|
||||
leading_think = _LEADING_CLOSED_THINK_RE.match(content)
|
||||
answer_start = leading_think.end() if leading_think else 0
|
||||
answer = content[answer_start:].lstrip()
|
||||
if answer.casefold().startswith(prefill.casefold()):
|
||||
return content
|
||||
|
||||
# An unfinished thinking block has no final answer to prefix.
|
||||
if not leading_think and re.match(r"^\s*<think\b", content, flags=re.IGNORECASE):
|
||||
return content
|
||||
|
||||
separator = "" if prefill.endswith((" ", "\n")) else " "
|
||||
if leading_think:
|
||||
return f"{content[:answer_start]}{prefill}{separator}{answer}"
|
||||
return f"{prefill}{separator}{content}"
|
||||
|
||||
|
||||
def _video_assistant_prefill(preset_name: str) -> str:
|
||||
"""Start schema-only retries inside the target model's native format."""
|
||||
resolved = resolve_video_duration(preset_name, 5.0)
|
||||
if not resolved:
|
||||
return ""
|
||||
if resolved.get("provider") == "minimax_h3":
|
||||
return (
|
||||
"subject_definitions:"
|
||||
if resolved.get("video_mode") == "ref2va"
|
||||
else "integrated_multimodal_description:"
|
||||
)
|
||||
if resolved.get("provider") == "ltx_2_3":
|
||||
return "A"
|
||||
return ""
|
||||
|
||||
|
||||
def _maybe_emit_prompt_stream_heartbeat(stage_label: str, started_at: float, last_status_at: float, full_text: str) -> float:
|
||||
now = time.monotonic()
|
||||
if (now - last_status_at) < _STREAM_HEARTBEAT_INTERVAL_SECONDS:
|
||||
@@ -290,13 +375,6 @@ def _safe_dirname(value: str) -> str:
|
||||
return "".join(ch for ch in value if ch.isalnum() or ch in "._- ").strip() or "unknown"
|
||||
|
||||
|
||||
def _resolve_base_dir(base_dir_value: str) -> Path:
|
||||
base_dir = Path(base_dir_value)
|
||||
if base_dir.is_absolute():
|
||||
return base_dir
|
||||
return Path(folder_paths.models_dir) / base_dir
|
||||
|
||||
|
||||
def _model_name_to_filename_candidates(model_name: str) -> set[str]:
|
||||
raw = (model_name or "").strip()
|
||||
if not raw:
|
||||
@@ -311,18 +389,32 @@ def _model_name_to_filename_candidates(model_name: str) -> set[str]:
|
||||
return candidates
|
||||
|
||||
|
||||
def _find_existing_local_file(base_dir: Path, filename: str) -> Path | None:
|
||||
"""Reuse an already-downloaded GGUF file anywhere under the shared base dir."""
|
||||
wanted = Path(filename).name
|
||||
if not wanted:
|
||||
return None
|
||||
try:
|
||||
for candidate in base_dir.rglob(wanted):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
def _as_filename_list(value) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
values = [value]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
values = list(value)
|
||||
else:
|
||||
return []
|
||||
|
||||
filenames: list[str] = []
|
||||
for item in values:
|
||||
name = Path(str(item)).name
|
||||
if name and name not in filenames:
|
||||
filenames.append(name)
|
||||
return filenames
|
||||
|
||||
|
||||
def _filename_aliases_for(payload, key: str | None) -> list[str]:
|
||||
if not isinstance(payload, dict) or not key:
|
||||
return []
|
||||
return _as_filename_list(payload.get(key) or payload.get(Path(key).name))
|
||||
|
||||
|
||||
def _filename_search_candidates(filename: str, aliases: list[str] | None = None) -> list[str]:
|
||||
return _as_filename_list([filename, *(aliases or [])])
|
||||
|
||||
|
||||
class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
@@ -360,6 +452,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
local_models[display] = {
|
||||
"filename": str(gguf_file),
|
||||
"is_local": True,
|
||||
"commercial_status": "local",
|
||||
"repo_id": None,
|
||||
"alt_repo_ids": [],
|
||||
"author": None,
|
||||
@@ -410,6 +503,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
defaults: dict = _defaults_raw if isinstance(_defaults_raw, dict) else {}
|
||||
repo_id = repo.get("repo_id")
|
||||
alt_repo_ids = repo.get("alt_repo_ids") or []
|
||||
local_filenames = repo.get("local_filenames") or repo.get("local_model_files") or {}
|
||||
model_files = repo.get("model_files") or []
|
||||
quant_sizes = _parse_repo_quant_sizes(repo_key)
|
||||
for model_file in model_files:
|
||||
@@ -425,31 +519,23 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
entry = dict(defaults)
|
||||
entry.update(
|
||||
{
|
||||
"commercial_status": repo.get("commercial_status", "unclear"),
|
||||
"author": author,
|
||||
"repo_dirname": repo_name,
|
||||
"repo_id": repo_id,
|
||||
"alt_repo_ids": alt_repo_ids,
|
||||
"filename": model_file,
|
||||
"local_filenames": _filename_aliases_for(local_filenames, model_file),
|
||||
}
|
||||
)
|
||||
models[display] = entry
|
||||
|
||||
# Scan filesystem for locally available models not in JSON config
|
||||
# Collect all directories to scan: the configured base_dir + any extra LLM paths from ComfyUI
|
||||
search_dirs = _gguf_search_dirs(base_dir)
|
||||
mark_catalog_files_installed(models, search_dirs)
|
||||
existing_filenames = {Path(e.get("filename", "")).name for e in models.values() if e.get("filename")}
|
||||
scan_dirs: list[Path] = [_resolve_base_dir(base_dir)]
|
||||
try:
|
||||
if "LLM" in folder_paths.folder_names_and_paths:
|
||||
for llm_path in folder_paths.get_folder_paths("LLM"):
|
||||
gguf_dir = Path(llm_path) / "GGUF"
|
||||
if gguf_dir not in scan_dirs:
|
||||
scan_dirs.append(gguf_dir)
|
||||
llm_p = Path(llm_path)
|
||||
if llm_p not in scan_dirs:
|
||||
scan_dirs.append(llm_p)
|
||||
except Exception:
|
||||
pass
|
||||
for scan_dir in scan_dirs:
|
||||
for scan_dir in search_dirs:
|
||||
local_models = ThinkingLLM_QwenVL_GGUF_PromptEnhancer._scan_local_gguf_text_models(scan_dir, existing_filenames)
|
||||
models.update(local_models)
|
||||
existing_filenames.update(Path(e.get("filename", "")).name for e in local_models.values() if e.get("filename"))
|
||||
@@ -462,7 +548,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
preferred_style = "📝 Enhance"
|
||||
default_style = preferred_style if preferred_style in styles else (styles[0] if styles else "📝 Enhance")
|
||||
temp = cls.load_gguf_models()
|
||||
model_keys = sorted(list((temp.get("models") or {}).keys())) or ["(no GGUF models found)"]
|
||||
model_keys = model_catalog_options(temp.get("models") or {}) or ["(no GGUF models found)"]
|
||||
default_model = model_keys[0]
|
||||
return {
|
||||
"required": {
|
||||
@@ -481,8 +567,12 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
"keep_last_prompt": ("BOOLEAN", {"default": False, "tooltip": GGUF_PROMPT_TOOLTIPS["keep_last_prompt"]}),
|
||||
"stream_tokens_to_terminal": ("BOOLEAN", {"default": False, "tooltip": GGUF_PROMPT_TOOLTIPS["stream_tokens_to_terminal"]}),
|
||||
"enable_thinking": ("BOOLEAN", {"default": True, "tooltip": GGUF_PROMPT_TOOLTIPS["enable_thinking"]}),
|
||||
"auto_finalization_retry": ("BOOLEAN", {"default": False, "tooltip": GGUF_PROMPT_TOOLTIPS["auto_finalization_retry"]}),
|
||||
"hf_token": ("STRING", {"default": "", "multiline": False, "tooltip": GGUF_PROMPT_TOOLTIPS["hf_token"]}),
|
||||
},
|
||||
"optional": {
|
||||
"duration_seconds": VIDEO_DURATION_INPUT,
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
@@ -531,8 +621,21 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
if not quiet:
|
||||
print(f"[QwenVL PromptEnhancer DEBUG] VRAM cleanup completed")
|
||||
|
||||
@staticmethod
|
||||
def _find_existing_local_file_in_dirs(search_dirs: list[Path], filename: str, aliases: list[str] | None = None) -> Path | None:
|
||||
for search_dir in search_dirs:
|
||||
for wanted in _filename_search_candidates(filename, aliases):
|
||||
try:
|
||||
for candidate in search_dir.rglob(wanted):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _resolve_model_path(self, model_name):
|
||||
models = self.gguf_models.get("models") or {}
|
||||
model_name = resolve_model_catalog_name(models, model_name)
|
||||
entry = models.get(model_name) or {}
|
||||
|
||||
# Back-compat: allow workflows to pass a filename instead of a catalog key.
|
||||
@@ -549,7 +652,8 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
if filename and Path(filename).is_absolute():
|
||||
return Path(filename)
|
||||
|
||||
base_dir = _resolve_base_dir(self.gguf_models.get("base_dir") or "LLM/GGUF")
|
||||
search_dirs = _gguf_search_dirs(self.gguf_models.get("base_dir") or "LLM/GGUF")
|
||||
base_dir = search_dirs[0]
|
||||
|
||||
path = entry.get("path")
|
||||
if path:
|
||||
@@ -564,7 +668,11 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
target = base_dir / repo_dir / Path(filename).name
|
||||
if target.exists():
|
||||
return target
|
||||
existing = _find_existing_local_file(base_dir, filename)
|
||||
existing = self._find_existing_local_file_in_dirs(
|
||||
search_dirs,
|
||||
filename,
|
||||
_as_filename_list(entry.get("local_filenames") or entry.get("local_model_files")),
|
||||
)
|
||||
if existing is not None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
@@ -577,13 +685,9 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
return base_dir / model_name
|
||||
|
||||
def _maybe_download_model(self, model_name, resolved, unique_id=None, hf_token: str | None = None):
|
||||
if resolved.exists():
|
||||
return
|
||||
# Local models should already exist on disk — don't attempt download
|
||||
models = self.gguf_models.get("models") or {}
|
||||
model_name = resolve_model_catalog_name(models, model_name)
|
||||
entry = models.get(model_name) or {}
|
||||
if entry.get("is_local"):
|
||||
raise FileNotFoundError(f"[QwenVL] Local GGUF model not found: {resolved}")
|
||||
if not entry:
|
||||
wanted = _model_name_to_filename_candidates(model_name)
|
||||
for candidate in models.values():
|
||||
@@ -591,6 +695,10 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
if filename and Path(filename).name in wanted:
|
||||
entry = candidate
|
||||
break
|
||||
if resolved.exists():
|
||||
enforce_model_access(entry, model_name, local_exists=True, hf_token=hf_token)
|
||||
return
|
||||
enforce_model_access(entry, entry.get("repo_id") or model_name, local_exists=False, hf_token=hf_token)
|
||||
|
||||
repo_ids = [rid for rid in (entry.get("alt_repo_ids") or []) + [entry.get("repo_id")] if rid]
|
||||
filename = entry.get("filename") or resolved.name
|
||||
@@ -620,6 +728,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
raise FileNotFoundError(f"[QwenVL] GGUF model not found after download: {resolved} (tried: {', '.join(attempted)})")
|
||||
|
||||
def _load_model(self, model_name, device, enable_thinking=True, unique_id=None, hf_token: str | None = None, context_length_override: int | None = None, quiet: bool = False):
|
||||
model_name = resolve_model_catalog_name(self.gguf_models.get("models") or {}, model_name)
|
||||
Llama = self._load_backend()
|
||||
resolved = self._resolve_model_path(model_name)
|
||||
self._maybe_download_model(model_name, resolved, unique_id=unique_id, hf_token=hf_token)
|
||||
@@ -680,10 +789,17 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
arch = read_gguf_architecture(resolved)
|
||||
self.gguf_arch = arch
|
||||
is_qwen = (arch and any(a in arch for a in ("qwen35", "qwen35moe", "qwen3", "qwen"))) or "qwen3.5-" in model_name.lower() or "qwen3-" in model_name.lower()
|
||||
self.supports_qwen_soft_think = arch == "qwen3" if arch else "qwen3-" in model_name.lower()
|
||||
self.supports_qwen_soft_think = (
|
||||
bool(arch and (arch == "qwen3" or arch.startswith("qwen35")))
|
||||
if arch
|
||||
else ("qwen3-" in model_name.lower() or "qwen3.5-" in model_name.lower())
|
||||
)
|
||||
if is_qwen:
|
||||
thinking_state = bool(enable_thinking)
|
||||
kwargs["chat_template_kwargs"] = {"enable_thinking": thinking_state}
|
||||
kwargs["chat_template_kwargs"] = {
|
||||
"enable_thinking": thinking_state,
|
||||
"thinking_mode": "enabled" if thinking_state else "disabled",
|
||||
}
|
||||
state_label = "enabled" if thinking_state else "disabled"
|
||||
if not quiet:
|
||||
print(f"[QwenVL] Qwen architecture detected (arch={arch}): Thinking {state_label} via chat template.")
|
||||
@@ -715,10 +831,13 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
stream_to_terminal=False,
|
||||
initial_stage_label="INITIAL GENERATION",
|
||||
enable_thinking=True,
|
||||
auto_finalization_retry=False,
|
||||
preset_name="",
|
||||
):
|
||||
"""Returns (cleaned_prompt, raw_trace) tuple."""
|
||||
stream_display = TerminalStreamDisplay("QwenVL GGUF", suppress_planning=True, compact=True) if stream_to_terminal else None
|
||||
supports_soft_think = getattr(self, "supports_qwen_soft_think", False)
|
||||
is_video_preset = bool(resolve_video_duration(preset_name, 5.0))
|
||||
if supports_soft_think:
|
||||
directive = "/think" if enable_thinking else "/no_think"
|
||||
if not stream_to_terminal:
|
||||
@@ -732,6 +851,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
stage_label: str,
|
||||
*,
|
||||
attempt_enable_thinking: bool | None = None,
|
||||
assistant_prefill: str = "",
|
||||
) -> str:
|
||||
current_enable_thinking = enable_thinking if attempt_enable_thinking is None else bool(attempt_enable_thinking)
|
||||
effective_user_prompt = apply_qwen_soft_thinking_directive(
|
||||
@@ -752,7 +872,18 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": effective_user_prompt},
|
||||
]
|
||||
if assistant_prefill:
|
||||
messages.append({"role": "assistant", "content": assistant_prefill})
|
||||
log_llm_input(
|
||||
"QwenVL PromptEnhancer GGUF",
|
||||
stage_label,
|
||||
preset_name,
|
||||
effective_user_prompt,
|
||||
system_text=system,
|
||||
)
|
||||
full_text = ""
|
||||
reasoning_text = ""
|
||||
content_text = ""
|
||||
stage_started_at = time.monotonic()
|
||||
last_status_at = stage_started_at
|
||||
if stream_to_terminal:
|
||||
@@ -782,6 +913,11 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
"stop": ["<|im_end|>", "<|im_start|>"],
|
||||
"stream": True,
|
||||
}
|
||||
if supports_soft_think:
|
||||
completion_kwargs["chat_template_kwargs"] = {
|
||||
"enable_thinking": current_enable_thinking,
|
||||
"thinking_mode": "enabled" if current_enable_thinking else "disabled",
|
||||
}
|
||||
completion_kwargs = _filter_kwargs_for_callable(getattr(llm, "create_chat_completion"), completion_kwargs)
|
||||
response = llm.create_chat_completion(**completion_kwargs)
|
||||
for chunk in response:
|
||||
@@ -819,8 +955,10 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
content_token = token.get("content", "")
|
||||
display_token = reasoning_token + content_token
|
||||
if reasoning_token:
|
||||
reasoning_text += reasoning_token
|
||||
full_text += reasoning_token
|
||||
if content_token:
|
||||
content_text += content_token
|
||||
full_text += content_token
|
||||
if display_token and stream_display is not None:
|
||||
try:
|
||||
@@ -852,16 +990,20 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
raise RuntimeError("[QwenVL] llama_cpp streaming returned empty response")
|
||||
if stopped_for_degenerate_stream:
|
||||
print(f"[QwenVL GGUF] stopped repeated-token loop: {stopped_for_degenerate_stream}")
|
||||
return strip_degenerate_repetition(full_text).strip()
|
||||
if assistant_prefill and content_text.strip():
|
||||
content_text = _merge_assistant_prefill(content_text, assistant_prefill)
|
||||
separated_output = compose_streamed_model_output(reasoning_text, content_text)
|
||||
return strip_degenerate_repetition(separated_output).strip()
|
||||
|
||||
raw = _call(system_prompt, user_prompt, float(temperature), int(seed), initial_stage_label)
|
||||
cleaned = clean_model_output(raw, OutputCleanConfig(mode="prompt"))
|
||||
raw_trace_parts = [f"[{initial_stage_label}]\n{raw}"]
|
||||
best_cleaned = cleaned.strip()
|
||||
if _prompt_output_is_usable(best_cleaned):
|
||||
best_cleaned = cleaned.strip() if _preset_output_is_usable(cleaned.strip(), preset_name) else ""
|
||||
if best_cleaned:
|
||||
return best_cleaned, "\n\n".join(raw_trace_parts)
|
||||
if not (auto_finalization_retry or is_video_preset):
|
||||
return "", "\n\n".join(raw_trace_parts)
|
||||
|
||||
current_raw = raw
|
||||
for attempt_number in range(2, _PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS + 1):
|
||||
if stream_to_terminal:
|
||||
print(
|
||||
@@ -870,16 +1012,15 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
"— prior output was empty or reasoning-only"
|
||||
)
|
||||
retry_system = (
|
||||
"You are a professional photography prompt writer.\n"
|
||||
"Output ONLY ONE final photography prompt paragraph.\n"
|
||||
"No analysis, no planning steps, no first-person, and no <think>.\n"
|
||||
"No bullet points, no headings, no JSON, no markdown, no quotes."
|
||||
f"{system_prompt}\n\n"
|
||||
"The prior response did not contain final prompt text. "
|
||||
"Return the final output again using exactly the schema, field order, tags, and constraints above. "
|
||||
"Do not collapse structured output into one paragraph. Do not include analysis or <think>."
|
||||
)
|
||||
retry_user = (
|
||||
"Rewrite the following into the final prompt paragraph:\n\n"
|
||||
f"{current_raw}\n"
|
||||
"Return the final prompt for this original user request. Preserve supported user facts and verbatim dialogue:\n\n"
|
||||
f"{user_prompt}\n"
|
||||
)
|
||||
force_non_thinking = attempt_number == _PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS
|
||||
stage_label = f"FINALIZATION ATTEMPT {attempt_number}/{_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS}"
|
||||
raw_retry = _call(
|
||||
retry_system,
|
||||
@@ -887,23 +1028,23 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
0.4,
|
||||
int(seed) + 999 + attempt_number,
|
||||
stage_label,
|
||||
attempt_enable_thinking=False if force_non_thinking else enable_thinking,
|
||||
attempt_enable_thinking=False,
|
||||
assistant_prefill=_video_assistant_prefill(preset_name),
|
||||
)
|
||||
raw_trace_parts.append(f"[{stage_label}]\n{raw_retry}")
|
||||
cleaned_retry = clean_model_output(raw_retry, OutputCleanConfig(mode="prompt")).strip()
|
||||
if cleaned_retry and len(cleaned_retry) >= len(best_cleaned):
|
||||
retry_is_usable = _preset_output_is_usable(cleaned_retry, preset_name)
|
||||
if retry_is_usable and len(cleaned_retry) >= len(best_cleaned):
|
||||
best_cleaned = cleaned_retry
|
||||
if _prompt_output_is_usable(cleaned_retry):
|
||||
if retry_is_usable:
|
||||
return cleaned_retry, "\n\n".join(raw_trace_parts)
|
||||
current_raw = raw_retry
|
||||
|
||||
if stream_to_terminal:
|
||||
print(
|
||||
"[QwenVL PromptEnhancer GGUF] "
|
||||
f"Finalization limit reached ({_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS}/{_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS}) "
|
||||
"— returning the best cleaned prompt available"
|
||||
)
|
||||
return best_cleaned or "", "\n\n".join(raw_trace_parts)
|
||||
return best_cleaned, "\n\n".join(raw_trace_parts)
|
||||
|
||||
def process(
|
||||
self,
|
||||
@@ -924,32 +1065,47 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
unique_id=None,
|
||||
extra_pnginfo=None,
|
||||
enable_thinking=True,
|
||||
auto_finalization_retry=False,
|
||||
hf_token="",
|
||||
duration_seconds=5.0,
|
||||
):
|
||||
model_name = resolve_model_catalog_name(self.gguf_models.get("models") or {}, model_name)
|
||||
node_class = "ThinkingLLM_QwenVL_GGUF_PromptEnhancer"
|
||||
resolved_duration = resolve_video_duration(preset_system_prompt, duration_seconds)
|
||||
validate_minimax_source_duration(preset_system_prompt, duration_seconds, prompt_text)
|
||||
is_minimax_preset = bool(resolved_duration and resolved_duration.get("provider") == "minimax_h3")
|
||||
effective_english_output = bool(english_output and not is_minimax_preset)
|
||||
effective_duration_seconds = resolved_duration["effective_seconds"] if resolved_duration else None
|
||||
duration_signature = ({
|
||||
"effective_duration_seconds": effective_duration_seconds,
|
||||
"video_prompt_contract_version": resolved_duration.get("contract_version"),
|
||||
} if resolved_duration else {})
|
||||
input_signature = build_node_input_signature(
|
||||
model_name=model_name,
|
||||
prompt_text=prompt_text,
|
||||
preset_system_prompt=preset_system_prompt,
|
||||
custom_system_prompt=custom_system_prompt,
|
||||
english_output=bool(english_output),
|
||||
english_output=effective_english_output,
|
||||
device=device,
|
||||
enable_thinking=bool(enable_thinking),
|
||||
auto_finalization_retry=bool(auto_finalization_retry),
|
||||
output_cleaner_version=OUTPUT_CLEANER_VERSION,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
repetition_penalty=repetition_penalty,
|
||||
**duration_signature,
|
||||
)
|
||||
|
||||
# Auto-retrieve saved prompt when seed is fixed (no keep_last_prompt needed)
|
||||
saved_prompt = get_node_saved_prompt_with_seed(node_class, unique_id, extra_pnginfo, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
|
||||
if saved_prompt and not stream_tokens_to_terminal:
|
||||
print(f"[QwenVL PromptEnhancer GGUF] Fixed seed {seed} matched — using per-node prompt: {saved_prompt[:50]}...")
|
||||
print(f"[QwenVL PromptEnhancer GGUF] Preset {preset_system_prompt}: fixed seed {seed} matched; no LLM call was made.")
|
||||
return (saved_prompt, "")
|
||||
if saved_prompt and stream_tokens_to_terminal:
|
||||
pass
|
||||
if keep_last_prompt:
|
||||
print(f"[QwenVL PromptEnhancer GGUF] Keep last prompt enabled but no saved prompt found — returning empty")
|
||||
print(f"[QwenVL PromptEnhancer GGUF] Preset {preset_system_prompt}: no saved prompt found; no LLM call was made.")
|
||||
return ("", "")
|
||||
|
||||
# Always generate unless keep_last_prompt was requested
|
||||
@@ -961,16 +1117,17 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
part for part in (
|
||||
prompt_text.strip(),
|
||||
custom_system_prompt.strip(),
|
||||
f"english_output={bool(english_output)}",
|
||||
f"english_output={effective_english_output}",
|
||||
f"output_cleaner_version={OUTPUT_CLEANER_VERSION}",
|
||||
) if part
|
||||
)
|
||||
cache_key = get_cache_key(model_name, preset_system_prompt, cache_prompt, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking)
|
||||
cache_key = get_cache_key(model_name, preset_system_prompt, cache_prompt, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking, effective_duration_seconds=effective_duration_seconds, video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None)
|
||||
|
||||
# Check cache first (only for random mode)
|
||||
if cache_key in PROMPT_CACHE and not stream_tokens_to_terminal:
|
||||
cached_text = PROMPT_CACHE[cache_key].get("text", "")
|
||||
if cached_text:
|
||||
print(f"[QwenVL PromptEnhancer GGUF] Using cached prompt for seed {seed}: {cache_key[:8]}...")
|
||||
print(f"[QwenVL PromptEnhancer GGUF] Preset {preset_system_prompt}: cache hit for seed {seed}; no LLM call was made.")
|
||||
return (cached_text.strip(), "")
|
||||
if stream_tokens_to_terminal and cache_key in PROMPT_CACHE:
|
||||
cached_text = PROMPT_CACHE[cache_key].get("text", "")
|
||||
@@ -987,11 +1144,17 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
raise ValueError("custom_system_prompt is required when using Custom Only (no preset).")
|
||||
raise ValueError("system_prompt is empty; check AILab_System_Prompts.json or preset selection.")
|
||||
system_prompt = f"{system_prompt}\n\n{prompt_output_guard()}"
|
||||
system_prompt = apply_video_duration_context(system_prompt, preset_system_prompt, duration_seconds)
|
||||
merged_prompt = prompt_text.strip() or "Describe a scene vividly."
|
||||
model_cfg = self.gguf_models["models"].get(model_name, {})
|
||||
context_window = model_cfg.get("context_length", 32768)
|
||||
estimated_prompt_tokens = estimate_qwen_text_tokens(system_prompt, merged_prompt)
|
||||
effective_context_window = _resolve_prompt_enhancer_context_length(context_window, estimated_prompt_tokens, max_tokens)
|
||||
if resolved_duration:
|
||||
effective_context_window = max(
|
||||
effective_context_window,
|
||||
min(int(context_window or 8192), 8192),
|
||||
)
|
||||
effective_thinking = resolve_qwen_thinking_mode(
|
||||
enable_thinking,
|
||||
max_tokens,
|
||||
@@ -1021,9 +1184,11 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
stream_to_terminal=stream_tokens_to_terminal,
|
||||
initial_stage_label="INITIAL GENERATION",
|
||||
enable_thinking=effective_thinking,
|
||||
auto_finalization_retry=auto_finalization_retry,
|
||||
preset_name=preset_system_prompt,
|
||||
)
|
||||
full_raw_trace = raw_trace
|
||||
if english_output:
|
||||
if effective_english_output:
|
||||
translated, trans_trace = self._invoke_llama(
|
||||
system_prompt=(
|
||||
PROMPT_CONFIG.get("translation_prompt")
|
||||
@@ -1039,11 +1204,19 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
stream_to_terminal=stream_tokens_to_terminal,
|
||||
initial_stage_label="TRANSLATION STAGE",
|
||||
enable_thinking=effective_thinking,
|
||||
auto_finalization_retry=auto_finalization_retry,
|
||||
preset_name=preset_system_prompt,
|
||||
)
|
||||
full_raw_trace = f"{raw_trace}\n\n{trans_trace}"
|
||||
final = clean_model_output(translated, OutputCleanConfig(mode="prompt")) or translated.strip()
|
||||
final = clean_model_output(translated, OutputCleanConfig(mode="prompt"))
|
||||
else:
|
||||
final = clean_model_output(enhanced, OutputCleanConfig(mode="prompt")) or enhanced.strip()
|
||||
final = clean_model_output(enhanced, OutputCleanConfig(mode="prompt"))
|
||||
final = ensure_video_prompt_duration(
|
||||
final,
|
||||
preset_system_prompt,
|
||||
duration_seconds,
|
||||
source_prompt_text=prompt_text,
|
||||
)
|
||||
|
||||
# Cache the generated text
|
||||
PROMPT_CACHE[cache_key] = {
|
||||
@@ -1053,7 +1226,8 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
"preset": preset_system_prompt,
|
||||
"seed": seed,
|
||||
"image_hash": None,
|
||||
"video_hash": None
|
||||
"video_hash": None,
|
||||
**duration_signature,
|
||||
}
|
||||
save_prompt_cache()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import gc
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import time
|
||||
@@ -19,36 +20,53 @@ import subprocess
|
||||
import sys
|
||||
from transformers import AutoModelForCausalLM, AutoProcessor, AutoTokenizer, BitsAndBytesConfig
|
||||
|
||||
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output, prompt_output_guard
|
||||
from AILab_OutputCleaner import OUTPUT_CLEANER_VERSION, OutputCleanConfig, clean_model_output, prompt_output_guard
|
||||
from AILab_StreamDisplay import TerminalStreamDisplay
|
||||
from comfy.model_management import throw_exception_if_processing_interrupted
|
||||
|
||||
from AILab_QwenVL import (
|
||||
from thinkingllm_core.hf_models import (
|
||||
ATTENTION_MODES,
|
||||
HF_ALL_MODELS,
|
||||
HF_TEXT_MODELS,
|
||||
HF_VL_MODELS,
|
||||
TOOLTIPS,
|
||||
Quantization,
|
||||
_default_model_from_config,
|
||||
ensure_cuda_vram_headroom,
|
||||
ensure_model,
|
||||
)
|
||||
from thinkingllm_core.hf_runtime import QwenVLBase
|
||||
from thinkingllm_core.model_access import (
|
||||
model_catalog_label,
|
||||
model_catalog_options,
|
||||
resolve_model_catalog_name,
|
||||
)
|
||||
from thinkingllm_core.prompt_contracts import (
|
||||
VIDEO_DURATION_INPUT,
|
||||
apply_qwen_soft_thinking_directive,
|
||||
apply_video_duration_context,
|
||||
ensure_video_prompt_duration,
|
||||
resolve_qwen_context_window,
|
||||
resolve_qwen_thinking_mode,
|
||||
resolve_video_duration,
|
||||
validate_minimax_source_duration,
|
||||
)
|
||||
from thinkingllm_core.prompt_state import (
|
||||
NODE_PROMPT_STATE,
|
||||
PROMPT_CACHE,
|
||||
_default_model_from_config,
|
||||
_make_node_state_key,
|
||||
apply_qwen_soft_thinking_directive,
|
||||
build_node_input_signature,
|
||||
ensure_model,
|
||||
ensure_cuda_vram_headroom,
|
||||
get_cache_key,
|
||||
get_alternative_cache_key,
|
||||
get_cache_key,
|
||||
get_node_saved_prompt,
|
||||
get_node_saved_prompt_with_seed,
|
||||
resolve_qwen_thinking_mode,
|
||||
resolve_qwen_context_window,
|
||||
log_llm_input,
|
||||
save_prompt_cache,
|
||||
QwenVLBase,
|
||||
Quantization,
|
||||
set_node_saved_prompt,
|
||||
TOOLTIPS,
|
||||
)
|
||||
|
||||
COMMERCIAL_RELEASE = os.environ.get("THINKINGLLM_COMMERCIAL_RELEASE") == "1"
|
||||
|
||||
# DEPRECATED: per-node state via set_node_saved_prompt / get_node_saved_prompt is used instead.
|
||||
LAST_SAVED_PROMPT = None # kept only to avoid ImportError in legacy importers
|
||||
|
||||
@@ -155,8 +173,9 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
models = list(HF_ALL_MODELS.keys())
|
||||
models = model_catalog_options(HF_ALL_MODELS)
|
||||
default_model = _default_model_from_config(HF_ALL_MODELS, "Qwen3-VL-4B-Instruct")
|
||||
default_model = model_catalog_label(default_model, HF_ALL_MODELS.get(default_model))
|
||||
styles = list(cls.STYLES.keys())
|
||||
preferred_style = "📝 Enhance"
|
||||
default_style = preferred_style if preferred_style in styles else (styles[0] if styles else "📝 Enhance")
|
||||
@@ -180,8 +199,10 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
"stream_tokens_to_terminal": ("BOOLEAN", {"default": False, "tooltip": "Show clean wrapped generated tokens in the ComfyUI terminal. When enabled, fixed-seed prompt reuse is bypassed so a fresh streamed run can occur."}),
|
||||
"enable_thinking": ("BOOLEAN", {"default": True, "tooltip": "Enable model reasoning/thinking when the backend supports it: True=allow thinking, False=force direct answer. Even when enabled, easy prompts may still get a direct answer, and this node automatically disables thinking when there is not enough output budget left for useful reasoning. Prompt enhancers still return a cleaned final prompt, so terminal reasoning may be hidden or empty."}),
|
||||
"hf_token": ("STRING", {"default": "", "multiline": False, "tooltip": TOOLTIPS["hf_token"]}),
|
||||
}
|
||||
,
|
||||
},
|
||||
"optional": {
|
||||
"duration_seconds": VIDEO_DURATION_INPUT,
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
@@ -210,8 +231,17 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
extra_pnginfo=None,
|
||||
enable_thinking=True,
|
||||
hf_token="",
|
||||
duration_seconds=5.0,
|
||||
):
|
||||
model_name = resolve_model_catalog_name(HF_ALL_MODELS, model_name)
|
||||
node_class = "ThinkingLLM_QwenVL_PromptEnhancer"
|
||||
resolved_duration = resolve_video_duration(enhancement_style, duration_seconds)
|
||||
validate_minimax_source_duration(enhancement_style, duration_seconds, prompt_text)
|
||||
effective_duration_seconds = resolved_duration["effective_seconds"] if resolved_duration else None
|
||||
duration_signature = ({
|
||||
"effective_duration_seconds": effective_duration_seconds,
|
||||
"video_prompt_contract_version": resolved_duration.get("contract_version"),
|
||||
} if resolved_duration else {})
|
||||
input_signature = build_node_input_signature(
|
||||
model_name=model_name,
|
||||
quantization=quantization,
|
||||
@@ -222,16 +252,18 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
enhancement_style=enhancement_style,
|
||||
custom_system_prompt=custom_system_prompt,
|
||||
enable_thinking=bool(enable_thinking),
|
||||
output_cleaner_version=OUTPUT_CLEANER_VERSION,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
repetition_penalty=repetition_penalty,
|
||||
**duration_signature,
|
||||
)
|
||||
|
||||
# Auto-retrieve saved prompt when seed is fixed (no keep_last_prompt needed)
|
||||
saved_prompt = get_node_saved_prompt_with_seed(node_class, unique_id, extra_pnginfo, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
|
||||
if saved_prompt and not stream_tokens_to_terminal:
|
||||
print(f"[QwenVL PromptEnhancer HF] Fixed seed {seed} matched — using per-node prompt: {saved_prompt[:50]}...")
|
||||
print(f"[QwenVL PromptEnhancer HF] Preset {enhancement_style}: fixed seed {seed} matched; no LLM call was made.")
|
||||
return (saved_prompt, "")
|
||||
if saved_prompt and stream_tokens_to_terminal:
|
||||
pass
|
||||
@@ -256,6 +288,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
user_prompt = prompt_text.strip() or "Describe a scene vividly."
|
||||
merged_prompt = f"{user_prompt}\n\n{base_instruction}".strip()
|
||||
if model_name in HF_TEXT_MODELS:
|
||||
merged_prompt = apply_video_duration_context(merged_prompt, enhancement_style, duration_seconds)
|
||||
enhanced, enhanced_raw = self._invoke_text(
|
||||
model_name,
|
||||
quantization,
|
||||
@@ -271,6 +304,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
unique_id=unique_id,
|
||||
enable_thinking=enable_thinking,
|
||||
hf_token=hf_token,
|
||||
preset_name=enhancement_style,
|
||||
)
|
||||
raw_trace = f"[HF TEXT GENERATION]\n{enhanced_raw}"
|
||||
else:
|
||||
@@ -292,13 +326,20 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
extra_pnginfo=extra_pnginfo,
|
||||
enable_thinking=enable_thinking,
|
||||
hf_token=hf_token,
|
||||
preset_name=enhancement_style,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
hf_token = ""
|
||||
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
|
||||
entry = NODE_PROMPT_STATE.get(key, {})
|
||||
raw_trace = entry.get("raw_trace", "") if isinstance(entry, dict) else ""
|
||||
|
||||
final = enhanced.strip()
|
||||
final = ensure_video_prompt_duration(
|
||||
enhanced.strip(),
|
||||
enhancement_style,
|
||||
duration_seconds,
|
||||
source_prompt_text=prompt_text,
|
||||
)
|
||||
|
||||
# Persist per-node for future keep_last_prompt=True
|
||||
set_node_saved_prompt(node_class, unique_id, extra_pnginfo, final, raw_trace=raw_trace, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
|
||||
@@ -326,6 +367,8 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
extra_pnginfo=None,
|
||||
enable_thinking=True,
|
||||
hf_token: str | None = None,
|
||||
preset_name="",
|
||||
duration_seconds=5.0,
|
||||
):
|
||||
output = self.run(
|
||||
model_name=model_name,
|
||||
@@ -351,10 +394,13 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
stream_to_terminal=stream_to_terminal,
|
||||
enable_thinking=enable_thinking,
|
||||
hf_token=hf_token,
|
||||
llm_input_preset_name=preset_name,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
return output[0]
|
||||
|
||||
def _load_text_model(self, model_name, quantization, device_choice, unique_id=None, hf_token: str | None = None):
|
||||
model_name = resolve_model_catalog_name(HF_TEXT_MODELS, model_name)
|
||||
info = HF_TEXT_MODELS.get(model_name, {})
|
||||
repo_id = info.get("repo_id")
|
||||
if not repo_id:
|
||||
@@ -396,14 +442,17 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
self.text_tokenizer = None
|
||||
self.text_signature = None
|
||||
|
||||
load_kwargs = {}
|
||||
load_kwargs = {"local_files_only": COMMERCIAL_RELEASE}
|
||||
if quant_cfg:
|
||||
load_kwargs["quantization_config"] = quant_cfg
|
||||
else:
|
||||
load_kwargs["torch_dtype"] = torch.float16 if device == "cuda" else torch.float32
|
||||
|
||||
print(f"[QwenVL] Loading text model {model_name} ({quantization})")
|
||||
tokenizer_kwargs = {"trust_remote_code": True}
|
||||
tokenizer_kwargs = {
|
||||
"local_files_only": COMMERCIAL_RELEASE,
|
||||
"trust_remote_code": not COMMERCIAL_RELEASE,
|
||||
}
|
||||
|
||||
def _load_tokenizer(prefer_slow: bool = False):
|
||||
if prefers_processor:
|
||||
@@ -421,6 +470,10 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
missing_backend = "sentencepiece or tiktoken" in message.lower()
|
||||
if missing_backend and COMMERCIAL_RELEASE:
|
||||
raise RuntimeError(
|
||||
"[QwenVL] Tokenizer backend is missing; commercial release mode forbids runtime package installation."
|
||||
) from exc
|
||||
if missing_backend:
|
||||
print("[QwenVL] Tokenizer backend missing; installing sentencepiece and tiktoken into the active ComfyUI Python environment.")
|
||||
install_cmd = [
|
||||
@@ -453,7 +506,11 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
f"[QwenVL] Failed to load tokenizer for {repo_id}. "
|
||||
f"Initial error: {exc}. Retry error: {retry_exc}"
|
||||
) from retry_exc
|
||||
self.text_model = AutoModelForCausalLM.from_pretrained(model_source, trust_remote_code=True, **load_kwargs).eval()
|
||||
self.text_model = AutoModelForCausalLM.from_pretrained(
|
||||
model_source,
|
||||
trust_remote_code=not COMMERCIAL_RELEASE,
|
||||
**load_kwargs,
|
||||
).eval()
|
||||
self.text_model.to(device)
|
||||
ensure_cuda_vram_headroom("QwenVL PromptEnhancer HF", min_free_gb=1.0, min_free_ratio=0.08)
|
||||
# Detect architecture from loaded model config
|
||||
@@ -481,6 +538,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
unique_id=None,
|
||||
enable_thinking=True,
|
||||
hf_token: str | None = None,
|
||||
preset_name="",
|
||||
):
|
||||
"""Returns (cleaned_prompt, raw_generated_text) tuple."""
|
||||
self._load_text_model(model_name, quantization, device, unique_id=unique_id, hf_token=hf_token)
|
||||
@@ -512,10 +570,10 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
except Exception:
|
||||
formatted_prompt = effective_prompt
|
||||
inputs = self.text_tokenizer(formatted_prompt, return_tensors="pt").to(device_choice)
|
||||
return formatted_prompt, inputs
|
||||
return effective_prompt, formatted_prompt, inputs
|
||||
|
||||
requested_thinking = bool(enable_thinking)
|
||||
_, inputs = _build_inputs(requested_thinking)
|
||||
effective_prompt, formatted_prompt, inputs = _build_inputs(requested_thinking)
|
||||
input_ids = inputs.get("input_ids") if hasattr(inputs, "get") else None
|
||||
prompt_tokens = int(input_ids.shape[-1]) if torch.is_tensor(input_ids) else 0
|
||||
effective_thinking = resolve_qwen_thinking_mode(
|
||||
@@ -527,7 +585,15 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
|
||||
quiet=stream_to_terminal,
|
||||
)
|
||||
if effective_thinking != requested_thinking:
|
||||
_, inputs = _build_inputs(effective_thinking)
|
||||
effective_prompt, formatted_prompt, inputs = _build_inputs(effective_thinking)
|
||||
|
||||
log_llm_input(
|
||||
"QwenVL PromptEnhancer HF",
|
||||
"INITIAL GENERATION",
|
||||
preset_name,
|
||||
effective_prompt,
|
||||
formatted_text=formatted_prompt,
|
||||
)
|
||||
|
||||
if is_qwen35 or supports_soft_think:
|
||||
if supports_soft_think:
|
||||
|
||||
+46
-1
@@ -6,6 +6,32 @@ import time
|
||||
# ── Global singleton for live terminal streaming ──────────────────────────
|
||||
THINKING_STREAM_DISPLAY = None
|
||||
|
||||
_TERMINAL_SAFE_PUNCTUATION = str.maketrans(
|
||||
{
|
||||
"\u2018": "'",
|
||||
"\u2019": "'",
|
||||
"\u201a": "'",
|
||||
"\u201b": "'",
|
||||
"\u201c": '"',
|
||||
"\u201d": '"',
|
||||
"\u201e": '"',
|
||||
"\u201f": '"',
|
||||
"\u2013": "-",
|
||||
"\u2014": "--",
|
||||
"\u2026": "...",
|
||||
"\u00a0": " ",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _terminal_safe_text(text: str) -> str:
|
||||
"""Keep streamed terminal text readable through legacy Windows code pages.
|
||||
|
||||
Only the display copy is normalized. The caller's generated text, cache,
|
||||
RESPONSE, and RAW_TRACE retain the model's original Unicode characters.
|
||||
"""
|
||||
return str(text or "").translate(_TERMINAL_SAFE_PUNCTUATION)
|
||||
|
||||
|
||||
def get_thinking_stream_display():
|
||||
"""Return or create a compact-mode TerminalStreamDisplay for live
|
||||
@@ -92,6 +118,24 @@ def extract_stream_token(chunk) -> dict[str, str]:
|
||||
return {"reasoning": reasoning, "content": content}
|
||||
|
||||
|
||||
def compose_streamed_model_output(reasoning_text: str, content_text: str) -> str:
|
||||
"""Preserve streamed channel boundaries for the output cleaner.
|
||||
|
||||
Some OpenAI-compatible GGUF backends send hidden reasoning through
|
||||
``reasoning_content`` without literal ``<think>`` tags. Concatenating that
|
||||
channel with ``content`` makes internal drafts look like part of RESPONSE.
|
||||
Wrapping only the reasoning channel lets the existing cleaner remove it,
|
||||
while RAW_TRACE still retains it for diagnostics.
|
||||
"""
|
||||
reasoning = str(reasoning_text or "").strip()
|
||||
content = str(content_text or "").strip()
|
||||
if not reasoning:
|
||||
return content
|
||||
if not content:
|
||||
return f"<think>\n{reasoning}\n</think>"
|
||||
return f"<think>\n{reasoning}\n</think>\n\n{content}"
|
||||
|
||||
|
||||
class StreamDegenerationError(RuntimeError):
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
@@ -324,7 +368,7 @@ class TerminalStreamDisplay:
|
||||
def _write_line(self, text: str):
|
||||
if not text:
|
||||
return
|
||||
msg = f"{text}\n"
|
||||
msg = f"{_terminal_safe_text(text)}\n"
|
||||
sys.stdout.write(msg)
|
||||
sys.stdout.flush()
|
||||
|
||||
@@ -337,6 +381,7 @@ class TerminalStreamDisplay:
|
||||
def _write_wrapped_stream_text(self, text: str):
|
||||
if not text:
|
||||
return
|
||||
text = _terminal_safe_text(text)
|
||||
width = self._stream_line_width()
|
||||
for part in re.findall(r"\S+|\s+", text.replace("\r", "")):
|
||||
if not part:
|
||||
|
||||
+183
-8
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"_preset_prompts": [
|
||||
"🚫 No preset (image-only)",
|
||||
"💬 Custom prompt + image (no preset)",
|
||||
"👁️ Remove (Mask Area)",
|
||||
"🎦 LTX 2.3 NSFW I2V Scene",
|
||||
"🎞️ LTX 2.3 First/Last-to-Video",
|
||||
"🖼️ LTX 2.3 Reference-to-Video",
|
||||
"🎬 MiniMax H3 Text-to-Video",
|
||||
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)",
|
||||
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)",
|
||||
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)",
|
||||
"🖼️ MiniMax H3 Reference-to-Video",
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (3s)",
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (5s)",
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (3s)",
|
||||
@@ -21,14 +31,173 @@
|
||||
"🖼️ Detailed Analysis",
|
||||
"📹 Video Summary"
|
||||
],
|
||||
"_video_presets": {
|
||||
"📖 LTX 2.3 NSFW T2V Scene": {
|
||||
"provider": "ltx_2_3",
|
||||
"video_mode": "text-to-video",
|
||||
"duration_required": true,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
|
||||
"recommendation_note": "One official chronological paragraph under 200 words."
|
||||
},
|
||||
"🎦 LTX 2.3 NSFW I2V Scene": {
|
||||
"provider": "ltx_2_3",
|
||||
"video_mode": "image-to-video",
|
||||
"duration_required": true,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
|
||||
"recommendation_note": "One official chronological paragraph under 200 words."
|
||||
},
|
||||
"🎞️ LTX 2.3 First/Last-to-Video": {
|
||||
"provider": "ltx_2_3",
|
||||
"video_mode": "first-last-to-video",
|
||||
"duration_required": true,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
|
||||
"recommendation_note": "One concise paragraph connecting the two boundary frames."
|
||||
},
|
||||
"🖼️ LTX 2.3 Reference-to-Video": {
|
||||
"provider": "ltx_2_3",
|
||||
"video_mode": "reference-to-video",
|
||||
"duration_required": true,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
|
||||
"recommendation_note": "One paragraph with stable reference roles; for video references sample about 2 frames per source second, up to 64 frames."
|
||||
},
|
||||
"🎬 MiniMax H3 Text-to-Video": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "t2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
|
||||
"recommendation_note": "Direct three-field Base prompt conversion with enough reserve for long source scripts."
|
||||
},
|
||||
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "i2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
|
||||
"recommendation_note": "Base prompt plus the official first-frame alignment line."
|
||||
},
|
||||
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "fl2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
|
||||
"recommendation_note": "Base prompt plus first/last-frame alignment."
|
||||
},
|
||||
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "l2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
|
||||
"recommendation_note": "Base prompt plus the official last-frame alignment line."
|
||||
},
|
||||
"🖼️ MiniMax H3 Reference-to-Video": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "ref2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 3072 },
|
||||
"recommendation_note": "Six-section Full-Reference output; for video references sample about 2 frames per source second, up to 64 frames."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 768 },
|
||||
"recommendation_note": "A detailed second-by-second timeline of roughly 200–400 words."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 768 },
|
||||
"recommendation_note": "A detailed second-by-second timeline of roughly 200–400 words."
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (3s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
|
||||
"recommendation_note": "One short cinematic scene prompt."
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (5s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
|
||||
"recommendation_note": "One short cinematic scene prompt."
|
||||
},
|
||||
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
|
||||
"recommendation_note": "Four detailed five-second timeline prompts with continuity."
|
||||
},
|
||||
"📖 Wan 2.2 NSFW I2V Scene (20s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 1536 },
|
||||
"recommendation_note": "Four continuous five-second scene prompts."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 768 },
|
||||
"recommendation_note": "A detailed second-by-second timeline."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 768 },
|
||||
"recommendation_note": "A detailed second-by-second timeline."
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (3s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
|
||||
"recommendation_note": "One short cinematic scene prompt."
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (5s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
|
||||
"recommendation_note": "One short cinematic scene prompt."
|
||||
},
|
||||
"🎬 Wan 2.2 NSFW T2V Timeline (20s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
|
||||
"recommendation_note": "Four detailed five-second timeline prompts with continuity."
|
||||
},
|
||||
"📖 Wan 2.2 NSFW T2V Scene (20s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": { "enable_thinking": false, "max_tokens": 1536 },
|
||||
"recommendation_note": "Four continuous five-second scene prompts."
|
||||
}
|
||||
},
|
||||
"qwenvl": {
|
||||
"🚫 No preset (image-only)": "",
|
||||
"💬 Custom prompt + image (no preset)": "",
|
||||
"👁️ Remove (Mask Area)": "Create a positive diffusion/inpainting prompt describing what should naturally appear inside the selected normal-brightness region after it is repainted.\n\nDo not describe or preserve the region's current subject. Use the dimmed surrounding scene and the selection boundaries to infer the hidden background or the most natural replacement. Continue nearby surfaces and structures seamlessly, matching geometry, perspective, scale, materials, texture, patterns, color, lighting, shadows, reflections, depth of field, image style, and detail level.\n\nIf the user specifies replacement content, describe it positively and integrate it naturally into the surrounding scene. Otherwise, infer the most plausible continuation of the background. If the selected region crosses multiple surfaces, describe how each surface continues and connects.\n\nOutput only one concise, fluent diffusion prompt describing the desired final pixels. Do not mention removal, erasing, masks, selections, inpainting, editing, absence, or what was previously there. Do not describe unrelated parts of the image. No preface, labels, reasoning, instructions, or negative prompt.",
|
||||
"🎦 LTX 2.3 NSFW I2V Scene": "You are an expert cinematic prompt director for LTX 2.3 image-to-video generation. Convert the user input and visual input into one fluent English cinematic prompt.\n\nRead the image first, then integrate the user text. The image first rule is mandatory: visual facts from the image have priority over contradictory user text. Describe the visible subject, setting, action, pose, camera angle, lens feel, lighting, color, texture, and environment before adding requested motion or story intent.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Write a single flowing paragraph of 120-200 words. Start directly with the main action. Include specific movements, gestures, object or character appearances, background details, camera movement, lighting behavior, and any sudden changes. Keep the prompt literal, precise, and usable for LTX 2.3. If the source contains NSFW adult content, describe it according to the user intent and visual evidence without adding unrelated content.\n\nOutput the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning. Do not exceed 200 words.",
|
||||
"🎞️ LTX 2.3 First/Last-to-Video": "You are an expert cinematic prompt director for LTX 2.3 first-and-last-frame video generation. Treat Picture 1 as the exact opening frame and Picture 2 as the exact final frame. Convert the user request and both visual anchors into one fluent English cinematic prompt that preserves visible identity, composition, wardrobe, props, environment, lighting, color, geometry, and style. Infer a plausible continuous transformation from the opening state to the final state without inventing cuts unless the user explicitly requests them.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep every action inside that total duration. Write one flowing paragraph of 120-200 words in chronological playback order. Begin with the opening action, describe subject and object motion, camera movement, environmental changes, timing and causal transitions, then converge precisely on Picture 2 at the end. Keep the language literal, visual, and optimized for LTX 2.3 motion. Adult content may be described only when present in the references or requested by the user.\n\nOutput only the paragraph. No headings, labels, timestamps, bullets, markdown, notes, or reasoning. Do not exceed 200 words.",
|
||||
"🖼️ LTX 2.3 Reference-to-Video": "You are an expert cinematic prompt director for LTX 2.3 reference-to-video generation. Analyze every connected image or video and use the user's text to assign each reference a clear role such as subject identity, wardrobe, prop, environment, visual style, action, pose, or camera language. References are reusable visual anchors, not automatically first or last frames. Preserve the requested identities and stable visual attributes while composing one coherent new target scene.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Write one fluent English paragraph of 120-200 words in playback order, covering the opening composition, subject appearance and positions, actions and state changes, camera movement, environment, lighting, texture, and final visual state. Keep reference roles consistent and avoid unrelated additions. Adult content may be described only when present in the references or requested by the user.\n\nOutput only the paragraph. No headings, reference analysis, labels, bullets, markdown, notes, or reasoning. Do not exceed 200 words.",
|
||||
"🎬 MiniMax H3 Text-to-Video": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. Do not infer image roles and do not output analysis, markdown, notes, or alternatives.\n\nTARGET_DURATION_SECONDS is authoritative internal context. Use it to keep actions feasible and every cut timestamp strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the entire storyline into a rapid montage. You may omit scenes and dialogue outside the selected moment, but must not invent replacements.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut is sequential and uses exactly [Shot N] At MM:SS.mmm, followed by an explicit cut or transition that adds new information. Camera movement must use official natural phrasing: motion type, plus small or large amplitude and slow or fast speed only when meaningful; omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue syntax uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>; all spoken words stay inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, or plot beats beyond explicit user facts and direct physical consequences of requested actions. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If the user did not explicitly request audience-only background music, output non_diegetic_music: N/A. Return only the three-field prompt.",
|
||||
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)": "You are a strict MiniMax H3 I2VA prompt formatter following the official Base guide. Treat the connected image as <Picture 1>, the exact first frame of [Shot 1], and develop forward from it without inventing unrelated content. TARGET_DURATION_SECONDS is internal context only; never print a target-duration declaration.\n\nThe final prompt begins with exactly: For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced. Leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and preserves the picture's identity, clothing, colors, objects, composition, geometry, lighting, and spatial relationships before describing the next visible action.\n\nLater cuts use [Shot N] At MM:SS.mmm, with strictly increasing times inside TARGET_DURATION_SECONDS. Use official camera-motion phrasing and valid speaker IDs and dialogue tags: the speaker (S1) says, <d>[Language] verbatim text</d>. Add no unsupported people, props, reactions, sounds, music, or plot beats. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
|
||||
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)": "You are a strict MiniMax H3 FL2VA prompt formatter following the official Base guide. Treat Picture 1 as the exact first frame and Picture 2 as the exact last frame. Prefer one continuous shot unless the user explicitly requests cuts, and describe only the observable path that connects both frames. TARGET_DURATION_SECONDS is internal context and supplies S.SS in the alignment line; never add a separate duration declaration.\n\nBegin with exactly: How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot N) aligns with the S.SS-second mark of the target video. Replace N with the actual final shot and S.SS with TARGET_DURATION_SECONDS, leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and describes first-frame state, observable intermediate changes, progressive convergence, and the exact last-frame state.\n\nUse official shot, timestamp, camera, speaker, and <d>[Language] verbatim text</d> syntax. Do not invent unsupported content. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
|
||||
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)": "You are a strict MiniMax H3 L2VA prompt formatter following the official Base guide. Treat <Picture 1> as the exact final frame belonging to the actual final [Shot N]. Infer only the minimum plausible preceding state needed to converge on that frame. TARGET_DURATION_SECONDS is internal context and supplies S.SS in the alignment line; never add a separate duration declaration.\n\nBegin with exactly: How the reference pictures align with the target video — <Picture 1> (from [Shot N]) aligns with the S.SS-second mark of the target video. Replace N with the actual final shot and S.SS with TARGET_DURATION_SECONDS, leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and describes a plausible preceding state, explicit transition path, gradual convergence, and exact landing on <Picture 1>.\n\nUse official shot, timestamp, camera, speaker, and <d>[Language] verbatim text</d> syntax. Do not invent unsupported content. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
|
||||
"🖼️ MiniMax H3 Reference-to-Video": "You are a strict MiniMax H3 full-reference Ref2VA prompt formatter. Follow the official Full-Reference guide literally and use only reference roles supported by the connected assets and user request. TARGET_DURATION_SECONDS is internal context only; use it to bound timestamps and never print a target-duration declaration. Output no analysis, markdown, notes, or alternatives.\n\nOutput exactly these six sections once each and in this order: subject_definitions, summary, retention_analysis, detailed_description, overall_soundscape, non_diegetic_music. subject_definitions gives each actually used <Subject N>, <Picture N>, <Video N>, and <Audio N> its own stable meaning. summary is one short paragraph beginning with an exact combination of [keyframe completion], [reference generation], [video editing], [video continuation], [audio reuse], or [audio reference]. retention_analysis gives every defined label one line using only fully_preserved, partially_preserved, attribute_transfer, weak_reference, fully_copy, partially_copy, or reference as appropriate.\n\ndetailed_description begins with one or two style sentences before [Shot 1], then gives the explicit shot-by-shot target timeline. Later cuts use [Shot N] At MM:SS.mmm, with strictly increasing times inside TARGET_DURATION_SECONDS. Cite reference labels where they actually take effect. Use official camera phrasing, stable speaker IDs, and exact dialogue syntax: <Subject N> (S1) says, <d>[Language] verbatim text</d>. Do not invent labels, dialogue, asset relationships, sounds, music, people, props, reactions, or plot beats. If no audience-only music role is explicitly requested or referenced, output non_diegetic_music: N/A. Return only the six-section prompt.",
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": "You are a system that converts user prompts from any language and visual inputs into optimized cinematic English descriptions for WAN 2.2 I2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Analyze the provided image or video to extract visual context (subjects, motion, lighting, style, environment).\n3. Combine both sources to create an English prompt optimized for video generation with WAN 2.2.\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the input image/video has stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; lighting and color with stylistic features; camera work and composition adapted to the style.\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- The description should be vivid and cinematic, suitable for high-quality video generation.\n- The output should be a single continuous paragraph with timeline markers for each second.\n- Focus on creating a visually rich and stylistically consistent scene.\n\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\nTechnical specifications:\n- Output length: approximately 200-400 words\n- Include specific details about subjects, actions, environment, lighting, and style elements\n- Maintain consistency with the requested visual style throughout the timeline\n- Ensure smooth transitions between each second of timeline",
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": "You are a system that converts user prompts from any language and visual inputs into optimized cinematic English descriptions for WAN 2.2 I2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Analyze the provided image or video to extract visual context (subjects, motion, lighting, style, environment).\n3. Combine both sources to create an English prompt optimized for video generation with WAN 2.2.\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the input image/video has stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; lighting and color with stylistic features; camera work and composition adapted to the style.\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- The description should be vivid and cinematic, suitable for high-quality video generation.\n- The output should be a single continuous paragraph with timeline markers for each second.\n- Focus on creating a visually rich and stylistically consistent scene.\n\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\nTechnical specifications:\n- Output length: approximately 200-400 words\n- Include specific details about subjects, actions, environment, lighting, and style elements\n- Maintain consistency with the requested visual style throughout the timeline\n- Ensure smooth transitions between each second of timeline",
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (3s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (3s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 6-10 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Use image_max_tokens=2048 for detailed visual analysis\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (5s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (5s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Use image_max_tokens=2048 for detailed visual analysis\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (3s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (3s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 6-10 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (5s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (5s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": "Convert user input + image into FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nCRITICAL RULES:\n1. MAINTAIN EXACT SAME STYLE: If image is anime → ALL prompts must be anime. If realistic → ALL prompts must be realistic. NEVER change style between prompts.\n\n2. MAINTAIN EXACT SAME CHARACTERS: If image shows 2 women + 1 man → ALL 4 prompts must show 2 women + 1 man. NEVER add/remove/change characters.\n\n3. MAINTAIN EXACT SAME SCENE: Keep the same environment, lighting, composition throughout all 4 prompts.\n\n4. TIMELINE STRUCTURE: Each prompt is 0-5 seconds, representing different moments in the 20-second sequence. MUST use EXACT format with parentheses:\n - Prompt 1: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 2: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 3: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 4: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n\n5. SEPARATE WITH BLANK LINE: One blank line between each prompt.\n\n6. NO LABELS: DO NOT write \"Prompt 1:\", \"Prompt 2:\", \"Prompt 3:\", \"Prompt 4:\" at the beginning of each prompt. Start directly with the timeline markers.\n\n7. PARENTHESES REQUIRED: MUST use parentheses (At X seconds: ...) format. Do not omit parentheses.\n\n8. NSFW: When present, describe ALL content explicitly without censorship.\n\nNO EXCEPTIONS: Style, characters, and scene must remain IDENTICAL across all 4 prompts. Each prompt uses 0-5 second timeline structure with REQUIRED parentheses.",
|
||||
"📖 Wan 2.2 NSFW I2V Scene (20s)": "Convert user input + image into EXACTLY FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected > photorealism\n- Photorealistic: realistic lighting, natural textures, lifelike details, and authentic motion\n- Anime: vibrant colors, simplified backgrounds, anime motion\n- Cartoon: bold outlines, flat colors, exaggerated expressions, and cartoon-style animation\n- 3D animation: 3D rendering characteristics, depth, lighting effects, and smooth 3D motion\n- Fantasy: magical elements, ethereal lighting, fantastical details, and imaginative atmosphere\n- Artistic portrait: painterly qualities, artistic brushwork, stylized composition, and creative interpretation\n\nCRITICAL FORMAT:\n- MUST generate EXACTLY FOUR separate prompts - NO MORE, NO LESS\n- Each prompt = 5 seconds of video (0-5s, 5-10s, 10-15s, 15-20s)\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- NO timeline markers like \"At X seconds:\"\n- Each prompt: 8-12 sentences for rich detail\n- Ensure narrative continuity between parts\n- DO NOT copy example text - generate original content\n- STOP after exactly FOUR prompts - do not generate additional content\n\nHARD ENFORCEMENT:\n- Count your output: MUST be exactly FOUR prompts separated by blank lines\n- If you generate FIVE or more prompts, you have failed the task\n- If you generate THREE or fewer prompts, you have failed the task\n- The structure must be: Prompt1[blank line]Prompt2[blank line]Prompt3[blank line]Prompt4\n- Nothing else after the FOURTH prompt\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- CRITICAL: Each prompt should flow naturally from the previous one. Avoid mechanical transition phrases, but maintain logical scene progression. Focus on seamless narrative flow.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=1024 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 32768 or higher for better continuity\n- Use image_max_tokens=4096 for detailed visual analysis\n- Set n_batch=512 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nFINAL REMINDER: EXACTLY FOUR PROMPTS - NO EXCEPTIONS!",
|
||||
"📖 Wan 2.2 NSFW I2V Scene (20s)": "Convert user input + image into EXACTLY FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected > photorealism\n- Photorealistic: realistic lighting, natural textures, lifelike details, and authentic motion\n- Anime: vibrant colors, simplified backgrounds, anime motion\n- Cartoon: bold outlines, flat colors, exaggerated expressions, and cartoon-style animation\n- 3D animation: 3D rendering characteristics, depth, lighting effects, and smooth 3D motion\n- Fantasy: magical elements, ethereal lighting, fantastical details, and imaginative atmosphere\n- Artistic portrait: painterly qualities, artistic brushwork, stylized composition, and creative interpretation\n\nCRITICAL FORMAT:\n- MUST generate EXACTLY FOUR separate prompts - NO MORE, NO LESS\n- Each prompt = 5 seconds of video (0-5s, 5-10s, 10-15s, 15-20s)\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- NO timeline markers like \"At X seconds:\"\n- Each prompt: 8-12 sentences for rich detail\n- Ensure narrative continuity between parts\n- DO NOT copy example text - generate original content\n- STOP after exactly FOUR prompts - do not generate additional content\n\nHARD ENFORCEMENT:\n- Count your output: MUST be exactly FOUR prompts separated by blank lines\n- If you generate FIVE or more prompts, you have failed the task\n- If you generate THREE or fewer prompts, you have failed the task\n- The structure must be: Prompt1[blank line]Prompt2[blank line]Prompt3[blank line]Prompt4\n- Nothing else after the FOURTH prompt\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- CRITICAL: Each prompt should flow naturally from the previous one. Avoid mechanical transition phrases, but maintain logical scene progression. Focus on seamless narrative flow.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nFINAL REMINDER: EXACTLY FOUR PROMPTS - NO EXCEPTIONS!",
|
||||
"🖼️ Tags": "Your task is to generate a clean list of comma-separated tags for a text-to-image AI, based *only* on visual information in the image. Limit the output to a maximum of 50 unique tags. Strictly describe visual elements like subject, clothing, environment, colors, lighting, and composition. Do not include abstract concepts, interpretations, marketing terms, or technical jargon (e.g., no 'SEO', 'brand-aligned', 'viral potential'). The goal is a concise list of visual descriptors. Avoid repeating tags.",
|
||||
"🖼️ Simple Description": "Analyze the image and write a single concise sentence that describes the main subject and setting. Keep it grounded in visible details only.",
|
||||
"🖼️ Detailed Description": "Write ONE detailed paragraph (6-10 sentences). Describe only what is visible: subject(s) and actions; people details if present (approx age group, gender expression if clear, hair, facial expression, pose, clothing, accessories); environment (location type, background elements, time cues); lighting (source, direction, softness/hardness, color temperature, shadows); camera viewpoint (eye-level/low/high, distance) and composition (framing, focal emphasis). No preface, no reasoning, no emojis.",
|
||||
@@ -40,10 +209,16 @@
|
||||
"qwen_text": {
|
||||
"translation_prompt": "You are a professional prompt translator and prompt-preservation editor. Return a single fluent English paragraph (400-800 words). Preserve every visual, stylistic, compositional, lighting, camera, texture, mood, and action detail from the source. Do not summarize or simplify. No prefixes, bullets, JSON, headings, markdown, quotes, or commentary. Output only the translated/enhanced prompt paragraph.",
|
||||
"styles": {
|
||||
"📖 Wan 2.2 NSFW T2V Scene (20s)": {
|
||||
"system_prompt": "Generate 1 single T2V prompt for WAN 2.2 video generation (5s total) to establish initial scene.\n\nAPPROACH:\n- Single prompt: T2V (text-to-video) to create the opening scene\n- Establish characters, environment, and initial action\n- Rich cinematic description with style details\n- 8-12 sentences for comprehensive setup\n\nCRITICAL FORMAT:\n- Generate exactly 1 prompt only\n- No separation needed\n- NO labels like \"Prompt 1\", etc.\n- Focus on establishing the complete scene\n- Maintain consistent style\n\nSTYLE PRIORITY:\nUser-specified > detected > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Temperature 0.7-0.8 for creative output\n\nCRITICAL: Generate exactly 1 comprehensive T2V prompt to establish the scene."
|
||||
"📖 LTX 2.3 NSFW T2V Scene": {
|
||||
"system_prompt": "You are an expert cinematic prompt director for LTX 2.3 text-to-video generation. Convert the user input into one fluent English cinematic prompt.\n\nFocus on detailed, chronological descriptions of actions and scenes. Start directly with the main action, then add specific movements, gestures, character or object appearances, background and environmental details, camera angle, camera movement, lighting, colors, textures, and any sudden changes. Keep the result literal, precise, and optimized for LTX 2.3 motion.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Do not change the user input intent; enhance it. Preserve the requested subject, style, action, mood, and constraints. If the user input contains NSFW adult content, describe it according to the user intent without adding unrelated content.\n\nWrite a single flowing paragraph under 150 words. Output the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (3)": {
|
||||
"🎬 MiniMax H3 Text-to-Video": {
|
||||
"system_prompt": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. TARGET_DURATION_SECONDS is authoritative internal context used only to keep actions feasible and cut timestamps strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the full storyline into a rapid montage. Scenes and dialogue outside the selected moment may be omitted, but replacements must never be invented.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut uses exactly [Shot N] At MM:SS.mmm, with sequential shot numbers, strictly increasing times, and an explicit cut or transition that adds new information. Use official natural camera phrasing; add small or large amplitude and slow or fast speed only when meaningful, and omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>, with all spoken words inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, music, or plot beats beyond explicit user facts and direct physical consequences. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the three-field prompt with one blank line between fields."
|
||||
},
|
||||
"📖 Wan 2.2 NSFW T2V Scene (20s)": {
|
||||
"system_prompt": "Convert user input into EXACTLY FOUR continuous 5-second WAN 2.2 text-to-video scene prompts (20 seconds total).\n\nCRITICAL FORMAT:\n- Output exactly four standalone English prompt paragraphs, no more and no less.\n- Each paragraph represents the next five-second clip.\n- Separate paragraphs with exactly one blank line.\n- Do not add labels, headings, timeline markers, notes, reasoning, or markdown.\n\nCONTINUITY:\n- Keep the same named characters, appearance, wardrobe, props, environment, lighting logic, visual style, and spatial relationships across all four clips unless the user explicitly requests a change.\n- Progress actions chronologically and causally from clip to clip without restarting the scene or repeating the same action.\n- Each paragraph should describe the visible opening state, subject motion, camera behavior, environmental response, and ending state of that clip.\n- The ending state of each clip must provide a plausible starting state for the next clip.\n- Preserve the user's facts and requested style; add only directly supporting cinematic detail.\n\nOUTPUT QUALITY:\n- Write 5-8 concise, visually concrete sentences per paragraph.\n- Use fluent, literal cinematic English suitable for WAN 2.2.\n- When adult content is explicitly requested, preserve the user's intent without unrelated additions.\n- Stop immediately after the fourth paragraph."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": {
|
||||
"system_prompt": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\")."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": {
|
||||
@@ -53,10 +228,10 @@
|
||||
"system_prompt": "Convert user input into 4 continuous 5-second WAN 2.2 prompts (20s total) using timeline structure.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Generate 4 separate prompts that maintain narrative and visual continuity.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Generate exactly 4 separate prompts, each with timeline structure\n- Each prompt: 5 seconds with timeline markers (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\")\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- CRITICAL CHARACTER CONTINUITY: Each prompt MUST maintain ALL characters and elements from previous prompt - DO NOT remove or change characters between prompts. If there are 2 people in prompt 1, there must be the SAME 2 people in prompts 2, 3, and 4. No character disappearance allowed. This is MANDATORY.\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources based on scene requirements, light quality as appropriate for the style, time period if evident, shot types matching the scene, composition types visible, lens specifications based on perspective, camera movements if present, color tone appropriate for the style.\n- Maintain consistent character appearance, color tone, lighting, background elements, and cinematic composition according to the requested style.\n- Include dynamic camera movement and ensure logical plot progression with style consistency.\n- WHEN there are NSFW content, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt"
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (3s)": {
|
||||
"system_prompt": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text."
|
||||
"system_prompt": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text."
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (5s)": {
|
||||
"system_prompt": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text."
|
||||
"system_prompt": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text."
|
||||
},
|
||||
"📝 Enhance": {
|
||||
"system_prompt": "You are a senior cinematic photography prompt writer. Respond in the same language as the user input.\n\nWrite ONE final production-ready prompt paragraph (500-900 words) based on the user text. Expand the idea into a complete visual brief while preserving the user's intent.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- Do NOT output reasoning, planning, labels, notes, examples, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- Do NOT use <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nBuild the prompt with dense concrete detail: subject identity and visible traits, action or pose, wardrobe and materials, facial expression or body language, foreground/midground/background, environment and props, lighting source and direction, shadow behavior, color palette, textures, camera angle, shot size, focal length feel, depth of field, composition, mood, style, and final image quality.\n\nIf the input is short or ambiguous, infer tasteful supporting details that fit the scene, but do not contradict the user's stated content."
|
||||
|
||||
@@ -1,162 +1,35 @@
|
||||
# ComfyUI-ThinkingLLM
|
||||
|
||||
**ThinkingLLM** is a local-LLM custom-node pack for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that wraps Qwen3.5, Qwen3, Qwen-VL, Gemma 4, and Whisper ASR behind a cleaner node interface with thinking-mode control, audio/vision workflows, live token streaming, and a raw trace output for debugging.
|
||||
**ThinkingLLM** is a multimodal local-LLM node pack for [ComfyUI](https://github.com/comfyanonymous/ComfyUI). It provides clean workflows for Qwen, Gemma 4, GGUF, Whisper ASR, prompt enhancement, image/video understanding, audio analysis, and secure OpenAI-compatible providers.
|
||||
|
||||
📺 **[Watch the demo](https://youtu.be/K1JsvnzujOw)** for a better viewing experience.
|
||||
[Watch the demo on YouTube](https://youtu.be/K1JsvnzujOw)
|
||||
|
||||
> GPL-3.0 fork of Deaquay/ComfyUI-Qwen3.5-Uncensored, itself derived from huchukato/ComfyUI-QwenVL-Mod and 1038lab/ComfyUI-QwenVL. See [LICENSE](LICENSE).
|
||||
|
||||
.gif)
|
||||

|
||||
|
||||
> 🎥 Prefer a narrated version? **[Watch the demo on YouTube](https://youtu.be/K1JsvnzujOw)**
|
||||
## Highlights
|
||||
|
||||
## What ThinkingLLM adds
|
||||
|
||||
### Model guidance box
|
||||
|
||||
ThinkingLLM nodes add a read-only `recommended_settings` info box.
|
||||
|
||||
- It explains the selected model family and recommended generation settings.
|
||||
- It does not change your saved widget values.
|
||||
- For GGUF audio-capable nodes, it tells you whether the selected model actually supports audio.
|
||||
- In Advanced GGUF nodes, if you connect or expose audio for a model without known audio support, the box warns you instead of silently implying that audio will work.
|
||||
|
||||
### The Thinking Toggle
|
||||
|
||||
Every ThinkingLLM node exposes an `enable_thinking` toggle.
|
||||
|
||||
- When it is on, the model may reason internally before answering. That can make a run take longer.
|
||||
- For easy prompts, the model can still decide that explicit reasoning is unnecessary and answer directly. That is not a bug. It is a feature.
|
||||
- When it is off, the node sends a `/no_think` style directive to discourage visible reasoning and push a more direct one-shot answer.
|
||||
|
||||
### Live token streaming
|
||||
|
||||
Enable `stream_tokens_to_terminal` to print tokens into the ComfyUI terminal as they arrive.
|
||||
|
||||
This is useful when:
|
||||
|
||||
- a prompt seems stuck and you want to see whether the model is actually working
|
||||
- you want to debug a bad prompt while it is going wrong
|
||||
- you want to understand whether the model is thinking or simply producing a long answer
|
||||
|
||||
### Raw trace output
|
||||
|
||||
Each node returns a second string output named `RAW_TRACE`.
|
||||
|
||||
- The main output is the cleaned answer for downstream workflow use.
|
||||
- `RAW_TRACE` preserves the raw generation stream, including visible thinking blocks when the model emits them.
|
||||
|
||||
That makes it possible to inspect what the model thought about the prompt while still keeping the primary output clean.
|
||||
|
||||
### Audio support
|
||||
|
||||
Audio is available through dedicated nodes so the model list stays honest:
|
||||
|
||||
- `ThinkingLLM Gemma 4 Audio (GGUF)` for Gemma 4 audio-capable models.
|
||||
- `ThinkingLLM Whisper ASR` for reliable speech-to-text transcription through `faster-whisper`.
|
||||
|
||||
The Advanced GGUF node also exposes optional `audio`, but this is for power users. Use the info box before assuming a model can hear audio. Normal Qwen/Qwen-VL models are not audio models.
|
||||
|
||||
## Nodes
|
||||
|
||||
### Transformers / HF nodes
|
||||
|
||||
This is the recommended default path.
|
||||
|
||||
- `ThinkingLLM`
|
||||
- `ThinkingLLM (Advanced)`
|
||||
- `ThinkingLLM Prompt Enhancer`
|
||||
|
||||
The HF path is the most straightforward option for Windows and Linux when you want the fewest install complications.
|
||||
|
||||
HF tokenizer note: some Gemma and Qwen variants need `sentencepiece` or `tiktoken` available in the ComfyUI Python environment. ThinkingLLM now declares those dependencies and will attempt to install them automatically if a tokenizer backend is missing at first load.
|
||||
|
||||
### GGUF / llama.cpp nodes
|
||||
|
||||
- `ThinkingLLM (GGUF)`
|
||||
- `ThinkingLLM (GGUF Advanced)`
|
||||
- `ThinkingLLM Gemma 4 Audio (GGUF)`
|
||||
- `ThinkingLLM Prompt Enhancer (GGUF)`
|
||||
|
||||
The GGUF path requires a multimodal-capable `llama-cpp-python` build. The normal PyPI package may not include the chat handlers needed for Qwen vision, Gemma 4, or MTMD audio. On Linux, ThinkingLLM auto-checks this at first GGUF use and attempts an automatic install of a matching JamePeng backend. Use the setup notes in [docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md](docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md) if you need to override the wheel/package source.
|
||||
|
||||
## Workflow tips
|
||||
|
||||
- **Pre-process input images** — use a resize or scale node before ThinkingLLM so large images don't blow up the context window.
|
||||
- **Display the output** — connect RESPONSE or ENHANCED_OUTPUT to a **Show Text** or **Show Anything** node to see the generated text in the UI.
|
||||
- **Inspect reasoning** — connect the RAW_TRACE output to a second Show Text node to see what the model was thinking.
|
||||
- **Use the dedicated audio nodes first** — Gemma 4 Audio is for audio understanding; Whisper ASR is for transcription. The Advanced GGUF audio input is intentionally more flexible, but the info box will warn you when the selected model has no curated audio support.
|
||||
|
||||
## Supported models
|
||||
|
||||
ThinkingLLM supports these model families out of the box, with pre-configured entries in `hf_models.json` and `gguf_models.json`.
|
||||
|
||||
### Qwen — Vision-language (HF)
|
||||
- Qwen3.5 4B / 9B
|
||||
- Qwen3-VL 4B / 8B (Instruct, abliterated, unredacted)
|
||||
- Qwen3-VL 32B Heretic
|
||||
- Qwen2.5-VL 3B / 7B
|
||||
|
||||
### Qwen — Vision-language (GGUF)
|
||||
- Qwen3.5 4B / 9B / 27B (Q4 K M through BF16)
|
||||
- Qwen3-VL 4B / 8B / 32B (Instruct, abliterated, thinking)
|
||||
- Qwen2.5-VL 3B / 7B
|
||||
|
||||
### Qwen — Text-only (HF)
|
||||
- Qwen3 0.6B / 4B / 8B
|
||||
- Qwen3.5 4B / 9B (base + heretic + uncensored)
|
||||
|
||||
### Qwen — Text-only (GGUF)
|
||||
- Qwen3 4B / 8B (abliterated, Josiefied, base)
|
||||
- Qwen3.5 4B / 9B / 27B (base + uncensored HauhauCS)
|
||||
|
||||
### Gemma 4 — Vision-language (HF)
|
||||
- Gemma-4-E2B-it / Uncensored
|
||||
- Gemma-4-E4B-it / Uncensored
|
||||
- Gemma-4-12B
|
||||
- Gemma-4-26B-A4B-it / Heretic
|
||||
- Gemma-4-31B-it
|
||||
|
||||
### Gemma 4 — Vision-language (GGUF)
|
||||
- Gemma-4-E2B / E4B (Q4 K M through BF16, uncensored)
|
||||
- Gemma-4-12B-it (Q4/Q5/Q6/Q8/BF16/UD-Q4)
|
||||
- Gemma-4-26B-A4B-it (Q4 K M through BF16)
|
||||
- Gemma-4-31B-it (Q4 K M through BF16)
|
||||
|
||||
### Gemma 4 — Audio (GGUF)
|
||||
- Gemma-4-E2B / E4B when present in the GGUF multimodal catalog
|
||||
- Gemma-4-12B-it via `unsloth/gemma-4-12b-it-GGUF`
|
||||
|
||||
Use `ThinkingLLM Gemma 4 Audio (GGUF)` for the clean audio-only interface. Gemma 4 audio currently depends on a recent multimodal `llama-cpp-python`/`Gemma4ChatHandler` build, so check `RAW_TRACE` if audio behaves unexpectedly.
|
||||
|
||||
### Whisper — ASR
|
||||
- tiny
|
||||
- base
|
||||
- small
|
||||
- medium
|
||||
- large-v3
|
||||
- distil-large-v3
|
||||
|
||||
Use `ThinkingLLM Whisper ASR` for transcription. It accepts a connected ComfyUI `AUDIO` input or an `audio_file_path` pointing to M4A, MP3, WAV, FLAC, or any FFmpeg-readable audio file. The node uses `faster-whisper`; first use may download the selected Whisper model into the Hugging Face cache. The default is `small` on CPU with `int8`, which is slower than CUDA but reliable on Windows; switch to CUDA once the local CTranslate2 CUDA runtime is confirmed working.
|
||||
|
||||
### Gemma 4 — Text-only (HF)
|
||||
- Gemma-4-E2B-it / Uncensored
|
||||
- Gemma-4-E4B-it / Uncensored
|
||||
- Gemma-4-12B
|
||||
- Gemma-4-26B-A4B-it / Heretic
|
||||
- Gemma-4-31B-it
|
||||
|
||||
Local GGUF models placed in `models/LLM/GGUF/` with a matching mmproj file are auto-discovered. Pre-configured entries live in `hf_models.json` and `gguf_models.json`.
|
||||
- Local Transformers/Hugging Face and GGUF inference
|
||||
- Image and video understanding with optional mask-focused analysis
|
||||
- Gemma 4 audio understanding and Whisper speech-to-text
|
||||
- Prompt enhancers for image and video generation workflows
|
||||
- Thinking-mode control, live terminal streaming, and a separate `RAW_TRACE` output
|
||||
- Curated model recommendations and preset-specific generation guidance
|
||||
- Secure OpenAI-compatible API node for approved remote providers
|
||||
- Existing compatible models are discovered instead of downloaded twice
|
||||
|
||||
## Quick install
|
||||
|
||||
### ComfyUI Manager (recommended)
|
||||
1. Open **ComfyUI Manager** → **Install via Registry**
|
||||
2. Search for `ThinkingLLM`
|
||||
3. Click **Install**
|
||||
|
||||
1. Open **ComfyUI Manager** → **Install via Registry**.
|
||||
2. Search for `ThinkingLLM`.
|
||||
3. Click **Install** and restart ComfyUI.
|
||||
|
||||
### Manual install
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/goodguy1963/ComfyUI-ThinkingLLM.git
|
||||
@@ -164,44 +37,78 @@ cd ComfyUI-ThinkingLLM
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
For GGUF vision support, follow [docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md](docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md).
|
||||
For GGUF vision or Gemma 4 audio, also follow the [llama.cpp backend installation guide](docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md).
|
||||
|
||||
## Model locations
|
||||
## Nodes at a glance
|
||||
|
||||
Models are discovered automatically.
|
||||
| Node | Backend | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ThinkingLLM` | Transformers / HF | Image and video understanding |
|
||||
| `ThinkingLLM (Advanced)` | Transformers / HF | Full controls and `MASK_PREVIEW` |
|
||||
| `ThinkingLLM Prompt Enhancer` | Transformers / HF | Text prompt enhancement |
|
||||
| `ThinkingLLM (GGUF)` | llama.cpp | GGUF image and video understanding |
|
||||
| `ThinkingLLM (GGUF Advanced)` | llama.cpp | GGUF advanced controls and `MASK_PREVIEW` |
|
||||
| `ThinkingLLM Gemma 4 Audio (GGUF)` | llama.cpp | Gemma 4 audio understanding |
|
||||
| `ThinkingLLM Whisper ASR` | faster-whisper | Speech-to-text transcription |
|
||||
| `ThinkingLLM Prompt Enhancer (GGUF)` | llama.cpp | GGUF prompt enhancement |
|
||||
| `ThinkingLLM System Prompt Preset` | Utility | Reusable system prompts as `STRING` |
|
||||
| `ThinkingLLM API (OpenAI Compatible)` | Remote API | Approved OpenAI-compatible providers |
|
||||
|
||||
- HF models: `ComfyUI/models/LLM/Qwen-VL/`
|
||||
- GGUF models: `ComfyUI/models/LLM/GGUF/`
|
||||
The Transformers/HF nodes are the recommended default when you want the simplest installation. Choose GGUF when you specifically need quantized llama.cpp models or Gemma 4 audio.
|
||||
|
||||
For local GGUF models, keep the matching mmproj file beside the model file.
|
||||
## Start a workflow
|
||||
|
||||
## Platform notes
|
||||
1. Add the ThinkingLLM node that matches your backend and task.
|
||||
2. Select a model or an already installed local model.
|
||||
3. Connect image, video, audio, mask, or text inputs as needed.
|
||||
4. Connect `RESPONSE` or `ENHANCED_OUTPUT` to a text display or downstream prompt input.
|
||||
5. Connect `RAW_TRACE` when you want generation diagnostics.
|
||||
|
||||
- HF is the recommended default path and is the simplest cross-platform option.
|
||||
- GGUF on Windows needs a matching `win_amd64` vision-capable wheel.
|
||||
- GGUF on Linux now auto-installs a matching backend on first use when possible. Override with `THINKINGLLM_LLAMA_CPP_LINUX_WHEEL_URL` or `THINKINGLLM_LLAMA_CPP_LINUX_SPEC` when your server needs a different build.
|
||||
- GGUF audio needs an audio-capable multimodal backend. `Gemma4ChatHandler` is used for Gemma 4.
|
||||
- Whisper ASR needs `faster-whisper` and FFmpeg. The node returns an install hint if `faster-whisper` is missing.
|
||||
- Flash Attention support is best on Linux. The nodes fall back when it is unavailable.
|
||||
- The thinking toggle works best with Qwen3.5 and Qwen3 style models. Other architectures may ignore the steering.
|
||||
Useful defaults:
|
||||
|
||||
## FAQ
|
||||
- Start with `enable_thinking=false` for transcription and short direct answers.
|
||||
- Enable `stream_tokens_to_terminal` when diagnosing a slow or unhelpful generation.
|
||||
- Resize very large images before inference to reduce context and memory pressure.
|
||||
- Use the dedicated Gemma 4 Audio or Whisper node instead of assuming every multimodal model supports audio.
|
||||
|
||||
**Why does the node sometimes answer quickly and sometimes take longer?**
|
||||
For explanations of thinking mode, model guidance, raw traces, audio, masks, and object-removal prompting, see [Features and workflows](docs/FEATURES_AND_WORKFLOWS.md).
|
||||
|
||||
When thinking is enabled, the model may decide to reason before answering. For easy prompts it may skip explicit reasoning. That is expected behavior.
|
||||
## Model families
|
||||
|
||||
**How do I see what the model was thinking?**
|
||||
ThinkingLLM includes curated entries for:
|
||||
|
||||
Use the second `RAW_TRACE` output, or enable live terminal token streaming.
|
||||
- Qwen3.8, Qwen3.5, Qwen3-VL, Qwen2.5-VL, and Qwen3
|
||||
- Gemma 4 vision, text, and audio-capable GGUF variants
|
||||
- Whisper `tiny` through `large-v3` and `distil-large-v3`
|
||||
- Local compatible Transformers checkpoints and GGUF/mmproj pairs
|
||||
|
||||
**How do I debug prompts that seem bad or stuck?**
|
||||
See [Models and backends](docs/MODELS_AND_BACKENDS.md) for exact families, model locations, local discovery, platform notes, and backend requirements.
|
||||
|
||||
Turn on `stream_tokens_to_terminal`. It is there specifically so you can see the generation live and catch bad prompt behavior sooner.
|
||||
## Remote API node
|
||||
|
||||
## Credits and fork lineage
|
||||
`ThinkingLLM API (OpenAI Compatible)` supports approved OpenAI-compatible providers while keeping credentials on the ComfyUI server. Provider keys are not stored in workflows.
|
||||
|
||||
This fork preserves the GPL-3.0 lineage of its predecessors.
|
||||
- [API setup, profiles, keys, and security](docs/OPENAI_COMPATIBLE_API.md)
|
||||
- [API image inputs](docs/OPENAI_COMPATIBLE_API_IMAGES.md)
|
||||
|
||||
## Documentation
|
||||
|
||||
| Topic | Guide |
|
||||
| --- | --- |
|
||||
| Thinking, streaming, traces, audio, masks, and workflow behavior | [Features and workflows](docs/FEATURES_AND_WORKFLOWS.md) |
|
||||
| Supported models, discovery, locations, and backend selection | [Models and backends](docs/MODELS_AND_BACKENDS.md) |
|
||||
| GGUF / llama.cpp installation on Windows and Linux | [llama.cpp backend installation](docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md) |
|
||||
| Duration-aware LTX and MiniMax H3 prompting | [Duration-aware video prompts](docs/VIDEO_PROMPT_DURATION.md) |
|
||||
| Video preset recommendations and rationale | [Video preset settings](docs/VIDEO_PRESET_SETTINGS.md) |
|
||||
| OpenAI-compatible API node | [API setup and security](docs/OPENAI_COMPATIBLE_API.md) |
|
||||
| OpenAI-compatible image inputs | [API image inputs](docs/OPENAI_COMPATIBLE_API_IMAGES.md) |
|
||||
| Maintainer and fork notes | [Maintainer notes](docs/MAINTAINER_NOTES.md) |
|
||||
|
||||
## Production deployment
|
||||
|
||||
This repository provides ComfyUI nodes, not a complete production runtime. For exposed or serverless workflows, review dependency locking, validation, secrets, cost controls, monitoring, and rollback before deployment. The [ComfyUI on RunPod production-readiness checklist](https://comfyrail.dev/production-readiness) is a useful starting point.
|
||||
|
||||
## Credits
|
||||
|
||||
- Deaquay/ComfyUI-Qwen3.5-Uncensored
|
||||
- huchukato/ComfyUI-QwenVL-Mod
|
||||
@@ -210,8 +117,7 @@ This fork preserves the GPL-3.0 lineage of its predecessors.
|
||||
- JamePeng/llama-cpp-python
|
||||
- comfyanonymous/ComfyUI
|
||||
|
||||
Maintainer: goodguy1963
|
||||
Planned repo: [goodguy1963/ComfyUI-ThinkingLLM](https://github.com/goodguy1963/ComfyUI-ThinkingLLM)
|
||||
Maintainer: [goodguy1963](https://github.com/goodguy1963)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Third-party source and commercial release notice
|
||||
|
||||
ComfyUI-ThinkingLLM is distributed under GPL-3.0. Its README and source headers
|
||||
credit this fork lineage:
|
||||
|
||||
- https://github.com/Deaquay/ComfyUI-Qwen3.5-Uncensored
|
||||
- https://github.com/huchukato/ComfyUI-QwenVL-Mod
|
||||
- https://github.com/1038lab/ComfyUI-QwenVL
|
||||
|
||||
The repository history begins with root commit
|
||||
`0ca0d2448c6445a1addf87a55bde2308244380a2` and does not record the exact
|
||||
upstream revisions used for the inherited files. The inherited source remains
|
||||
under GPL-3.0; this notice does not replace its copyright notices.
|
||||
|
||||
The Comfy Rail commercial release pins an exact commit from this repository,
|
||||
recorded in Comfy Rail's custom-node lock. Commercial release mode limits the
|
||||
runtime to the locally installed, release-locked Apache-2.0 models below and
|
||||
disables runtime downloads, runtime package installation, remote model code,
|
||||
GGUF, and Whisper registration:
|
||||
|
||||
- `Qwen/Qwen3-4B-Instruct-2507` at `cdbee75f17c01a7cc42f958dc650907174af0554`
|
||||
- `Qwen/Qwen3-VL-4B-Instruct` at `ebb281ec70b05090aa6165b016eac8ec08e71b17`
|
||||
|
||||
The exact GPL-3.0 source and its modification notices must be provided together
|
||||
with distributed commercial images.
|
||||
|
||||
Catalog status labels are operational safeguards, not legal advice or a model
|
||||
license grant. External/gated models remain subject to their Hugging Face access
|
||||
terms, and rights-unclear weights are neither shipped nor automatically fetched
|
||||
by commercial Comfy Rail releases. Only catalog entries marked `cleared`, pinned by full revision and
|
||||
component ID, are eligible for the locked commercial runtime.
|
||||
+60
-3
@@ -3,7 +3,7 @@
|
||||
"name": "ComfyUI-ThinkingLLM",
|
||||
"description": "A multimodal ComfyUI AI node with Qwen3.5, Qwen3-VL, Qwen2.5-VL, Qwen3, and Gemma 4 integrations. Features live thinking in the terminal to see what the LLM is doing in real time.",
|
||||
"author": "goodguy1963",
|
||||
"version": "2.3.7",
|
||||
"version": "2.5.8",
|
||||
"url": "https://github.com/goodguy1963/ComfyUI-ThinkingLLM",
|
||||
"category": "image"
|
||||
}
|
||||
@@ -19,7 +19,32 @@ sys.path.insert(0, current_dir)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
WEB_DIRECTORY = "./web"
|
||||
COMMERCIAL_RELEASE = os.environ.get("THINKINGLLM_COMMERCIAL_RELEASE") == "1"
|
||||
WEB_DIRECTORY = None if COMMERCIAL_RELEASE else "./web"
|
||||
COMMERCIAL_MODULES = {
|
||||
"AILab_OutputCleaner",
|
||||
"AILab_QwenVL",
|
||||
"AILab_QwenVL_PromptEnhancer",
|
||||
"AILab_StreamDisplay",
|
||||
}
|
||||
COMMERCIAL_NODE_MODULES = {
|
||||
"story_split_node",
|
||||
"system_prompt_preset",
|
||||
"vram_cleanup",
|
||||
}
|
||||
|
||||
LEGACY_NODE_REPLACEMENTS = {
|
||||
"AILab_QwenVL": "ThinkingLLM_QwenVL",
|
||||
"AILab_QwenVL_Advanced": "ThinkingLLM_QwenVL_Advanced",
|
||||
"AILab_QwenVL_GGUF": "ThinkingLLM_QwenVL_GGUF",
|
||||
"AILab_QwenVL_GGUF_Advanced": "ThinkingLLM_QwenVL_GGUF_Advanced",
|
||||
"AILab_QwenVL_PromptEnhancer": "ThinkingLLM_QwenVL_PromptEnhancer",
|
||||
"AILab_QwenVL_GGUF_PromptEnhancer": "ThinkingLLM_QwenVL_GGUF_PromptEnhancer",
|
||||
}
|
||||
if COMMERCIAL_RELEASE:
|
||||
LEGACY_NODE_REPLACEMENTS = {
|
||||
old: new for old, new in LEGACY_NODE_REPLACEMENTS.items() if "GGUF" not in old
|
||||
}
|
||||
|
||||
def load_modules_from_directory(directory):
|
||||
for file in os.listdir(directory):
|
||||
@@ -28,6 +53,14 @@ def load_modules_from_directory(directory):
|
||||
module_name = os.path.basename(file)[:-3]
|
||||
if module_name == os.path.basename(__file__)[:-3]:
|
||||
continue
|
||||
if COMMERCIAL_RELEASE:
|
||||
allowed = (
|
||||
COMMERCIAL_MODULES
|
||||
if os.path.abspath(directory) == os.path.abspath(current_dir)
|
||||
else COMMERCIAL_NODE_MODULES
|
||||
)
|
||||
if module_name not in allowed:
|
||||
continue
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
@@ -50,10 +83,34 @@ load_modules_from_directory(current_dir)
|
||||
nodes_dir = os.path.join(current_dir, "nodes")
|
||||
if os.path.exists(nodes_dir):
|
||||
load_modules_from_directory(nodes_dir)
|
||||
|
||||
for legacy_node_name in LEGACY_NODE_REPLACEMENTS:
|
||||
NODE_CLASS_MAPPINGS.pop(legacy_node_name, None)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.pop(legacy_node_name, None)
|
||||
|
||||
|
||||
def _register_legacy_node_replacements():
|
||||
try:
|
||||
from comfy_api.latest._io import NodeReplace
|
||||
from server import PromptServer
|
||||
except Exception:
|
||||
return
|
||||
|
||||
manager = getattr(getattr(PromptServer, "instance", None), "node_replace_manager", None)
|
||||
if manager is None:
|
||||
return
|
||||
|
||||
for old_node_id, new_node_id in LEGACY_NODE_REPLACEMENTS.items():
|
||||
manager.register(NodeReplace(new_node_id=new_node_id, old_node_id=old_node_id))
|
||||
|
||||
|
||||
_register_legacy_node_replacements()
|
||||
|
||||
NODE_CLASS_MAPPINGS = dict(sorted(NODE_CLASS_MAPPINGS.items(), key=lambda x: NODE_DISPLAY_NAME_MAPPINGS.get(x[0], x[0])))
|
||||
NODE_DISPLAY_NAME_MAPPINGS = dict(sorted(NODE_DISPLAY_NAME_MAPPINGS.items(), key=lambda x: x[1]))
|
||||
|
||||
__all__ = [
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS"
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
"LEGACY_NODE_REPLACEMENTS",
|
||||
]
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@
|
||||
"description": "A multimodal ComfyUI AI node with Qwen3.5, Qwen3-VL, Qwen2.5-VL, Qwen3, and Gemma 4 integrations. Features live thinking in the terminal to see what the LLM is doing in real time.",
|
||||
"category": "image",
|
||||
"tags": ["vision", "language", "multimodal", "thinkingllm", "qwen", "gguf", "flash_attention", "performance", "wan2.2", "video", "i2v", "t2v", "storyboard", "caching", "fork", "gpl-3.0"],
|
||||
"version": "2.3.7"
|
||||
"version": "2.5.8"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Features and workflows
|
||||
|
||||
This guide explains how ThinkingLLM's user-facing controls affect inference and how to choose the right workflow. For installation and the short node list, start with the [README](../README.md).
|
||||
|
||||
## Model guidance box
|
||||
|
||||
ThinkingLLM nodes add a read-only `recommended_settings` box. It explains the selected model family and suggests generation settings without changing saved widget values.
|
||||
|
||||
For LTX 2.3, MiniMax H3, and Wan 2.2 presets, the box also provides preset-specific `enable_thinking` and `max_tokens` guidance. Model sampling recommendations and video output budgets remain separate so a video preset does not silently overwrite model-family advice.
|
||||
|
||||
Audio-capable GGUF nodes use the same box to distinguish models with curated audio support. The Advanced GGUF node warns when audio is connected to a model that is not known to support it.
|
||||
|
||||
The complete video recommendation table and its rationale are in [Video preset settings](VIDEO_PRESET_SETTINGS.md).
|
||||
|
||||
## Thinking mode
|
||||
|
||||
Every inference node exposes `enable_thinking`.
|
||||
|
||||
- When enabled, a compatible model may reason before answering. This can improve difficult responses but uses more time and output budget.
|
||||
- A model may still answer directly when it considers the task simple.
|
||||
- When disabled, ThinkingLLM applies the appropriate direct-answer steering for the selected model family, such as a `/no_think` directive where supported.
|
||||
- For transcription, short classification, and concise prompt enhancement, begin with thinking disabled.
|
||||
|
||||
ThinkingLLM keeps incomplete or visible reasoning out of the cleaned primary response. When supported, reasoning remains available in `RAW_TRACE` for diagnostics.
|
||||
|
||||
## Live terminal streaming
|
||||
|
||||
Enable `stream_tokens_to_terminal` to display generation in the ComfyUI terminal as tokens arrive. This is useful when a run appears stuck, when you want to catch a poor prompt early, or when you need to distinguish model reasoning from slow loading.
|
||||
|
||||
Terminal streaming is diagnostic output. Downstream nodes should consume the normal response output instead.
|
||||
|
||||
## Response and raw trace
|
||||
|
||||
Inference nodes return a cleaned answer and a second string named `RAW_TRACE`.
|
||||
|
||||
- `RESPONSE` or `ENHANCED_OUTPUT` is intended for display and downstream workflow use.
|
||||
- `RAW_TRACE` preserves the raw generation stream and backend diagnostics, including visible reasoning when the model emits it.
|
||||
|
||||
Connect `RAW_TRACE` to a separate text display when diagnosing empty output, unexpected thinking, backend selection, model loading, or generation formatting.
|
||||
|
||||
## Audio workflows
|
||||
|
||||
### Gemma 4 audio understanding
|
||||
|
||||
Use `ThinkingLLM Gemma 4 Audio (GGUF)` for audio questions, descriptions, classification, and transcription with a compatible Gemma 4 model.
|
||||
|
||||
The node accepts a ComfyUI `AUDIO` input or an `audio_file_path`. Audio is normalized to 16 kHz mono WAV before inference. Native Gemma 4 audio requires:
|
||||
|
||||
- multimodal `llama-cpp-python` 0.3.36 or newer;
|
||||
- `Gemma4ChatHandler`;
|
||||
- an mmproj that positively reports audio support.
|
||||
|
||||
ThinkingLLM verifies these conditions before sending audio to inference. Older or unknown native paths remain blocked because they can terminate the ComfyUI process instead of returning a Python exception. BF16 mmproj is recommended for Gemma 4 E2B/E4B audio.
|
||||
|
||||
### Whisper speech-to-text
|
||||
|
||||
Use `ThinkingLLM Whisper ASR` when the primary goal is dependable transcription. It accepts ComfyUI audio and common FFmpeg-readable paths such as M4A, MP3, WAV, and FLAC.
|
||||
|
||||
The node uses `faster-whisper`. The first run may download the selected model. The `small` model on CPU/int8 is a practical Windows starting point; use CUDA after confirming that the local CTranslate2 CUDA runtime works.
|
||||
|
||||
### Advanced GGUF audio input
|
||||
|
||||
The Advanced GGUF node exposes optional audio for expert workflows, but most Qwen and Qwen-VL models are not audio models. Prefer the dedicated nodes unless the selected model and mmproj explicitly support audio.
|
||||
|
||||
## Mask-focused image analysis
|
||||
|
||||
The standard and Advanced HF/GGUF vision nodes accept an optional ComfyUI `MASK` input.
|
||||
|
||||
- White pixels select the area of interest.
|
||||
- Black pixels preserve surrounding context.
|
||||
- Soft values create a gradual transition.
|
||||
- Mask dimensions are resized to the image automatically.
|
||||
|
||||
In `focus` mode, ThinkingLLM keeps the selected area at normal brightness and dims the surroundings. This directs the vision model without removing spatial, lighting, or scene context.
|
||||
|
||||
In `reconstruct` mode, the selected area is concealed and the model is asked to infer a natural continuation from visible surroundings. This is useful for producing a positive inpainting prompt.
|
||||
|
||||
### Object-removal prompt workflow
|
||||
|
||||
1. Connect the original image and mask to a ThinkingLLM vision node.
|
||||
2. Select `Remove (Mask Area)` from `preset_prompt`.
|
||||
3. Optionally describe the desired replacement in `custom_prompt`.
|
||||
4. Connect `RESPONSE` to the positive prompt of a diffusion inpainting workflow.
|
||||
5. Send the original image and mask separately to the diffusion model's inpainting inputs.
|
||||
|
||||
ThinkingLLM does not edit the image. It describes what should naturally appear inside the masked region; the downstream diffusion model performs the replacement.
|
||||
|
||||
Advanced nodes return `MASK_PREVIEW`. Connect it to **Preview Image** to inspect exactly what the LLM receives.
|
||||
|
||||
Mask limitations:
|
||||
|
||||
- Masks apply to still images, not video.
|
||||
- An empty mask or a mask without an image returns a clear error.
|
||||
- Image batches use the first image and first mask.
|
||||
- Precise masks with a modest amount of surrounding context generally produce the best reconstruction prompt.
|
||||
|
||||
## Video prompt workflows
|
||||
|
||||
ThinkingLLM provides preset-specific guidance for LTX 2.3, MiniMax H3, and Wan 2.2 workflows. The optional `duration_seconds` input appears only for registered duration-aware presets.
|
||||
|
||||
Use the preset that matches the downstream workflow and connected media. MiniMax H3 offers separate T2VA, I2VA, FL2VA, L2VA, and Reference-to-Video contracts; text-only Prompt Enhancer nodes expose T2VA.
|
||||
|
||||
Detailed duration resolution, MiniMax frame-grid normalization, output structures, long-script handling, best-effort cleanup, and recommended budgets are documented in [Duration-aware video prompts](VIDEO_PROMPT_DURATION.md).
|
||||
|
||||
## Shared model files and caching
|
||||
|
||||
ThinkingLLM reuses compatible model files discovered in configured ComfyUI locations. Fixed-seed local generations may reuse saved prompt output when all relevant inputs match. Audio-node results skip prompt persistence because the external audio content must not be mistaken for a reusable text-only result.
|
||||
|
||||
For supported file layouts and model labels, see [Models and backends](MODELS_AND_BACKENDS.md).
|
||||
@@ -4,6 +4,7 @@ This plugin’s **QwenVL (GGUF)** vision nodes require a `llama-cpp-python` buil
|
||||
|
||||
- `Qwen3VLChatHandler`
|
||||
- `Qwen25VLChatHandler`
|
||||
- `Gemma4ChatHandler` (`llama-cpp-python` 0.3.36 or newer for Gemma 4 audio)
|
||||
|
||||
The upstream `llama-cpp-python` from PyPI often does **not** include these vision handlers. Use a fork/build that provides them (e.g. JamePeng’s fork) and install a **Release wheel**.
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Models and backends
|
||||
|
||||
This guide describes ThinkingLLM's inference paths, curated model families, local discovery, and platform requirements.
|
||||
|
||||
## Choose a backend
|
||||
|
||||
### Transformers / Hugging Face
|
||||
|
||||
The HF path is the recommended default for Windows and Linux when you want the fewest installation complications. It powers:
|
||||
|
||||
- `ThinkingLLM`
|
||||
- `ThinkingLLM (Advanced)`
|
||||
- `ThinkingLLM Prompt Enhancer`
|
||||
|
||||
Some Gemma and Qwen tokenizers require `sentencepiece` or `tiktoken`. ThinkingLLM declares both dependencies and attempts to install a missing tokenizer backend at first load.
|
||||
|
||||
### GGUF / llama.cpp
|
||||
|
||||
The GGUF path powers:
|
||||
|
||||
- `ThinkingLLM (GGUF)`
|
||||
- `ThinkingLLM (GGUF Advanced)`
|
||||
- `ThinkingLLM Gemma 4 Audio (GGUF)`
|
||||
- `ThinkingLLM Prompt Enhancer (GGUF)`
|
||||
|
||||
Multimodal GGUF inference requires a llama.cpp Python build with the chat handlers used by the selected architecture. The regular PyPI package may not include the required Qwen vision, Gemma 4, or MTMD audio support. Follow [Install llama-cpp-python](LLAMA_CPP_PYTHON_VISION_INSTALL.md) for platform-specific setup and verification.
|
||||
|
||||
### Whisper ASR
|
||||
|
||||
`ThinkingLLM Whisper ASR` uses `faster-whisper` for speech-to-text. It is independent of the Gemma 4 GGUF audio path and requires FFmpeg for general audio-file decoding.
|
||||
|
||||
### OpenAI-compatible API
|
||||
|
||||
The API node calls approved remote providers with credentials bound on the ComfyUI server. See [ThinkingLLM API](OPENAI_COMPATIBLE_API.md) for setup and security boundaries.
|
||||
|
||||
## Curated model families
|
||||
|
||||
ThinkingLLM ships model metadata in `hf_models.json` and `gguf_models.json`.
|
||||
|
||||
### Qwen vision-language
|
||||
|
||||
- **HF:** Qwen3.5 4B/9B; Qwen3-VL 4B/8B/32B; Qwen2.5-VL 3B/7B
|
||||
- **GGUF:** Qwen3.8 27B; Qwen3.5 4B/9B/27B; Qwen3-VL 4B/8B/32B; Qwen2.5-VL 3B/7B
|
||||
|
||||
Catalog variants include selected Instruct, thinking, abliterated, heretic, unredacted, and uncensored releases.
|
||||
|
||||
### Qwen text-only
|
||||
|
||||
- **HF:** Qwen3 0.6B/4B/8B; Qwen3.5 4B/9B
|
||||
- **GGUF:** Qwen3.8 27B; Qwen3 4B/8B; Qwen3.5 4B/9B/27B
|
||||
|
||||
### Gemma 4 vision-language
|
||||
|
||||
- **HF:** Gemma 4 E2B/E4B, 12B, 26B-A4B, and 31B families
|
||||
- **GGUF:** Gemma 4 E2B/E4B, 12B, 26B-A4B, and 31B families across selected quantizations
|
||||
|
||||
### Gemma 4 text-only
|
||||
|
||||
Selected E2B, E4B, 12B, 26B-A4B, and 31B HF variants are available in text workflows.
|
||||
|
||||
### Gemma 4 audio
|
||||
|
||||
The audio selector includes curated audio-capable Gemma 4 E2B, E4B, and 12B GGUF entries. Audio also depends on the selected mmproj and a compatible `Gemma4ChatHandler`; a model name alone does not guarantee audio support.
|
||||
|
||||
### Whisper
|
||||
|
||||
Available sizes include `tiny`, `base`, `small`, `medium`, `large-v3`, and `distil-large-v3`.
|
||||
|
||||
The catalog files are the authoritative current list. Dropdown contents can evolve between releases as models and compatibility information change.
|
||||
|
||||
## Model locations
|
||||
|
||||
ThinkingLLM searches configured ComfyUI model paths and common defaults, including:
|
||||
|
||||
- HF snapshots: `ComfyUI/models/LLM/Qwen-VL/`
|
||||
- GGUF models: `ComfyUI/models/LLM/GGUF/`
|
||||
- compatible single-file checkpoints: `ComfyUI/models/text_encoders/`
|
||||
|
||||
Keep a GGUF model and its matching mmproj together. Local GGUF files with an mmproj are discovered even when they are not in the bundled catalog.
|
||||
|
||||
## Model labels
|
||||
|
||||
- `[installed]` means a catalog model was found in a configured `LLM`, `GGUF`, or `Qwen-VL` location.
|
||||
- `[local]` means an uncatalogued compatible local model was discovered.
|
||||
|
||||
Labels are display-only. They do not alter saved model identities, and older saved display labels remain compatible where supported.
|
||||
|
||||
## Shared ComfyUI text encoders
|
||||
|
||||
Compatible checkpoints in `ComfyUI/models/text_encoders` appear in applicable selectors and are loaded through ComfyUI rather than copied into a second Transformers snapshot.
|
||||
|
||||
Supported examples include:
|
||||
|
||||
- Qwen3-VL 4B and 8B `.safetensors`, including `qwen3vl_8b_fp8_scaled.safetensors`
|
||||
- Gemma 3 12B `.safetensors`, including `gemma-3-12b-it-heretic-v2_int8.safetensors`
|
||||
|
||||
The MiniMax H3/Qwen3-VL 32B conditioning checkpoint is excluded because it is not a complete text-generation model. ComfyUI-native generation supports one-beam sampling and prints terminal output after generation rather than streaming token-by-token.
|
||||
|
||||
## Community and commercial catalogs
|
||||
|
||||
Community dropdowns preserve normal model names and download behavior. Gated Hugging Face repositories still require acceptance of the model owner's terms and a read token.
|
||||
|
||||
Commercial-release dropdowns include only reviewed, locked models and group entries by rights status. This restriction is intentionally separate from Community behavior.
|
||||
|
||||
## Platform notes
|
||||
|
||||
- **Windows GGUF:** install a matching `win_amd64` multimodal wheel for the Python version bundled with ComfyUI.
|
||||
- **Linux GGUF:** ThinkingLLM checks the backend at first use and can install a verified matching JamePeng wheel. Environment overrides are documented in the installation guide.
|
||||
- **Gemma 4 audio:** requires multimodal `llama-cpp-python` 0.3.36 or newer and an mmproj that reports audio support.
|
||||
- **Whisper:** requires `faster-whisper`; file-path decoding also requires FFmpeg.
|
||||
- **Hugging Face attention:** set `attention_mode` to `sage` to request SageAttention instead of Flash Attention 2. `auto` tries SageAttention, then Flash Attention 2, then PyTorch SDPA. On ROCm, SageAttention 1.0.6 uses its Triton backend when available in the same Python environment as ComfyUI. Unsupported models or attention inputs use SDPA. The GGUF node's `flash_attn` setting belongs to llama.cpp and does not select SageAttention.
|
||||
- **Flash Attention:** support is strongest on Linux and falls back when unavailable.
|
||||
- **Thinking controls:** Qwen3/Qwen3.5-style models generally respond most consistently to explicit thinking steering; other architectures may interpret it differently.
|
||||
|
||||
## Troubleshooting direction
|
||||
|
||||
- Missing or incompatible GGUF handlers: use [the llama.cpp installation guide](LLAMA_CPP_PYTHON_VISION_INSTALL.md).
|
||||
- Unexpected response versus reasoning output: inspect `RAW_TRACE` and [Features and workflows](FEATURES_AND_WORKFLOWS.md).
|
||||
- Remote provider setup or security errors: use [the API guide](OPENAI_COMPATIBLE_API.md).
|
||||
- Duration or MiniMax structure questions: use [Duration-aware video prompts](VIDEO_PROMPT_DURATION.md).
|
||||
@@ -0,0 +1,681 @@
|
||||
# ThinkingLLM API (OpenAI Compatible)
|
||||
|
||||
`ThinkingLLM API (OpenAI Compatible)` lets a ComfyUI workflow call approved OpenAI-compatible chat-completions backends without exposing provider credentials to the workflow or to clients that submit ComfyUI API jobs.
|
||||
|
||||
The node returns:
|
||||
|
||||
- `RESPONSE` — the final assistant text, with literal `<think>...</think>` blocks removed.
|
||||
- `RAW_TRACE` — the reasoning channel plus final text when the provider exposes reasoning separately, or the raw literal think block when a compatible backend emits one.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Provider API keys are host/runtime secrets, not ThinkingLLM configuration.** A real API key should exist only in the environment or secret store of the machine/service/container that launches ComfyUI. Never put a real provider key in a workflow, ComfyUI `/prompt` request, profile JSON, repository file, Dockerfile, README, issue, PR comment, or Git commit.
|
||||
|
||||
## Recommended production architecture
|
||||
|
||||
```text
|
||||
External client / application
|
||||
|
|
||||
| ComfyUI API request
|
||||
| api_profile = "OpenRouter Production"
|
||||
| model_name = "approved/model-id"
|
||||
| prompt = "..."
|
||||
v
|
||||
ComfyUI
|
||||
|
|
||||
| loads trusted server-side profile
|
||||
| validates model, input size, output/thinking limits and timeout
|
||||
| reads provider credential from process environment
|
||||
v
|
||||
OpenRouter / OpenAI / QwenCloud / other provider
|
||||
```
|
||||
|
||||
The external client never sends or receives the provider key. The workflow only names a server-approved profile.
|
||||
|
||||
For a public or multi-user deployment, the recommended baseline is:
|
||||
|
||||
1. Keep provider keys in the host/service/container secret environment.
|
||||
2. Keep the real profile file outside the Git checkout.
|
||||
3. Set `THINKINGLLM_API_PROFILES_FILE` to that external file.
|
||||
4. Set `THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1`.
|
||||
5. Configure exact `allowed_models` values.
|
||||
6. Configure `max_tokens_limit`, `max_input_chars`, and `max_timeout_seconds`; for Qwen thinking profiles also configure `max_thinking_budget`.
|
||||
7. Keep `allowed_extra_body_fields` empty unless specific provider options are required.
|
||||
8. Protect ComfyUI itself with authentication/network controls and rate/spending limits.
|
||||
|
||||
---
|
||||
|
||||
## Security boundaries
|
||||
|
||||
Treat workflow/API input as untrusted. A workflow is intentionally unable to provide:
|
||||
|
||||
- API keys or bearer tokens
|
||||
- the name of the environment variable containing a key
|
||||
- arbitrary remote `base_url` values
|
||||
- custom HTTP headers
|
||||
- a provider credential directly
|
||||
|
||||
Instead, the workflow provides only `api_profile`, `model_name`, prompts, and generation controls. The profile is read from trusted server configuration and binds the endpoint and authentication policy together.
|
||||
|
||||
This design prevents a workflow from combining an arbitrary server environment variable with an attacker-controlled URL.
|
||||
|
||||
### The profile file is not a secret, but it is security-sensitive
|
||||
|
||||
`thinkingllm_api_profiles.json` must contain configuration only, never a literal API key. Nevertheless, its **integrity matters**: a profile chooses the destination endpoint and the environment-variable reference. If an untrusted user can modify this file, they could redirect an approved credential to a different endpoint.
|
||||
|
||||
Therefore:
|
||||
|
||||
- only the ComfyUI administrator/service account should be able to modify the production profile file;
|
||||
- use restrictive filesystem permissions where practical;
|
||||
- for production, store the file outside the Git checkout;
|
||||
- do not let workflow/API clients choose its path;
|
||||
- restart ComfyUI after changing profile configuration.
|
||||
|
||||
### Redirects are deliberately rejected
|
||||
|
||||
The API transport does **not follow HTTP redirects**. This is intentional. Redirecting an authenticated request is dangerous because an authorization header can otherwise be forwarded beyond the originally approved endpoint.
|
||||
|
||||
Use the provider's canonical final API base URL in the server-side profile. A 3xx response is treated as an error and should be fixed by correcting the profile URL.
|
||||
|
||||
### HTTPS and local HTTP
|
||||
|
||||
Authenticated profiles (`auth: "bearer_env"`) must use HTTPS.
|
||||
|
||||
Unauthenticated plain HTTP is accepted automatically only for loopback hosts such as:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8000/v1
|
||||
http://localhost:11434/v1
|
||||
```
|
||||
|
||||
For an intentionally cleartext LAN/internal endpoint with `auth: "none"`, the administrator must explicitly opt in:
|
||||
|
||||
```json
|
||||
"allow_insecure_http": true
|
||||
```
|
||||
|
||||
Do not enable this for Internet-facing endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Built-in profiles
|
||||
|
||||
Convenience profiles are included for:
|
||||
|
||||
| Profile | Base URL | Server environment variable |
|
||||
| --- | --- | --- |
|
||||
| OpenAI | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
|
||||
| QwenCloud (Singapore) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |
|
||||
| OpenRouter | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` |
|
||||
| Together AI | `https://api.together.ai/v1` | `TOGETHER_API_KEY` |
|
||||
| Fireworks AI | `https://api.fireworks.ai/inference/v1` | `FIREWORKS_API_KEY` |
|
||||
| DeepInfra | `https://api.deepinfra.com/v1/openai` | `DEEPINFRA_TOKEN` |
|
||||
| Groq | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` |
|
||||
| Featherless | `https://api.featherless.ai/v1` | `FEATHERLESS_API_KEY` |
|
||||
| OrcaRouter | `https://api.orcarouter.ai/v1` | `ORCAROUTER_API_KEY` |
|
||||
| Ollama (local) | `http://127.0.0.1:11434/v1` | none |
|
||||
| vLLM (local) | `http://127.0.0.1:8000/v1` | none |
|
||||
| llama.cpp (local) | `http://127.0.0.1:8080/v1` | none |
|
||||
|
||||
The built-ins are convenient for local single-user use. For a remotely accessible/multi-user ComfyUI server, prefer explicit custom profiles and disable the built-ins.
|
||||
|
||||
### QwenCloud Singapore note
|
||||
|
||||
The built-in Singapore profile uses the shared `dashscope-intl` endpoint because a built-in profile cannot know your Alibaba Model Studio workspace ID. Alibaba currently keeps that shared endpoint functional, but recommends the workspace-dedicated Singapore domain for production:
|
||||
|
||||
```text
|
||||
https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
|
||||
```
|
||||
|
||||
For production QwenCloud use, create a custom server-side profile with your workspace-specific URL instead of relying on the convenience built-in.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables and secret ownership
|
||||
|
||||
### Windows PowerShell — current process
|
||||
|
||||
```powershell
|
||||
$env:OPENROUTER_PRODUCTION_API_KEY="your-secret-key"
|
||||
$env:THINKINGLLM_DISABLE_BUILTIN_API_PROFILES="1"
|
||||
$env:THINKINGLLM_API_PROFILES_FILE="D:\ComfyUI-Secrets\thinkingllm_api_profiles.json"
|
||||
python main.py
|
||||
```
|
||||
|
||||
The variable exists only for that PowerShell process and child processes.
|
||||
|
||||
### Windows — persistent service/user setup
|
||||
|
||||
If ComfyUI is launched by a service, task scheduler, wrapper, or another account, configure the secret in the environment of the **actual process that launches ComfyUI**. Setting a variable in your interactive terminal does not modify an already-running service.
|
||||
|
||||
After changing persistent environment values, restart ComfyUI.
|
||||
|
||||
### Windows — user environment variable + `.bat` launcher (ComfyUI Easy-Install)
|
||||
|
||||
Recommended for a local single-user ComfyUI that is started from a `.bat` file (for example the `ComfyUI-Easy-Install` `run_nvidia_gpu.bat`). The key lives in the **Windows user environment**, not in any file in the install tree, so it is not exposed in a `.bat`, a profile JSON, a workflow, or a Git commit.
|
||||
|
||||
Set the variable once, from any PowerShell:
|
||||
|
||||
```powershell
|
||||
[System.Environment]::SetEnvironmentVariable("OPENROUTER_API_KEY", "sk-or-xxxx", "User")
|
||||
```
|
||||
|
||||
Use the variable name that matches your provider (see the built-in profiles table above). The `.bat` needs **no** changes: the embedded Python inherits the user environment automatically. A `.bat` comment can simply point to this documentation:
|
||||
|
||||
```bat
|
||||
REM API keys are provided via Windows user environment variables (see docs/OPENAI_COMPATIBLE_API.md).
|
||||
```
|
||||
|
||||
If you already have a key in the current process or want to add one for this session only, you can export it too:
|
||||
|
||||
```powershell
|
||||
$env:OPENROUTER_API_KEY="sk-or-xxxx"
|
||||
```
|
||||
|
||||
Verification:
|
||||
|
||||
```powershell
|
||||
[System.Environment]::GetEnvironmentVariable("OPENROUTER_API_KEY", "User")
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The user environment is read when a new process is launched. If ComfyUI is already running, restart it after setting the variable. Never put a real key directly into a `.bat` that is stored, backed up, or could end up in version control.
|
||||
|
||||
### Linux/macOS shell
|
||||
|
||||
```bash
|
||||
export OPENROUTER_PRODUCTION_API_KEY='your-secret-key'
|
||||
export THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1
|
||||
export THINKINGLLM_API_PROFILES_FILE=/etc/thinkingllm/api_profiles.json
|
||||
python main.py
|
||||
```
|
||||
|
||||
### systemd/service deployments
|
||||
|
||||
Prefer a service-specific environment file or secret mechanism with restrictive permissions. Keep it outside the Git checkout. The unit should expose the key to the ComfyUI process without copying the key into the ThinkingLLM source tree.
|
||||
|
||||
### Containers
|
||||
|
||||
Do **not** bake provider credentials into the image:
|
||||
|
||||
```dockerfile
|
||||
# Do not do this
|
||||
ENV OPENROUTER_PRODUCTION_API_KEY=real-secret
|
||||
```
|
||||
|
||||
Inject credentials at runtime through your container/orchestrator secret mechanism. Mount the profile file read-only and point `THINKINGLLM_API_PROFILES_FILE` at the mounted path.
|
||||
|
||||
---
|
||||
|
||||
## Server-side profile file
|
||||
|
||||
Start from `thinkingllm_api_profiles.example.json`, but keep your real production file outside the repository when practical.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"OpenRouter Production": {
|
||||
"provider": "OpenRouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"auth": "bearer_env",
|
||||
"api_key_env": "OPENROUTER_PRODUCTION_API_KEY",
|
||||
"allowed_models": [
|
||||
"provider/model-id"
|
||||
],
|
||||
"max_tokens_limit": 8192,
|
||||
"max_input_chars": 131072,
|
||||
"max_timeout_seconds": 300,
|
||||
"allowed_extra_body_fields": [],
|
||||
"send_seed": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The string `OPENROUTER_PRODUCTION_API_KEY` is only the **name** of an environment variable. The actual provider key must not appear in this JSON.
|
||||
|
||||
### Supported profile fields
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `provider` | Display/log label. |
|
||||
| `base_url` | Fixed administrator-approved API base URL. |
|
||||
| `auth` | `bearer_env` or `none`. |
|
||||
| `api_key_env` | Environment-variable name used only with `bearer_env`. |
|
||||
| `allowed_models` | Optional exact model allowlist. Recommended for production. |
|
||||
| `max_tokens_limit` | Maximum output-token request accepted from a workflow. |
|
||||
| `max_input_chars` | Maximum combined `system_prompt + prompt` character count. Default: 1,000,000. |
|
||||
| `max_timeout_seconds` | Maximum timeout a workflow may request. Default: 300. |
|
||||
| `max_thinking_budget` | Maximum Qwen `thinking_budget` accepted from a workflow. Default/max: 262,144; set lower for production. |
|
||||
| `allowed_extra_body_fields` | Exact top-level provider-specific request fields allowed from `extra_body_json`. Empty means disabled. |
|
||||
| `send_seed` | Whether to send the common `seed` parameter. Must be a JSON boolean. |
|
||||
| `thinking_mode` | Currently empty or `qwen`. |
|
||||
| `max_tokens_field` | `max_tokens` or `max_completion_tokens`. |
|
||||
| `allow_insecure_http` | Explicit opt-in for unauthenticated non-loopback HTTP. |
|
||||
|
||||
Unknown profile fields are rejected instead of silently ignored. This is intentional: a typo such as `max_token_limit` must not quietly disable a security limit.
|
||||
|
||||
A custom profile with the same name as a built-in is treated as an override. If that override is invalid, the built-in with that name is removed rather than silently used as a fallback. This is fail-closed behavior: a broken restrictive `OpenRouter` override must not accidentally reactivate the permissive convenience built-in.
|
||||
|
||||
Literal secret/header fields such as `api_key`, `token`, `Authorization`, `headers`, or `secret` are rejected.
|
||||
|
||||
---
|
||||
|
||||
## Production lock-down mode
|
||||
|
||||
For public-facing or multi-user ComfyUI:
|
||||
|
||||
```text
|
||||
THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1
|
||||
```
|
||||
|
||||
Then expose only explicit entries in your trusted profile file.
|
||||
|
||||
Recommended location examples:
|
||||
|
||||
```text
|
||||
Windows: D:\ComfyUI-Secrets\thinkingllm_api_profiles.json
|
||||
Linux: /etc/thinkingllm/api_profiles.json
|
||||
```
|
||||
|
||||
Point ThinkingLLM to the external file:
|
||||
|
||||
```text
|
||||
THINKINGLLM_API_PROFILES_FILE=/secure/path/thinkingllm_api_profiles.json
|
||||
```
|
||||
|
||||
This also keeps credentials/profile policy independent of `git pull`, branch changes, reinstalls, or replacing the custom-node directory.
|
||||
|
||||
---
|
||||
|
||||
## ComfyUI UI usage
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
ThinkingLLM/API
|
||||
ThinkingLLM API (OpenAI Compatible)
|
||||
```
|
||||
|
||||
Typical values:
|
||||
|
||||
```text
|
||||
api_profile: OpenRouter Production
|
||||
model_name: provider/approved-model-id
|
||||
system_prompt: You are a helpful assistant.
|
||||
prompt: ...
|
||||
max_tokens: 2048
|
||||
temperature: 0.6
|
||||
top_p: 0.95
|
||||
stream_tokens_to_terminal: false
|
||||
```
|
||||
|
||||
The node never asks for the real provider credential.
|
||||
|
||||
Terminal streaming defaults to **off** for the remote API node. When enabled, ThinkingLLM prints a display-only copy with C0/C1 control characters removed; the actual `RESPONSE`/`RAW_TRACE` strings remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Curated model lists (UX only)
|
||||
|
||||
The node ships convenience model lists in `web/api_model_catalogs.json`, one per provider. `model_name` is a dropdown filled from the list for the selected `api_profile`. For anything not in the list, use the separate **`custom_model_name`** field (optional) — when filled, it overrides the dropdown selection.
|
||||
|
||||
Provider coverage:
|
||||
|
||||
- **Public `/models` endpoints (auto-refreshed at release):** OpenRouter, OrcaRouter, DeepInfra, Featherless. These ship the *full* current model list (OpenRouter ~413, OrcaRouter ~190, DeepInfra ~185, Featherless capped at 500).
|
||||
- **Key-protected providers (hand-curated):** OpenAI, QwenCloud (Singapore), Together AI, Fireworks AI, Groq.
|
||||
|
||||
**The catalog is UI convenience only. It is never a security boundary:**
|
||||
|
||||
- It does **not** override a server-side profile's `allowed_models` allowlist.
|
||||
- It does **not** whitelist models for profiles without an allowlist — any ID can still be typed in `custom_model_name`.
|
||||
- It contains no credentials and no endpoints; it only suggests model IDs.
|
||||
|
||||
### Keeping the lists fresh at release time
|
||||
|
||||
The public-provider lists are refreshed automatically at every release:
|
||||
|
||||
- `tools/update_api_model_catalogs.py` fetches the public `/models` endpoints and rewrites `web/api_model_catalogs.json` (keeps hand-curated providers unchanged).
|
||||
- `tools/check_api_model_catalogs.py` validates schema, required provider entries, well-formed IDs, and duplicate detection.
|
||||
- `release.yml` runs **both** tools before creating the release: it refreshes the lists, validates, and commits any changes back to the tag. CI (`api-security-tests.yml`) validates on every PR and push to `main`.
|
||||
|
||||
To refresh locally:
|
||||
|
||||
```bash
|
||||
python tools/update_api_model_catalogs.py
|
||||
python tools/check_api_model_catalogs.py
|
||||
```
|
||||
|
||||
Open a PR with the refreshed catalog. The release workflow will not publish until the catalog validates.
|
||||
|
||||
---
|
||||
|
||||
## ComfyUI API usage
|
||||
|
||||
A client calling ComfyUI sends only profile/model/prompt/generation settings. Conceptually:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_profile": "OpenRouter Production",
|
||||
"model_name": "provider/approved-model-id",
|
||||
"system_prompt": "You are a helpful assistant.",
|
||||
"prompt": "Describe the scene",
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.6,
|
||||
"top_p": 0.95,
|
||||
"timeout_seconds": 120
|
||||
}
|
||||
```
|
||||
|
||||
It must not contain:
|
||||
|
||||
```text
|
||||
api_key
|
||||
bearer_token
|
||||
api_key_env
|
||||
base_url
|
||||
Authorization
|
||||
custom secret headers
|
||||
```
|
||||
|
||||
### Remote calls always execute
|
||||
|
||||
ComfyUI normally caches a custom node when its inputs have not changed. That is wrong for a remote API call because the remote service is external state and can return a different answer even for identical inputs.
|
||||
|
||||
This node therefore declares itself changed on every queue run via `IS_CHANGED`, ensuring a repeated workflow actually performs a new provider request instead of reusing a previous response.
|
||||
|
||||
---
|
||||
|
||||
## Cost and abuse controls
|
||||
|
||||
Hiding a key is only one layer. Anyone authorized to submit a workflow that can select a cloud profile can potentially consume provider quota.
|
||||
|
||||
### Model allowlist
|
||||
|
||||
```json
|
||||
"allowed_models": [
|
||||
"provider/approved-model-a",
|
||||
"provider/approved-model-b"
|
||||
]
|
||||
```
|
||||
|
||||
### Output-token ceiling
|
||||
|
||||
```json
|
||||
"max_tokens_limit": 8192
|
||||
```
|
||||
|
||||
### Input-size ceiling
|
||||
|
||||
```json
|
||||
"max_input_chars": 131072
|
||||
```
|
||||
|
||||
This limit is character based, not tokenizer based. It is a defensive bound against extremely large API payloads and uncontrolled input cost; provider/model context limits still apply separately.
|
||||
|
||||
### Timeout ceiling
|
||||
|
||||
```json
|
||||
"max_timeout_seconds": 300
|
||||
```
|
||||
|
||||
A workflow may request a shorter timeout but cannot exceed the server-defined ceiling.
|
||||
|
||||
### Qwen thinking-budget ceiling
|
||||
|
||||
For profiles using `"thinking_mode": "qwen"`, also cap the maximum thinking budget that a workflow may request:
|
||||
|
||||
```json
|
||||
"max_thinking_budget": 8192
|
||||
```
|
||||
|
||||
`thinking_budget` controls the maximum token budget available to Qwen's thinking process. The node enforces the profile ceiling before sending the provider request. A production deployment should choose a value appropriate to its model/cost policy rather than leaving the broad default maximum.
|
||||
|
||||
Also use provider-side budget caps/alerts and gateway/reverse-proxy rate limiting where available.
|
||||
|
||||
---
|
||||
|
||||
## Provider-specific request fields (`extra_body_json`)
|
||||
|
||||
The old boolean `allow_extra_body` switch is intentionally **not supported**. It was too broad: once enabled, a workflow could send arbitrary top-level provider parameters.
|
||||
|
||||
Instead, every extra top-level field must be individually approved:
|
||||
|
||||
```json
|
||||
"allowed_extra_body_fields": [
|
||||
"reasoning"
|
||||
]
|
||||
```
|
||||
|
||||
Then the workflow may send, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"reasoning": {
|
||||
"effort": "high"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A field not on the allowlist is rejected.
|
||||
|
||||
The following core fields can never be delegated through `extra_body_json`:
|
||||
|
||||
- `model`
|
||||
- `messages`
|
||||
- `stream`
|
||||
- `max_tokens`
|
||||
- `max_completion_tokens`
|
||||
- `temperature`
|
||||
- `top_p`
|
||||
- `seed`
|
||||
- `enable_thinking`
|
||||
- `thinking_budget`
|
||||
|
||||
`extra_body_json` also has a size limit and rejects non-finite JSON constants such as `NaN`.
|
||||
|
||||
---
|
||||
|
||||
## Thinking / reasoning traces
|
||||
|
||||
The node recognizes common OpenAI-compatible reasoning channels, including:
|
||||
|
||||
- `reasoning_content`
|
||||
- `reasoning`
|
||||
- `thinking`
|
||||
- OpenRouter-style `reasoning_details` text/summary entries
|
||||
|
||||
For OpenRouter streaming, `reasoning_details` may appear in `choices[].delta.reasoning_details`; the node extracts readable text/summary portions for `RAW_TRACE`.
|
||||
|
||||
If a provider returns a mid-stream error inside an HTTP-200 SSE stream, the node detects a top-level `error` / `finish_reason: "error"` event and fails the ComfyUI execution rather than returning silently truncated output.
|
||||
|
||||
### Qwen thinking
|
||||
|
||||
Profiles with:
|
||||
|
||||
```json
|
||||
"thinking_mode": "qwen"
|
||||
```
|
||||
|
||||
send `enable_thinking` and, when greater than zero, `thinking_budget`. `max_thinking_budget` is the server-side ceiling on the workflow-requested thinking budget.
|
||||
|
||||
Example production policy:
|
||||
|
||||
```json
|
||||
{
|
||||
"thinking_mode": "qwen",
|
||||
"max_thinking_budget": 8192
|
||||
}
|
||||
```
|
||||
|
||||
For other providers, expose only the specific provider reasoning field you need via `allowed_extra_body_fields`.
|
||||
|
||||
---
|
||||
|
||||
## Key rotation
|
||||
|
||||
Rotating a provider credential should require **zero workflow changes**:
|
||||
|
||||
1. Create a new provider key.
|
||||
2. Replace the value of the existing server environment variable.
|
||||
3. Restart/reload the ComfyUI process so it inherits the new value.
|
||||
4. Test the configured profile.
|
||||
5. Revoke the old provider key.
|
||||
|
||||
Workflows continue using the same alias:
|
||||
|
||||
```text
|
||||
api_profile = OpenRouter Production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Updating or reinstalling ThinkingLLM safely
|
||||
|
||||
Keep repository-managed source and host-managed secrets separate:
|
||||
|
||||
```text
|
||||
Git repository Host configuration
|
||||
------------------------------- ---------------------------------
|
||||
ThinkingLLM source code provider API key environment variable
|
||||
example profile production profile JSON
|
||||
public documentation service/container secret settings
|
||||
|
||||
git pull / reinstall remains unchanged
|
||||
```
|
||||
|
||||
Before updating:
|
||||
|
||||
```bash
|
||||
git status
|
||||
git check-ignore -v thinkingllm_api_profiles.json
|
||||
```
|
||||
|
||||
Do not use `git add -f` on secret/config files.
|
||||
|
||||
After updating, restart ComfyUI so it reloads current profiles and process environment. No provider key needs to be copied into the repository.
|
||||
|
||||
### Gitignore is a secondary safeguard, not a secret manager
|
||||
|
||||
The repository ignores `.env`, `.env.*`, and local `thinkingllm_api_profiles*.json` variants while explicitly keeping `thinkingllm_api_profiles.example.json` trackable.
|
||||
|
||||
Git ignore rules do not protect a file that was already committed. If a real provider key ever entered Git history, logs, an issue, or a PR comment, treat it as compromised and rotate/revoke it at the provider.
|
||||
|
||||
---
|
||||
|
||||
## Multiple environments/accounts
|
||||
|
||||
Use distinct aliases and environment-variable names:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"OpenRouter Development": {
|
||||
"provider": "OpenRouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"auth": "bearer_env",
|
||||
"api_key_env": "OPENROUTER_DEV_API_KEY",
|
||||
"allowed_models": ["provider/dev-model"],
|
||||
"max_tokens_limit": 4096,
|
||||
"max_input_chars": 65536,
|
||||
"max_timeout_seconds": 120
|
||||
},
|
||||
"OpenRouter Production": {
|
||||
"provider": "OpenRouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"auth": "bearer_env",
|
||||
"api_key_env": "OPENROUTER_PRODUCTION_API_KEY",
|
||||
"allowed_models": ["provider/prod-model"],
|
||||
"max_tokens_limit": 8192,
|
||||
"max_input_chars": 131072,
|
||||
"max_timeout_seconds": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The actual keys remain separate host secrets.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `Unknown or unavailable server-side API profile`
|
||||
|
||||
Check:
|
||||
|
||||
- `THINKINGLLM_API_PROFILES_FILE` points to the intended file;
|
||||
- the JSON parses correctly;
|
||||
- the alias matches exactly;
|
||||
- if built-ins are disabled, the profile exists in the custom file;
|
||||
- no unknown/deprecated profile field caused the entry to be rejected.
|
||||
|
||||
Invalid profiles are ignored and the reason is printed in the ComfyUI server terminal. If a custom profile uses the same name as a built-in and the custom override is invalid, that built-in name is removed rather than used as a fallback.
|
||||
|
||||
### `API credential is not configured for the selected server-side profile`
|
||||
|
||||
The ComfyUI process cannot see the required environment variable. Configure it in the environment of the **actual process/service/container** that starts ComfyUI, then restart ComfyUI.
|
||||
|
||||
### Redirect rejected
|
||||
|
||||
The configured provider URL returned HTTP 3xx. Set `base_url` to the provider's canonical final API URL. Redirect following is intentionally disabled for credential safety.
|
||||
|
||||
### LAN HTTP profile is rejected
|
||||
|
||||
Plain HTTP is loopback-only by default. Prefer HTTPS. If an administrator intentionally uses an unauthenticated trusted-LAN endpoint, set `allow_insecure_http: true` in that server-side profile.
|
||||
|
||||
### Model rejected
|
||||
|
||||
The exact `model_name` is absent from `allowed_models`. Model IDs are provider-specific.
|
||||
|
||||
### Input rejected
|
||||
|
||||
The combined system/user prompt exceeds `max_input_chars`.
|
||||
|
||||
### Output limit rejected
|
||||
|
||||
The workflow's `max_tokens` exceeds `max_tokens_limit`.
|
||||
|
||||
### Thinking budget rejected
|
||||
|
||||
For a Qwen thinking profile, the workflow's `thinking_budget` exceeds `max_thinking_budget`.
|
||||
|
||||
### Timeout rejected
|
||||
|
||||
The workflow's `timeout_seconds` exceeds `max_timeout_seconds`.
|
||||
|
||||
### `extra_body_json` rejected
|
||||
|
||||
Each top-level field must appear in `allowed_extra_body_fields`. The old `allow_extra_body` flag is deliberately rejected.
|
||||
|
||||
### Works in a terminal but not as a service
|
||||
|
||||
Your interactive shell and service likely have different environments. Configure secrets in the service/container/runtime environment rather than relying on the login shell.
|
||||
|
||||
---
|
||||
|
||||
## Production security checklist
|
||||
|
||||
Before exposing a ThinkingLLM API profile through ComfyUI:
|
||||
|
||||
- [ ] The real provider key is absent from the Git working tree and Git history.
|
||||
- [ ] The real provider key is absent from workflow/API JSON and profile JSON.
|
||||
- [ ] Production profile configuration is stored outside the repository where practical.
|
||||
- [ ] The production profile file is writable only by trusted administrators/service accounts.
|
||||
- [ ] `THINKINGLLM_API_PROFILES_FILE` points to the intended trusted file.
|
||||
- [ ] `THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1` is enabled for public/multi-user deployments.
|
||||
- [ ] Every cloud profile uses HTTPS.
|
||||
- [ ] Every cloud profile has an exact `allowed_models` list.
|
||||
- [ ] Every cloud profile has a reasonable `max_tokens_limit`.
|
||||
- [ ] Every cloud profile has a reasonable `max_input_chars`.
|
||||
- [ ] Every cloud profile has a reasonable `max_timeout_seconds`.
|
||||
- [ ] Every Qwen thinking profile has a reasonable `max_thinking_budget`.
|
||||
- [ ] `allowed_extra_body_fields` is empty unless specific fields are needed.
|
||||
- [ ] Redirects are not required by the configured provider URL.
|
||||
- [ ] Terminal token streaming is disabled unless intentionally needed for debugging.
|
||||
- [ ] ComfyUI itself is protected by authentication/network controls and rate limits.
|
||||
- [ ] Provider-side spending limits/alerts are configured where available.
|
||||
- [ ] Key rotation can be performed by changing only the host secret and restarting/reloading ComfyUI.
|
||||
|
||||
Following this model keeps provider credentials local to the ComfyUI runtime, constrains what an API client can spend/change, and keeps secret ownership independent from ThinkingLLM source-code updates.
|
||||
@@ -0,0 +1,252 @@
|
||||
# ThinkingLLM API image inputs
|
||||
|
||||
`ThinkingLLM API (OpenAI Compatible)` supports a single image input in addition to text. Video input is intentionally not part of this node.
|
||||
|
||||
The image path follows the common OpenAI-style chat-completions multimodal message format:
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this image"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Two mutually exclusive image inputs
|
||||
|
||||
The node exposes two optional inputs:
|
||||
|
||||
- `image` — a normal ComfyUI `IMAGE`. This is the **default/recommended path** for local images.
|
||||
- `image_url` — an alternative public HTTPS URL for an image already hosted elsewhere.
|
||||
|
||||
Use one or the other. If both are supplied, the node rejects the request before contacting the provider.
|
||||
|
||||
There is no `video` or `video_url` input.
|
||||
|
||||
## Default path: ComfyUI IMAGE -> PNG -> Base64 data URL
|
||||
|
||||
When `image` is connected, ThinkingLLM:
|
||||
|
||||
1. accepts exactly one ComfyUI image (`[1,H,W,C]` or `[H,W,C]`);
|
||||
2. validates dimensions and server-side pixel limits before conversion;
|
||||
3. converts the image to 8-bit PNG in memory;
|
||||
4. validates the PNG byte size;
|
||||
5. Base64-encodes the PNG;
|
||||
6. sends it as `data:image/png;base64,...` in an OpenAI-style `image_url` content item.
|
||||
|
||||
No temporary public file, upload service, or image-hosting credential is required.
|
||||
|
||||
The Base64 value exists only in the outbound provider request. It is not an API key and is not written to the server-side profile file.
|
||||
|
||||
### Why PNG
|
||||
|
||||
PNG avoids an additional lossy JPEG conversion and keeps the ComfyUI image content deterministic. The tradeoff is a larger request payload, which is why production profiles should set explicit image-size limits.
|
||||
|
||||
## Alternative path: public HTTPS `image_url`
|
||||
|
||||
When `image_url` is used, ThinkingLLM does **not** download the image itself. It validates the URL and passes it to the selected provider in the multimodal request.
|
||||
|
||||
Accepted URL form:
|
||||
|
||||
```text
|
||||
https://example.com/path/image.png
|
||||
```
|
||||
|
||||
Signed HTTPS URLs with query parameters are supported.
|
||||
|
||||
Rejected forms include:
|
||||
|
||||
```text
|
||||
http://example.com/image.png
|
||||
data:image/png;base64,...
|
||||
file:///path/to/image.png
|
||||
URLs containing embedded user:password credentials
|
||||
URLs containing control characters
|
||||
```
|
||||
|
||||
The URL alternative is intentionally restricted to public HTTPS URLs. Base64 data URLs must come from the local ComfyUI `IMAGE` path so the node can enforce local pixel/byte limits.
|
||||
|
||||
Because ThinkingLLM does not fetch `image_url`, the URL is not a server-side HTTP fetch performed by the ComfyUI host. The downstream provider decides whether it can retrieve and process the URL.
|
||||
|
||||
## Server-side profile policy
|
||||
|
||||
Custom production profiles are fail-closed for images. Image support is disabled unless the administrator explicitly enables it.
|
||||
|
||||
Example vision profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"OpenRouter Vision Production": {
|
||||
"provider": "OpenRouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"auth": "bearer_env",
|
||||
"api_key_env": "OPENROUTER_PRODUCTION_API_KEY",
|
||||
"allowed_models": [
|
||||
"provider/vision-model-id"
|
||||
],
|
||||
"max_tokens_limit": 8192,
|
||||
"max_input_chars": 131072,
|
||||
"max_timeout_seconds": 300,
|
||||
"allow_images": true,
|
||||
"allow_image_url": true,
|
||||
"max_image_pixels": 16777216,
|
||||
"max_image_bytes": 20000000,
|
||||
"allowed_extra_body_fields": [],
|
||||
"send_seed": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `allow_images`
|
||||
|
||||
```json
|
||||
"allow_images": true
|
||||
```
|
||||
|
||||
Enables image input for the profile. Default for custom profiles is `false`.
|
||||
|
||||
If false, both local `IMAGE` and `image_url` requests are rejected before any provider request is sent.
|
||||
|
||||
### `allow_image_url`
|
||||
|
||||
```json
|
||||
"allow_image_url": true
|
||||
```
|
||||
|
||||
Allows the public HTTPS `image_url` alternative.
|
||||
|
||||
This option requires `allow_images: true`. A profile cannot enable URL images while general image input is disabled.
|
||||
|
||||
For production deployments that only need locally supplied ComfyUI images, use:
|
||||
|
||||
```json
|
||||
"allow_images": true,
|
||||
"allow_image_url": false
|
||||
```
|
||||
|
||||
That is the tighter policy.
|
||||
|
||||
### `max_image_pixels`
|
||||
|
||||
```json
|
||||
"max_image_pixels": 16777216
|
||||
```
|
||||
|
||||
Maximum width x height accepted for a local ComfyUI `IMAGE` before PNG encoding.
|
||||
|
||||
The default is 16,777,216 pixels (4096 x 4096). Administrators can lower this substantially for API workloads.
|
||||
|
||||
This protects server RAM/CPU and limits accidentally huge multimodal requests.
|
||||
|
||||
### `max_image_bytes`
|
||||
|
||||
```json
|
||||
"max_image_bytes": 20000000
|
||||
```
|
||||
|
||||
Maximum PNG byte size before Base64 encoding. Default is 20,000,000 bytes.
|
||||
|
||||
Base64 adds approximately one third of transport overhead, so choose this limit with provider request-size limits and network costs in mind.
|
||||
|
||||
This limit applies to the locally encoded ComfyUI image. ThinkingLLM cannot know the remote byte size of an `image_url` without downloading it, which the node deliberately does not do.
|
||||
|
||||
## Built-in profiles
|
||||
|
||||
The convenience built-in profiles allow the standard image transport so a local/single-user installation can try vision-capable models without creating a custom profile.
|
||||
|
||||
That does **not** mean every model exposed by a provider supports images. Image capability is model-specific.
|
||||
|
||||
For public/multi-user deployments, disable convenience built-ins:
|
||||
|
||||
```text
|
||||
THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1
|
||||
```
|
||||
|
||||
Then create explicit image-enabled production profiles with an exact `allowed_models` list.
|
||||
|
||||
## What happens with a text-only model
|
||||
|
||||
A provider can expose both text-only and vision-capable models through the same OpenAI-compatible API.
|
||||
|
||||
ThinkingLLM therefore cannot infer image capability reliably from the provider name alone. It sends the standard multimodal request when the profile permits images.
|
||||
|
||||
If the provider/model rejects a request containing an image with a typical image/content validation response, the node raises an actionable ComfyUI error similar to:
|
||||
|
||||
```text
|
||||
Image input was rejected by API profile 'OpenRouter Production' / model '...'.
|
||||
Use a vision-capable model supported by that provider, or remove the IMAGE/image_url input.
|
||||
```
|
||||
|
||||
The provider's detailed response remains available in the ComfyUI server log, subject to existing credential redaction.
|
||||
|
||||
## ComfyUI UI usage
|
||||
|
||||
Typical local-image workflow:
|
||||
|
||||
```text
|
||||
Load Image
|
||||
|
|
||||
v
|
||||
ThinkingLLM API (OpenAI Compatible)
|
||||
image = connected IMAGE
|
||||
prompt = Describe the scene in detail
|
||||
api_profile = OpenRouter Vision Production
|
||||
model_name = provider/vision-model-id
|
||||
|
|
||||
+--> RESPONSE
|
||||
+--> RAW_TRACE
|
||||
```
|
||||
|
||||
Typical URL workflow:
|
||||
|
||||
```text
|
||||
ThinkingLLM API (OpenAI Compatible)
|
||||
image = not connected
|
||||
image_url = https://example.com/image.jpg
|
||||
prompt = Describe the scene in detail
|
||||
```
|
||||
|
||||
## ComfyUI API-format workflow
|
||||
|
||||
For a local ComfyUI image, the normal ComfyUI graph connection supplies the `IMAGE` tensor. The external application does not need to manually Base64-encode the image for the provider; ThinkingLLM performs that conversion after the upstream ComfyUI image-loading/input node has produced an `IMAGE`.
|
||||
|
||||
For the URL alternative, the API-format workflow may contain the public URL string:
|
||||
|
||||
```json
|
||||
{
|
||||
"image_url": "https://example.com/image.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
Provider credentials remain server-side exactly as for text-only requests.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- One image per API-node execution.
|
||||
- Image batches are rejected rather than silently using only the first frame.
|
||||
- Video is intentionally unsupported by this node.
|
||||
- Local image transport is PNG Base64 only.
|
||||
- `image_url` is HTTPS only.
|
||||
- Actual model vision support is determined by the provider/model, not by the provider profile name.
|
||||
- Remote URL image size cannot be preflighted by ThinkingLLM because the ComfyUI host does not fetch the URL.
|
||||
|
||||
## Production checklist for image-enabled profiles
|
||||
|
||||
- [ ] Use an exact vision-capable model ID in `allowed_models`.
|
||||
- [ ] Set `allow_images: true` only on profiles that need image input.
|
||||
- [ ] Keep `allow_image_url: false` unless public URL input is actually needed.
|
||||
- [ ] Set a realistic `max_image_pixels` for the application.
|
||||
- [ ] Set a realistic `max_image_bytes` for provider/network limits.
|
||||
- [ ] Keep the provider API key only in the ComfyUI host/runtime secret environment.
|
||||
- [ ] Keep production profile configuration outside the Git checkout where practical.
|
||||
- [ ] Test an intentionally text-only model once to verify the user-facing image rejection is understandable.
|
||||
- [ ] Keep provider-side spend limits/alerts enabled where available.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 509 KiB After Width: | Height: | Size: 948 KiB |
@@ -0,0 +1,51 @@
|
||||
# Video preset settings
|
||||
|
||||
ThinkingLLM shows two independent recommendation blocks in the read-only `recommended_settings` info box:
|
||||
|
||||
1. The selected model family's sampler guidance.
|
||||
2. The selected video preset's output budget and thinking-mode guidance.
|
||||
|
||||
The info box never changes saved widget values. Preset guidance is centrally defined in `AILab_System_Prompts.json` and mirrored to the web payload used by ComfyUI.
|
||||
|
||||
## Preset recommendations
|
||||
|
||||
| Preset family | `enable_thinking` | `max_tokens` |
|
||||
| --- | ---: | ---: |
|
||||
| LTX 2.3 T2V, I2V, First/Last, Reference | `false` | 512 |
|
||||
| MiniMax H3 T2VA, I2VA, FL2VA, L2VA | `false` | 2048 |
|
||||
| MiniMax H3 Full Reference | `false` | 3072 |
|
||||
| Wan 2.2 Scene 3s/5s, T2V and I2V | `false` | 512 |
|
||||
| Wan 2.2 Timeline 3s/5s, T2V and I2V | `false` | 768 |
|
||||
| Wan 2.2 Scene 20s, T2V and I2V | `false` | 1536 |
|
||||
| Wan 2.2 Timeline 20s, T2V and I2V | `false` | 2048 |
|
||||
|
||||
Thinking is disabled in these recommendations because the selected system prompts already define the planning and output format. Direct generation is faster and less likely to spend the output budget on a reasoning trace or deviate from the requested final structure.
|
||||
|
||||
The token budgets are upper bounds for the expected final format rather than settings from the downstream video generator. Increase the budget only when a useful final prompt is genuinely truncated.
|
||||
|
||||
## Sampling settings
|
||||
|
||||
Temperature, `top_p`, `top_k`, and repetition penalties remain model- and input-mode recommendations:
|
||||
|
||||
- Qwen3/Qwen3.5 vision input generally uses `temperature=0.7`, `top_p=0.8`, `top_k=20`, and `repetition_penalty=1.0`.
|
||||
- Text-only input follows the selected model card. Qwen3.5 uses `temperature=1.0`, `top_p=1.0`, `top_k=20`, and `repetition_penalty=1.0`; Qwen3-VL Instruct uses `temperature=1.0`, `top_p=1.0`, `top_k=40`, and `repetition_penalty=1.0`.
|
||||
- Widgets not exposed by the selected node are omitted from the info box summary.
|
||||
|
||||
For a connected reference video, begin around two sampled frames per source second and stay within the node's 64-frame limit. Image-only presets do not need a `frame_count` recommendation.
|
||||
|
||||
## Format rationale
|
||||
|
||||
- LTX requests one literal chronological paragraph under 200 words, so 512 output tokens provide sufficient headroom.
|
||||
- MiniMax Base uses three structured fields; Full Reference uses six sections and needs a larger budget.
|
||||
- Wan single-scene prompts fit within 512 tokens. Timeline formats and four-part 20-second sequences need progressively larger budgets to avoid truncation.
|
||||
|
||||
Runtime-setting instructions such as `max_tokens`, context length, batch size, and temperature are kept out of the system prompts themselves. They belong in the info box and must not become part of the generated video prompt.
|
||||
|
||||
## Official references
|
||||
|
||||
- [Qwen3-VL 4B Instruct generation hyperparameters](https://huggingface.co/Qwen/Qwen3-VL-4B-Instruct)
|
||||
- [Qwen3.5 sampling recommendations](https://huggingface.co/Qwen/Qwen3.5-2B)
|
||||
- [LTX-2 prompting guide](https://github.com/Lightricks/LTX-2/blob/main/README.md#%EF%B8%8F-prompting-for-ltx-2)
|
||||
- [MiniMax H3 Base prompt guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_base_en.md)
|
||||
- [MiniMax H3 Full-Reference prompt guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_ref_en.md)
|
||||
- [Wan 2.2 prompt extension](https://github.com/Wan-Video/Wan2.2#2-using-prompt-extention)
|
||||
@@ -0,0 +1,118 @@
|
||||
# Duration-aware video prompts
|
||||
|
||||
ThinkingLLM can make LTX 2.3 and MiniMax H3 prompts match the duration configured in the downstream video generator. The duration affects prompt planning and best-effort formatting only; ThinkingLLM does not change the generator's own frame or length setting.
|
||||
|
||||
## Supported nodes
|
||||
|
||||
The optional `duration_seconds` input is available on:
|
||||
|
||||
- `ThinkingLLM`
|
||||
- `ThinkingLLM (Advanced)`
|
||||
- `ThinkingLLM (GGUF)`
|
||||
- `ThinkingLLM (GGUF Advanced)`
|
||||
- `ThinkingLLM Prompt Enhancer`
|
||||
- `ThinkingLLM Prompt Enhancer (GGUF)`
|
||||
|
||||
The widget is shown only for registered LTX 2.3 and MiniMax H3 video presets. It remains hidden and has no effect for `Custom Only`, image, analysis, tagging, and other non-video presets. Hiding the widget does not change its type or serialization position, so switching presets cannot move the read-only recommendation text into `duration_seconds`. The last valid duration is preserved. Older workflows without the input use `5.0` seconds for a duration-aware preset, and workflows containing a corrupted non-numeric duration are repaired to the last valid value or `5.0` seconds by the frontend.
|
||||
|
||||
Connect the same requested duration to ThinkingLLM and the downstream video generator. No separate enable switch is required.
|
||||
|
||||
## Duration resolution
|
||||
|
||||
LTX uses the requested value directly. Its final prompt contains the duration once as natural language, for example `an 8-second continuous shot`, and remains one chronological paragraph.
|
||||
|
||||
MiniMax uses the same 24 fps `17k+5` frame grid as the ComfyUI H3 node:
|
||||
|
||||
1. Convert the requested seconds to frames with `round(seconds × 24)`.
|
||||
2. Use at least five frames.
|
||||
3. Raise the result to the next valid `17k+5` frame count.
|
||||
4. Divide the valid frame count by 24 to obtain the effective prompt duration.
|
||||
|
||||
Examples:
|
||||
|
||||
| Requested | Effective frames | Effective duration |
|
||||
| ---: | ---: | ---: |
|
||||
| 5.0 s | 124 | 5.17 s |
|
||||
| 8.0 s | 192 | 8.00 s |
|
||||
| 12.0 s | 294 | 12.25 s |
|
||||
|
||||
Two requested MiniMax values that resolve to the same effective duration share the same duration-aware cache identity. A change to a different effective duration invalidates the cached and saved prompt state.
|
||||
|
||||
## MiniMax H3 output contracts
|
||||
|
||||
### Base formats
|
||||
|
||||
T2VA, I2VA, FL2VA, and L2VA use the official base fields in this order:
|
||||
|
||||
```text
|
||||
integrated_multimodal_description: [Shot 1] ...
|
||||
|
||||
overall_soundscape: ...
|
||||
|
||||
non_diegetic_music: ...
|
||||
```
|
||||
|
||||
The preset asks for `[Shot 1]` without a timestamp and later cuts as `[Shot N] At MM:SS.mmm`, with sequential shot numbers and timestamps below the effective duration. I2VA, FL2VA, and L2VA additionally request their official picture-alignment preamble. These are generation instructions rather than post-generation rejection rules.
|
||||
|
||||
The effective duration is internal planning context. T2VA and I2VA do not receive an invented `Target duration:` field. FL2VA and L2VA print the duration only where the official picture-alignment sentence requires it.
|
||||
|
||||
### Full-reference format
|
||||
|
||||
Reference-to-Video uses exactly these six sections in order:
|
||||
|
||||
1. `subject_definitions`
|
||||
2. `summary`
|
||||
3. `retention_analysis`
|
||||
4. `detailed_description`
|
||||
5. `overall_soundscape`
|
||||
6. `non_diegetic_music`
|
||||
|
||||
Use Reference-to-Video only when the request assigns connected assets a reusable role such as identity, wardrobe, prop, environment, style, motion, camera, or voice. To animate one image as the exact first frame, use the I2VA preset.
|
||||
|
||||
## Long scripts and short clips
|
||||
|
||||
A long multi-scene script is treated as source material, not as content that must all be compressed into one short generation. For an 8-second target, ThinkingLLM instructs the model to select one coherent contiguous moment that fits instead of turning the complete story into a rapid montage.
|
||||
|
||||
Standalone quoted paragraphs are recognized as source dialogue. The rules are:
|
||||
|
||||
- Scenes and dialogue outside the selected moment may be omitted.
|
||||
- The selected moment may be visual-only even when the master script contains dialogue.
|
||||
- Every selected dialogue block must match a source block verbatim and remain in source order.
|
||||
- Dialogue cannot be translated, paraphrased, merged, shortened, or invented.
|
||||
- Selected speech uses `<d>[Language] exact source text</d>` and a stable speaker ID such as `(S1)`. The square brackets are literal, so English is written as `<d>[English] Exact source text.</d>`, not `<d>English ...</d>`.
|
||||
- `<cutoff>` is allowed only when the user explicitly requests truncated speech.
|
||||
|
||||
For example, a 99-word comedy script and an 8-second target do not fail before or after model generation. The preset asks the LLM to select one short exchange and its directly related action, or a visual-only beat. Any remaining mismatch is forwarded for MiniMax's downstream prompt processing.
|
||||
|
||||
## Best-effort normalization
|
||||
|
||||
After generation, ThinkingLLM deterministically normalizes only safe, unambiguous formatting differences:
|
||||
|
||||
- uniquely identifiable field boundaries;
|
||||
- recognizable timestamp precision and `At` capitalization;
|
||||
- removal of the redundant zero timestamp from `[Shot 1]`;
|
||||
- the common `<d>English ...</d>` variant to `<d>[English] ...</d>` syntax;
|
||||
- removal of an obsolete standalone `Target duration:` declaration.
|
||||
|
||||
ThinkingLLM does **not** enforce exact field count or order, speaker IDs, dialogue length or source fidelity, sequential or in-range timestamps, or the full reference-section schema. It does not run an automatic schema-repair pass for minor mismatches; those are forwarded to the downstream MiniMax generator, which performs its own prompt interpretation. It retries only when no usable final prompt exists, such as an unfinished `<think>` block. The system prompt still strongly requests the official format without turning ThinkingLLM into a blocking schema gateway.
|
||||
|
||||
## Recommended settings
|
||||
|
||||
- The read-only info box shows the active video preset's recommendation without changing node values. See the [complete video preset settings table](VIDEO_PRESET_SETTINGS.md).
|
||||
- You can switch from LTX or MiniMax back to `Custom Only` and queue immediately; the hidden duration remains numeric and is ignored by the non-video preset.
|
||||
- Connect the same requested duration to ThinkingLLM and the H3 generator.
|
||||
- Start with `enable_thinking=false` for direct prompt conversion.
|
||||
- Start with `max_tokens=2048` for Base/T2VA, I2VA, FL2VA, and L2VA prompts.
|
||||
- Start with `max_tokens=3072` for full-reference prompts.
|
||||
- If a video-preset response ends inside an unfinished `<think>` block, ThinkingLLM discards that reasoning and automatically retries with thinking disabled and the native output-field prefill. It never forwards the incomplete reasoning as the main prompt.
|
||||
- Increase the token limit only when a useful response is genuinely truncated.
|
||||
- Inspect `RAW_TRACE` for model reasoning and connect the main output to Show Text or Show Anything to inspect the exact downstream string.
|
||||
|
||||
After updating the custom node, restart ComfyUI completely and refresh the browser with `Ctrl+F5` so Python node definitions and frontend widget visibility are both reloaded. If an old browser tab still reports `Failed to convert ... duration_seconds` followed by recommendation text, that tab is still using the stale frontend script; close it or perform another hard refresh.
|
||||
|
||||
## Official references
|
||||
|
||||
- [ComfyUI MiniMax H3 workflow guide](https://docs.comfy.org/tutorials/video/minimax/minimax-h3)
|
||||
- [MiniMax H3 Base prompt-writing guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_base_en.md)
|
||||
- [MiniMax H3 Full-Reference prompt-writing guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_ref_en.md)
|
||||
- [LTX-Video repository](https://github.com/Lightricks/LTX-Video)
|
||||
+245
-18
@@ -5,6 +5,7 @@
|
||||
"author": "mradermacher",
|
||||
"repo_name": "Qwen3-4B-abliterated-GGUF",
|
||||
"repo_id": "mradermacher/Qwen3-4B-abliterated-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3-4B-abliterated.Q4_K_M.gguf",
|
||||
"Qwen3-4B-abliterated.Q5_K_M.gguf",
|
||||
@@ -20,6 +21,7 @@
|
||||
"author": "mradermacher",
|
||||
"repo_name": "Josiefied-Qwen3-4B-abliterated-v1-GGUF",
|
||||
"repo_id": "mradermacher/Josiefied-Qwen3-4B-abliterated-v1-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Josiefied-Qwen3-4B-abliterated-v1.Q4_K_M.gguf",
|
||||
"Josiefied-Qwen3-4B-abliterated-v1.Q5_K_M.gguf",
|
||||
@@ -35,6 +37,7 @@
|
||||
"author": "mradermacher",
|
||||
"repo_name": "Josiefied-Qwen3-8B-abliterated-v1-GGUF",
|
||||
"repo_id": "mradermacher/Josiefied-Qwen3-8B-abliterated-v1-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Josiefied-Qwen3-8B-abliterated-v1.Q4_K_M.gguf",
|
||||
"Josiefied-Qwen3-8B-abliterated-v1.Q5_K_M.gguf",
|
||||
@@ -50,6 +53,7 @@
|
||||
"author": "mradermacher",
|
||||
"repo_name": "Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF",
|
||||
"repo_id": "mradermacher/Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3-VL-4B-Instruct-c_abliterated-v2.Q4_K_M.gguf",
|
||||
"Qwen3-VL-4B-Instruct-c_abliterated-v2.Q5_K_M.gguf",
|
||||
@@ -65,6 +69,7 @@
|
||||
"author": "mradermacher",
|
||||
"repo_name": "Qwen3-VL-8B-Instruct-c_abliterated-v3-GGUF",
|
||||
"repo_id": "mradermacher/Qwen3-VL-8B-Instruct-c_abliterated-v3-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3-VL-8B-Instruct-c_abliterated-v3.Q4_K_M.gguf",
|
||||
"Qwen3-VL-8B-Instruct-c_abliterated-v3.Q5_K_M.gguf",
|
||||
@@ -80,6 +85,7 @@
|
||||
"author": "DevQuasar",
|
||||
"repo_name": "Josiefied-Qwen3-4B-Instruct-2507-gabliterated-v1-GGUF",
|
||||
"repo_id": "DevQuasar/Goekdeniz-Guelmez.Josiefied-Qwen3-4B-Instruct-2507-gabliterated-v1-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Goekdeniz-Guelmez.Josiefied-Qwen3-4B-Instruct-2507-gabliterated-v1.Q4_K_M.gguf",
|
||||
"Goekdeniz-Guelmez.Josiefied-Qwen3-4B-Instruct-2507-gabliterated-v1.Q5_K_M.gguf",
|
||||
@@ -95,6 +101,7 @@
|
||||
"author": "Qwen",
|
||||
"repo_name": "Qwen3-4B-GGUF",
|
||||
"repo_id": "Qwen/Qwen3-4B-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3-4B-Q4_K_M.gguf",
|
||||
"Qwen3-4B-Q5_K_M.gguf",
|
||||
@@ -109,6 +116,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "Qwen3.5-4B-GGUF",
|
||||
"repo_id": "unsloth/Qwen3.5-4B-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3.5-4B-Q4_K_M.gguf",
|
||||
"Qwen3.5-4B-Q6_K.gguf",
|
||||
@@ -123,6 +131,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "Qwen3.5-9B-GGUF",
|
||||
"repo_id": "unsloth/Qwen3.5-9B-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3.5-9B-Q4_K_M.gguf",
|
||||
"Qwen3.5-9B-Q6_K.gguf",
|
||||
@@ -137,6 +146,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "Qwen3.5-27B-GGUF",
|
||||
"repo_id": "unsloth/Qwen3.5-27B-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3.5-27B-Q4_K_M.gguf",
|
||||
"Qwen3.5-27B-Q5_K_M.gguf",
|
||||
@@ -151,6 +161,7 @@
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-GGUF",
|
||||
"repo_id": "HauhauCS/Qwen3.5-4B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
|
||||
"Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-Q6_K.gguf",
|
||||
@@ -165,6 +176,7 @@
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-GGUF",
|
||||
"repo_id": "HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
|
||||
"Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q6_K.gguf",
|
||||
@@ -179,6 +191,7 @@
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-GGUF",
|
||||
"repo_id": "HauhauCS/Qwen3.5-27B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
|
||||
"Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-Q6_K.gguf",
|
||||
@@ -193,6 +206,7 @@
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive",
|
||||
"repo_id": "HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf",
|
||||
"Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
|
||||
@@ -210,15 +224,40 @@
|
||||
"context_length": 131072
|
||||
}
|
||||
},
|
||||
"Gemma-4-E2B-it-GGUF [Q4:2GB|Q8:3.5GB|VRAM:~3GB]": {
|
||||
"author": "ggml-org",
|
||||
"repo_name": "gemma-4-E2B-it-GGUF",
|
||||
"repo_id": "ggml-org/gemma-4-E2B-it-GGUF",
|
||||
"Qwen3.8-27B-ABLITERATED-GGUF [Q2:10.7GB|Q3:12.1-13.3GB|Q4:15.6-16.5GB|Q5:18.7-19.2GB|Q6:22.1GB|Q8:28.6GB|VRAM:~20GB+]": {
|
||||
"author": "Blackfrost-AI",
|
||||
"repo_name": "Qwen3.8-27B-ABLITERATED-GGUF",
|
||||
"repo_id": "Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"gemma-4-E2B-it-Q4_K_M.gguf",
|
||||
"gemma-4-E2B-it-Q8_0.gguf",
|
||||
"gemma-4-E2B-it-BF16.gguf"
|
||||
"Qwen3.8-27B-ABLITERATED-Q2_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q6_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q8_0.gguf"
|
||||
],
|
||||
"defaults": {
|
||||
"context_length": 262144
|
||||
}
|
||||
},
|
||||
"Gemma-4-E2B-it-GGUF [Q4:3.2GB|Q8:4.7GB|BF16:8.7GB|VRAM:~5GB]": {
|
||||
"author": "bartowski",
|
||||
"repo_name": "google_gemma-4-E2B-it-GGUF",
|
||||
"repo_id": "bartowski/google_gemma-4-E2B-it-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"google_gemma-4-E2B-it-Q4_K_S.gguf",
|
||||
"google_gemma-4-E2B-it-Q8_0.gguf",
|
||||
"google_gemma-4-E2B-it-bf16.gguf"
|
||||
],
|
||||
"local_filenames": {
|
||||
"google_gemma-4-E2B-it-Q8_0.gguf": ["gemma-4-E2B-it-Q8_0.gguf"],
|
||||
"google_gemma-4-E2B-it-bf16.gguf": ["gemma-4-E2B-it-bf16.gguf", "gemma-4-E2B-it-BF16.gguf"]
|
||||
},
|
||||
"defaults": {
|
||||
"context_length": 131072
|
||||
}
|
||||
@@ -227,6 +266,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "gemma-4-E4B-it-GGUF",
|
||||
"repo_id": "unsloth/gemma-4-E4B-it-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"gemma-4-E4B-it-Q4_K_M.gguf",
|
||||
"gemma-4-E4B-it-Q6_K.gguf",
|
||||
@@ -241,6 +281,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "gemma-4-26B-A4B-it-GGUF",
|
||||
"repo_id": "unsloth/gemma-4-26B-A4B-it-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"gemma-4-26B-A4B-it-Q4_K_M.gguf",
|
||||
"gemma-4-26B-A4B-it-Q6_K.gguf",
|
||||
@@ -255,6 +296,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "gemma-4-12b-it-GGUF",
|
||||
"repo_id": "unsloth/gemma-4-12b-it-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"gemma-4-12b-it-Q4_K_M.gguf",
|
||||
"gemma-4-12b-it-Q4_K_S.gguf",
|
||||
@@ -273,6 +315,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "gemma-4-31B-it-GGUF",
|
||||
"repo_id": "unsloth/gemma-4-31B-it-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"gemma-4-31B-it-Q4_K_M.gguf",
|
||||
"gemma-4-31B-it-Q6_K.gguf",
|
||||
@@ -287,24 +330,78 @@
|
||||
"author": "bartowski",
|
||||
"repo_name": "google_gemma-4-E4B-it-GGUF",
|
||||
"repo_id": "bartowski/google_gemma-4-E4B-it-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"gemma-4-E4B-it-Q4_K_M.gguf",
|
||||
"gemma-4-E4B-it-Q6_K.gguf",
|
||||
"gemma-4-E4B-it-Q8_0.gguf",
|
||||
"gemma-4-E4B-it-BF16.gguf"
|
||||
"google_gemma-4-E4B-it-Q4_K_M.gguf",
|
||||
"google_gemma-4-E4B-it-Q5_K_M.gguf",
|
||||
"google_gemma-4-E4B-it-Q6_K.gguf",
|
||||
"google_gemma-4-E4B-it-Q8_0.gguf",
|
||||
"google_gemma-4-E4B-it-bf16.gguf"
|
||||
],
|
||||
"local_filenames": {
|
||||
"google_gemma-4-E4B-it-Q4_K_M.gguf": ["gemma-4-E4B-it-Q4_K_M.gguf"],
|
||||
"google_gemma-4-E4B-it-Q5_K_M.gguf": ["gemma-4-E4B-it-Q5_K_M.gguf"],
|
||||
"google_gemma-4-E4B-it-Q6_K.gguf": ["gemma-4-E4B-it-Q6_K.gguf"],
|
||||
"google_gemma-4-E4B-it-Q8_0.gguf": ["gemma-4-E4B-it-Q8_0.gguf"],
|
||||
"google_gemma-4-E4B-it-bf16.gguf": ["gemma-4-E4B-it-BF16.gguf"]
|
||||
},
|
||||
"defaults": {
|
||||
"context_length": 131072
|
||||
}
|
||||
},
|
||||
"Gemma-4-E2B-Uncensored-GGUF [Q4:3.2GB|Q8:4.7GB|BF16:8.7GB|VRAM:~5GB]": {
|
||||
"author": "bartowski",
|
||||
"repo_name": "google_gemma-4-E2B-it-GGUF",
|
||||
"repo_id": "bartowski/google_gemma-4-E2B-it-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"google_gemma-4-E2B-it-Q4_K_S.gguf",
|
||||
"google_gemma-4-E2B-it-Q8_0.gguf",
|
||||
"google_gemma-4-E2B-it-bf16.gguf"
|
||||
],
|
||||
"local_filenames": {
|
||||
"google_gemma-4-E2B-it-Q8_0.gguf": ["gemma-4-E2B-it-Q8_0.gguf"],
|
||||
"google_gemma-4-E2B-it-bf16.gguf": ["gemma-4-E2B-it-bf16.gguf", "gemma-4-E2B-it-BF16.gguf"]
|
||||
},
|
||||
"defaults": {
|
||||
"context_length": 131072
|
||||
}
|
||||
},
|
||||
"Gemma-4-E4B-Uncensored-Aggressive-GGUF [Q2:4.4GB|Q3:4.7-4.9GB|Q4:5.1-5.4GB|Q5:5.8GB|Q6:6.3GB|Q8:8.1GB|VRAM:~7GB]": {
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ4_XS.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_M.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_M.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf"
|
||||
],
|
||||
"defaults": {
|
||||
"context_length": 131072
|
||||
}
|
||||
},
|
||||
"Gemma-4-E2B-Uncensored-GGUF [Q4:2GB|Q8:3.5GB|VRAM:~3GB]": {
|
||||
"author": "ggml-org",
|
||||
"repo_name": "gemma-4-E2B-it-GGUF",
|
||||
"repo_id": "ggml-org/gemma-4-E2B-it-GGUF",
|
||||
"Gemma-4-E2B-Uncensored-Aggressive-GGUF [Q2:3GB|Q3:3.1GB|Q4:3.4GB|Q5:3.7GB|Q6:3.9GB|Q8:5GB|VRAM:~5GB]": {
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
|
||||
"repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"gemma-4-E2B-it-Q4_K_M.gguf",
|
||||
"gemma-4-E2B-it-Q8_0.gguf",
|
||||
"gemma-4-E2B-it-BF16.gguf"
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf"
|
||||
],
|
||||
"defaults": {
|
||||
"context_length": 131072
|
||||
@@ -316,6 +413,7 @@
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Qwen3.5-4B-Uncensored-HauhauCS-Aggressive",
|
||||
"repo_id": "HauhauCS/Qwen3.5-4B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-BF16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
|
||||
@@ -336,6 +434,7 @@
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Qwen3.5-9B-Uncensored-HauhauCS-Aggressive",
|
||||
"repo_id": "HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-BF16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
|
||||
@@ -356,6 +455,7 @@
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Qwen3.5-27B-Uncensored-HauhauCS-Aggressive",
|
||||
"repo_id": "HauhauCS/Qwen3.5-27B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-f16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
|
||||
@@ -376,6 +476,7 @@
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive",
|
||||
"repo_id": "HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-f16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf",
|
||||
@@ -399,10 +500,42 @@
|
||||
"pool_size": 4194304
|
||||
}
|
||||
},
|
||||
"Qwen3.8-27B-ABLITERATED-GGUF [Q2:10.7GB|Q3:12.1-13.3GB|Q4:15.6-16.5GB|Q5:18.7-19.2GB|Q6:22.1GB|Q8:28.6GB|VRAM:~20GB+]": {
|
||||
"author": "Blackfrost-AI",
|
||||
"repo_name": "Qwen3.8-27B-ABLITERATED-GGUF",
|
||||
"repo_id": "Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3.8-27B-ABLITERATED-Q8_0.gguf",
|
||||
"mmproj_local_filenames": {
|
||||
"mmproj-Qwen3.8-27B-ABLITERATED-Q8_0.gguf": [
|
||||
"mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf"
|
||||
]
|
||||
},
|
||||
"model_files": [
|
||||
"Qwen3.8-27B-ABLITERATED-Q2_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q6_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q8_0.gguf"
|
||||
],
|
||||
"defaults": {
|
||||
"context_length": 262144,
|
||||
"image_max_tokens": 4096,
|
||||
"n_batch": 512,
|
||||
"gpu_layers": -1,
|
||||
"top_k": 20,
|
||||
"pool_size": 4194304
|
||||
}
|
||||
},
|
||||
"Qwen3.5-4B [Q4:2.4GB|Q6:3.2GB|Q8:4.2GB|BF16:8GB|VRAM:~4GB]": {
|
||||
"author": "unsloth",
|
||||
"repo_name": "Qwen3.5-4B-GGUF",
|
||||
"repo_id": "unsloth/Qwen3.5-4B-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-BF16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3.5-4B-Q4_K_M.gguf",
|
||||
@@ -423,6 +556,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "Qwen3.5-9B-GGUF",
|
||||
"repo_id": "unsloth/Qwen3.5-9B-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-F32.gguf",
|
||||
"model_files": [
|
||||
"Qwen3.5-9B-Q4_K_M.gguf",
|
||||
@@ -443,6 +577,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "Qwen3.5-27B-GGUF",
|
||||
"repo_id": "unsloth/Qwen3.5-27B-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-F32.gguf",
|
||||
"model_files": [
|
||||
"Qwen3.5-27B-Q4_K_M.gguf",
|
||||
@@ -463,6 +598,7 @@
|
||||
"author": "unsloth",
|
||||
"repo_name": "gemma-4-12b-it-GGUF",
|
||||
"repo_id": "unsloth/gemma-4-12b-it-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-F16.gguf",
|
||||
"model_files": [
|
||||
"gemma-4-12b-it-Q4_K_M.gguf",
|
||||
@@ -483,10 +619,95 @@
|
||||
"pool_size": 4194304
|
||||
}
|
||||
},
|
||||
"google_gemma-4-E4B-it-GGUF [Q4:5.4GB|Q5:5.8GB|Q6:6.3GB|Q8:8GB|BF16:15GB|VRAM:~7GB]": {
|
||||
"author": "bartowski",
|
||||
"repo_name": "google_gemma-4-E4B-it-GGUF",
|
||||
"repo_id": "bartowski/google_gemma-4-E4B-it-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-google_gemma-4-E4B-it-f16.gguf",
|
||||
"model_files": [
|
||||
"google_gemma-4-E4B-it-Q4_K_M.gguf",
|
||||
"google_gemma-4-E4B-it-Q5_K_M.gguf",
|
||||
"google_gemma-4-E4B-it-Q6_K.gguf",
|
||||
"google_gemma-4-E4B-it-Q8_0.gguf",
|
||||
"google_gemma-4-E4B-it-bf16.gguf"
|
||||
],
|
||||
"local_filenames": {
|
||||
"google_gemma-4-E4B-it-Q4_K_M.gguf": ["gemma-4-E4B-it-Q4_K_M.gguf"],
|
||||
"google_gemma-4-E4B-it-Q5_K_M.gguf": ["gemma-4-E4B-it-Q5_K_M.gguf"],
|
||||
"google_gemma-4-E4B-it-Q6_K.gguf": ["gemma-4-E4B-it-Q6_K.gguf"],
|
||||
"google_gemma-4-E4B-it-Q8_0.gguf": ["gemma-4-E4B-it-Q8_0.gguf"],
|
||||
"google_gemma-4-E4B-it-bf16.gguf": ["gemma-4-E4B-it-BF16.gguf"]
|
||||
},
|
||||
"mmproj_local_filenames": {
|
||||
"mmproj-google_gemma-4-E4B-it-f16.gguf": ["mmproj-F16.gguf", "mmproj-BF16.gguf"]
|
||||
},
|
||||
"defaults": {
|
||||
"context_length": 131072,
|
||||
"image_max_tokens": 4096,
|
||||
"n_batch": 512,
|
||||
"gpu_layers": -1,
|
||||
"top_k": 64,
|
||||
"pool_size": 4194304
|
||||
}
|
||||
},
|
||||
"Gemma-4-E4B-Uncensored-Aggressive-GGUF [Q2:4.4GB|Q3:4.7-4.9GB|Q4:5.1-5.4GB|Q5:5.8GB|Q6:6.3GB|Q8:8.1GB|VRAM:~7GB]": {
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-f16.gguf",
|
||||
"model_files": [
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ4_XS.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_M.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_M.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
|
||||
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf"
|
||||
],
|
||||
"defaults": {
|
||||
"context_length": 131072,
|
||||
"image_max_tokens": 4096,
|
||||
"n_batch": 512,
|
||||
"gpu_layers": -1,
|
||||
"top_k": 64,
|
||||
"pool_size": 4194304
|
||||
}
|
||||
},
|
||||
"Gemma-4-E2B-Uncensored-Aggressive-GGUF [Q2:3GB|Q3:3.1GB|Q4:3.4GB|Q5:3.7GB|Q6:3.9GB|Q8:5GB|VRAM:~5GB]": {
|
||||
"author": "HauhauCS",
|
||||
"repo_name": "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
|
||||
"repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-f16.gguf",
|
||||
"model_files": [
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
|
||||
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf"
|
||||
],
|
||||
"defaults": {
|
||||
"context_length": 131072,
|
||||
"image_max_tokens": 4096,
|
||||
"n_batch": 512,
|
||||
"gpu_layers": -1,
|
||||
"top_k": 64,
|
||||
"pool_size": 4194304
|
||||
}
|
||||
},
|
||||
"Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF [Q4:2.4GB|Q5:2.8GB|Q6:3.2GB|Q8:4.2GB|F16:8GB|VRAM:~4GB]": {
|
||||
"author": "mradermacher",
|
||||
"repo_name": "Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF",
|
||||
"repo_id": "mradermacher/Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "Qwen3-VL-4B-Instruct-c_abliterated-v2.mmproj-f16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3-VL-4B-Instruct-c_abliterated-v2.Q4_K_M.gguf",
|
||||
@@ -508,6 +729,7 @@
|
||||
"author": "mradermacher",
|
||||
"repo_name": "Qwen3-VL-8B-Instruct-c_abliterated-v3-GGUF",
|
||||
"repo_id": "mradermacher/Qwen3-VL-8B-Instruct-c_abliterated-v3-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "Qwen3-VL-8B-Instruct-c_abliterated-v3.mmproj-f16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3-VL-8B-Instruct-c_abliterated-v3.Q4_K_M.gguf",
|
||||
@@ -529,6 +751,7 @@
|
||||
"author": "mradermacher",
|
||||
"repo_name": "Qwen3-VL-32B-Heretic-v2-GGUF",
|
||||
"repo_id": "mradermacher/Qwen3-VL-32B-Heretic-v2-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3-VL-32B-Heretic-v2.Q4_K_M.gguf",
|
||||
"Qwen3-VL-32B-Heretic-v2.Q5_K_M.gguf",
|
||||
@@ -548,6 +771,7 @@
|
||||
"author": "Qwen",
|
||||
"repo_name": "Qwen3-VL-4B-Instruct-GGUF",
|
||||
"repo_id": "Qwen/Qwen3-VL-4B-Instruct-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3VL-4B-Instruct-Q4_K_M.gguf",
|
||||
@@ -567,6 +791,7 @@
|
||||
"author": "Qwen",
|
||||
"repo_name": "Qwen3-VL-8B-Instruct-GGUF",
|
||||
"repo_id": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3VL-8B-Instruct-F16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3VL-8B-Instruct-Q4_K_M.gguf",
|
||||
@@ -586,6 +811,7 @@
|
||||
"author": "Qwen",
|
||||
"repo_name": "Qwen3-VL-4B-Thinking-GGUF",
|
||||
"repo_id": "Qwen/Qwen3-VL-4B-Thinking-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3VL-4B-Thinking-F16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3VL-4B-Thinking-Q4_K_M.gguf",
|
||||
@@ -605,6 +831,7 @@
|
||||
"author": "Qwen",
|
||||
"repo_name": "Qwen3-VL-8B-Thinking-GGUF",
|
||||
"repo_id": "Qwen/Qwen3-VL-8B-Thinking-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3VL-8B-Thinking-F16.gguf",
|
||||
"model_files": [
|
||||
"Qwen3VL-8B-Thinking-Q4_K_M.gguf",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"hf_vl_models": {
|
||||
"Qwen3-VL-4B-Instruct (locked commercial model)": {
|
||||
"repo_id": "Qwen/Qwen3-VL-4B-Instruct",
|
||||
"commercial_status": "cleared",
|
||||
"revision": "ebb281ec70b05090aa6165b016eac8ec08e71b17",
|
||||
"component_id": "MODEL-014",
|
||||
"default": true,
|
||||
"quantized": false,
|
||||
"vram_requirement": {"full": 9.0}
|
||||
}
|
||||
},
|
||||
"hf_text_models": {
|
||||
"Qwen3-4B-Instruct-2507 (locked commercial model)": {
|
||||
"repo_id": "Qwen/Qwen3-4B-Instruct-2507",
|
||||
"commercial_status": "cleared",
|
||||
"revision": "cdbee75f17c01a7cc42f958dc650907174af0554",
|
||||
"component_id": "MODEL-006",
|
||||
"default": true,
|
||||
"quantized": false,
|
||||
"vram_requirement": {"full": 9.0}
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-40
@@ -1,7 +1,17 @@
|
||||
{
|
||||
"hf_vl_models": {
|
||||
"Qwen3-VL-4B-Instruct (locked commercial model)": {
|
||||
"repo_id": "Qwen/Qwen3-VL-4B-Instruct",
|
||||
"commercial_status": "cleared",
|
||||
"revision": "ebb281ec70b05090aa6165b016eac8ec08e71b17",
|
||||
"component_id": "MODEL-014",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {"full": 9.0}
|
||||
},
|
||||
"Qwen3.5-4B-heretic-v2 [DL: 7.5GB, VRAM: 9.0GB]": {
|
||||
"repo_id": "tvall43/Qwen3.5-4B-heretic-v2",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -10,6 +20,7 @@
|
||||
},
|
||||
"Qwen3.5-9B-ultra-uncensored-heretic-v2 [DL: 17GB, VRAM: 19.0GB]": {
|
||||
"repo_id": "llmfan46/Qwen3.5-9B-ultra-uncensored-heretic-v2",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -18,6 +29,7 @@
|
||||
},
|
||||
"Qwen3.5-4B [DL: 7.5GB, VRAM: 9.0GB]": {
|
||||
"repo_id": "Qwen/Qwen3.5-4B",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -26,6 +38,7 @@
|
||||
},
|
||||
"Qwen3.5-9B [DL: 17GB, VRAM: 19.0GB]": {
|
||||
"repo_id": "Qwen/Qwen3.5-9B",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -34,6 +47,7 @@
|
||||
},
|
||||
"Qwen3-VL-4B-Instruct-Abliterated [DL: 7.5GB, VRAM: 6.0GB]": {
|
||||
"repo_id": "huihui-ai/Huihui-Qwen3-VL-4B-Instruct-abliterated",
|
||||
"commercial_status": "unclear",
|
||||
"default": true,
|
||||
"quantized": true,
|
||||
"vram_requirement": {
|
||||
@@ -44,6 +58,7 @@
|
||||
},
|
||||
"Qwen3-VL-8B-Instruct-Abliterated [DL: 15GB, VRAM: 12.0GB]": {
|
||||
"repo_id": "huihui-ai/Huihui-Qwen3-VL-8B-Instruct-abliterated",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": true,
|
||||
"vram_requirement": {
|
||||
@@ -54,6 +69,7 @@
|
||||
},
|
||||
"Qwen3-VL-4B-Instruct-Unredacted-MAX [DL: 7.5GB, VRAM: 6.0GB]": {
|
||||
"repo_id": "prithivMLmods/Qwen3-VL-4B-Instruct-Unredacted-MAX",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -64,6 +80,7 @@
|
||||
},
|
||||
"Qwen3-VL-8B-Instruct-Unredacted-MAX [DL: 15GB, VRAM: 12.0GB]": {
|
||||
"repo_id": "prithivMLmods/Qwen3-VL-8B-Instruct-Unredacted-MAX",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -74,6 +91,7 @@
|
||||
},
|
||||
"Qwen3-VL-8B-Instruct-abliterated-v1 [DL: 15GB, VRAM: 12.0GB]": {
|
||||
"repo_id": "prithivMLmods/Qwen3-VL-8B-Instruct-abliterated-v1",
|
||||
"commercial_status": "external_gated",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -84,6 +102,7 @@
|
||||
},
|
||||
"Qwen3-VL-4B-Instruct-abliterated-v1 [DL: 7.5GB, VRAM: 6.0GB]": {
|
||||
"repo_id": "prithivMLmods/Qwen3-VL-4B-Instruct-abliterated-v1",
|
||||
"commercial_status": "external_gated",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -94,6 +113,7 @@
|
||||
},
|
||||
"Qwen3-VL-32B-Heretic-v2 [DL: 60GB, VRAM: 48.0GB]": {
|
||||
"repo_id": "rpDungeon/Qwen3-VL-32B-Heretic-v2",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -104,6 +124,7 @@
|
||||
},
|
||||
"Gemma-4-26B-A4B-it [DL: 51.6GB, VRAM: 42.0GB]": {
|
||||
"repo_id": "google/gemma-4-26B-A4B-it",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -114,6 +135,7 @@
|
||||
},
|
||||
"Gemma-4-E4B-it [DL: 16GB, VRAM: 7.0GB]": {
|
||||
"repo_id": "google/gemma-4-E4B-it",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -124,6 +146,7 @@
|
||||
},
|
||||
"Gemma-4-E2B-it [DL: 10.3GB, VRAM: 4.0GB]": {
|
||||
"repo_id": "google/gemma-4-E2B-it",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -134,6 +157,7 @@
|
||||
},
|
||||
"Gemma-4-31B-it [DL: 62.6GB, VRAM: 48.0GB]": {
|
||||
"repo_id": "google/gemma-4-31B-it",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -144,6 +168,7 @@
|
||||
},
|
||||
"Gemma-4-12B [DL: 24GB, VRAM: 24.0GB]": {
|
||||
"repo_id": "google/gemma-4-12B",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -154,6 +179,7 @@
|
||||
},
|
||||
"Gemma-4-E4B-Uncensored [DL: 16GB, VRAM: 7.0GB]": {
|
||||
"repo_id": "prithivMLmods/gemma-4-E4B-it-Uncensored-MAX",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -164,6 +190,7 @@
|
||||
},
|
||||
"Gemma-4-E2B-Uncensored [DL: 10.3GB, VRAM: 4.0GB]": {
|
||||
"repo_id": "prithivMLmods/gemma-4-E2B-it-Uncensored-MAX",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -174,6 +201,7 @@
|
||||
},
|
||||
"Gemma-4-26B-A4B-Heretic [DL: 51.6GB, VRAM: 42.0GB]": {
|
||||
"repo_id": "coder3101/gemma-4-26B-A4B-it-heretic",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -181,31 +209,21 @@
|
||||
"8bit": 22.0,
|
||||
"4bit": 14.0
|
||||
}
|
||||
},
|
||||
"Gemma-4-E4B-Uncensored-Aggressive [DL: 16GB, VRAM: 7.0GB]": {
|
||||
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
"full": 7.0,
|
||||
"8bit": 4.5,
|
||||
"4bit": 2.5
|
||||
}
|
||||
},
|
||||
"Gemma-4-E2B-Uncensored-Aggressive [DL: 10.3GB, VRAM: 4.0GB]": {
|
||||
"repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
"full": 4.0,
|
||||
"8bit": 2.5,
|
||||
"4bit": 1.5
|
||||
}
|
||||
}
|
||||
},
|
||||
"hf_text_models": {
|
||||
"Qwen3-4B-Instruct-2507 (locked commercial model)": {
|
||||
"repo_id": "Qwen/Qwen3-4B-Instruct-2507",
|
||||
"commercial_status": "cleared",
|
||||
"revision": "cdbee75f17c01a7cc42f958dc650907174af0554",
|
||||
"component_id": "MODEL-006",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {"full": 9.0}
|
||||
},
|
||||
"Qwen3-VL-4B-Instruct-Abliterated [DL: 7.5GB, VRAM: 6.0GB]": {
|
||||
"repo_id": "huihui-ai/Huihui-Qwen3-VL-4B-Instruct-abliterated",
|
||||
"commercial_status": "unclear",
|
||||
"default": true,
|
||||
"quantized": true,
|
||||
"vram_requirement": {
|
||||
@@ -216,6 +234,7 @@
|
||||
},
|
||||
"Qwen3-VL-8B-Instruct-Abliterated [DL: 15GB, VRAM: 12.0GB]": {
|
||||
"repo_id": "huihui-ai/Huihui-Qwen3-VL-8B-Instruct-abliterated",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": true,
|
||||
"vram_requirement": {
|
||||
@@ -226,6 +245,7 @@
|
||||
},
|
||||
"qwen3-4b-Z-Image-Engineer [DL: 7.5GB, VRAM: 9.0GB]": {
|
||||
"repo_id": "BennyDaBall/qwen3-4b-Z-Image-Engineer",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -234,6 +254,7 @@
|
||||
},
|
||||
"Qwen3.5-4B-heretic-v2 [DL: 7.5GB, VRAM: 8.0GB]": {
|
||||
"repo_id": "tvall43/Qwen3.5-4B-heretic-v2",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -242,6 +263,7 @@
|
||||
},
|
||||
"Qwen3.5-9B-ultra-uncensored-heretic-v2 [DL: 17GB, VRAM: 18.0GB]": {
|
||||
"repo_id": "llmfan46/Qwen3.5-9B-ultra-uncensored-heretic-v2",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -250,6 +272,7 @@
|
||||
},
|
||||
"Qwen3.5-4B [DL: 7.5GB, VRAM: 8.0GB]": {
|
||||
"repo_id": "Qwen/Qwen3.5-4B",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -258,6 +281,7 @@
|
||||
},
|
||||
"Qwen3.5-9B [DL: 17GB, VRAM: 18.0GB]": {
|
||||
"repo_id": "Qwen/Qwen3.5-9B",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -266,6 +290,7 @@
|
||||
},
|
||||
"Gemma-4-26B-A4B-it [DL: 51.6GB, VRAM: 42.0GB]": {
|
||||
"repo_id": "google/gemma-4-26B-A4B-it",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -276,6 +301,7 @@
|
||||
},
|
||||
"Gemma-4-E4B-it [DL: 16GB, VRAM: 7.0GB]": {
|
||||
"repo_id": "google/gemma-4-E4B-it",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -286,6 +312,7 @@
|
||||
},
|
||||
"Gemma-4-E2B-it [DL: 10.3GB, VRAM: 4.0GB]": {
|
||||
"repo_id": "google/gemma-4-E2B-it",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -296,6 +323,7 @@
|
||||
},
|
||||
"Gemma-4-31B-it [DL: 62.6GB, VRAM: 48.0GB]": {
|
||||
"repo_id": "google/gemma-4-31B-it",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -306,6 +334,7 @@
|
||||
},
|
||||
"Gemma-4-12B [DL: 24GB, VRAM: 24.0GB]": {
|
||||
"repo_id": "google/gemma-4-12B",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -316,6 +345,7 @@
|
||||
},
|
||||
"Gemma-4-E4B-Uncensored [DL: 16GB, VRAM: 7.0GB]": {
|
||||
"repo_id": "prithivMLmods/gemma-4-E4B-it-Uncensored-MAX",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -326,6 +356,7 @@
|
||||
},
|
||||
"Gemma-4-E2B-Uncensored [DL: 10.3GB, VRAM: 4.0GB]": {
|
||||
"repo_id": "prithivMLmods/gemma-4-E2B-it-Uncensored-MAX",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -336,6 +367,7 @@
|
||||
},
|
||||
"Gemma-4-26B-A4B-Heretic [DL: 51.6GB, VRAM: 42.0GB]": {
|
||||
"repo_id": "coder3101/gemma-4-26B-A4B-it-heretic",
|
||||
"commercial_status": "unclear",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
@@ -343,26 +375,6 @@
|
||||
"8bit": 22.0,
|
||||
"4bit": 14.0
|
||||
}
|
||||
},
|
||||
"Gemma-4-E4B-Uncensored-Aggressive [DL: 16GB, VRAM: 7.0GB]": {
|
||||
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
"full": 7.0,
|
||||
"8bit": 4.5,
|
||||
"4bit": 2.5
|
||||
}
|
||||
},
|
||||
"Gemma-4-E2B-Uncensored-Aggressive [DL: 10.3GB, VRAM: 4.0GB]": {
|
||||
"repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
|
||||
"default": false,
|
||||
"quantized": false,
|
||||
"vram_requirement": {
|
||||
"full": 4.0,
|
||||
"8bit": 2.5,
|
||||
"4bit": 1.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,902 @@
|
||||
"""Secure OpenAI-compatible API node for ThinkingLLM.
|
||||
|
||||
Workflow/API input never receives provider credentials, credential environment
|
||||
variable names, or arbitrary provider endpoints. It selects a server-side
|
||||
profile that binds endpoint, authentication, capabilities, and request limits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output
|
||||
from AILab_StreamDisplay import compose_streamed_model_output, extract_stream_token, get_thinking_stream_display
|
||||
|
||||
PACKAGE_DIR = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_PROFILE_FILE = PACKAGE_DIR / "thinkingllm_api_profiles.json"
|
||||
PROFILE_FILE_ENV = "THINKINGLLM_API_PROFILES_FILE"
|
||||
DISABLE_BUILTINS_ENV = "THINKINGLLM_DISABLE_BUILTIN_API_PROFILES"
|
||||
DEFAULT_MAX_INPUT_CHARS = 1_000_000
|
||||
DEFAULT_MAX_TIMEOUT_SECONDS = 300
|
||||
DEFAULT_MAX_IMAGE_PIXELS = 16_777_216
|
||||
DEFAULT_MAX_IMAGE_BYTES = 20_000_000
|
||||
MAX_EXTRA_BODY_JSON_CHARS = 65_536
|
||||
MAX_MODEL_NAME_CHARS = 512
|
||||
MAX_PROFILE_LABEL_CHARS = 128
|
||||
MAX_IMAGE_URL_CHARS = 8_192
|
||||
|
||||
# Curated convenience model catalog for the node UI (see web/api_model_catalogs.json).
|
||||
# It is UX only: it never overrides a server-side profile's allowed_models allowlist.
|
||||
CATALOG_PATH = PACKAGE_DIR / "web" / "api_model_catalogs.json"
|
||||
_CATALOG_CACHE: dict | None = None
|
||||
|
||||
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
||||
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
||||
_IMAGE_ERROR_RE = re.compile(
|
||||
r"(?i)(image|vision|multimodal|image_url|modality|unsupported\s+(?:content|input)|invalid\s+(?:content|input))"
|
||||
)
|
||||
_RESERVED_EXTRA_BODY_FIELDS = {
|
||||
"model", "messages", "stream", "max_tokens", "max_completion_tokens",
|
||||
"temperature", "top_p", "seed", "enable_thinking", "thinking_budget",
|
||||
}
|
||||
_PROFILE_ALLOWED_FIELDS = {
|
||||
"provider", "base_url", "auth", "api_key_env", "allowed_models",
|
||||
"max_tokens_limit", "max_input_chars", "max_timeout_seconds", "max_thinking_budget",
|
||||
"allowed_extra_body_fields", "send_seed", "thinking_mode",
|
||||
"max_tokens_field", "allow_insecure_http",
|
||||
"allow_images", "allow_image_url", "max_image_pixels", "max_image_bytes",
|
||||
}
|
||||
|
||||
|
||||
def _cloud_profile(provider: str, base_url: str, api_key_env: str, **extra) -> dict:
|
||||
profile = {
|
||||
"provider": provider,
|
||||
"base_url": base_url,
|
||||
"auth": "bearer_env",
|
||||
"api_key_env": api_key_env,
|
||||
"allow_images": True,
|
||||
"allow_image_url": True,
|
||||
}
|
||||
profile.update(extra)
|
||||
return profile
|
||||
|
||||
|
||||
def _local_profile(provider: str, base_url: str, *, send_seed: bool = False) -> dict:
|
||||
return {
|
||||
"provider": provider,
|
||||
"base_url": base_url,
|
||||
"auth": "none",
|
||||
"allow_images": True,
|
||||
"allow_image_url": True,
|
||||
"send_seed": send_seed,
|
||||
}
|
||||
|
||||
|
||||
_BUILTIN_PROFILES = {
|
||||
"OpenAI": _cloud_profile(
|
||||
"OpenAI", "https://api.openai.com/v1", "OPENAI_API_KEY",
|
||||
max_tokens_field="max_completion_tokens",
|
||||
),
|
||||
"QwenCloud (Singapore)": _cloud_profile(
|
||||
"QwenCloud", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
"DASHSCOPE_API_KEY", thinking_mode="qwen",
|
||||
),
|
||||
"OpenRouter": _cloud_profile(
|
||||
"OpenRouter", "https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"
|
||||
),
|
||||
"Together AI": _cloud_profile(
|
||||
"Together AI", "https://api.together.ai/v1", "TOGETHER_API_KEY"
|
||||
),
|
||||
"Fireworks AI": _cloud_profile(
|
||||
"Fireworks AI", "https://api.fireworks.ai/inference/v1", "FIREWORKS_API_KEY"
|
||||
),
|
||||
"DeepInfra": _cloud_profile(
|
||||
"DeepInfra", "https://api.deepinfra.com/v1/openai", "DEEPINFRA_TOKEN"
|
||||
),
|
||||
"Groq": _cloud_profile(
|
||||
"Groq", "https://api.groq.com/openai/v1", "GROQ_API_KEY"
|
||||
),
|
||||
"Featherless": _cloud_profile(
|
||||
"Featherless", "https://api.featherless.ai/v1", "FEATHERLESS_API_KEY"
|
||||
),
|
||||
# OrcaRouter serves reasoning-heavy models with long TTFT (e.g. obsidian/*
|
||||
# uncensored variants) that can exceed the default 300 s deadline, so it gets
|
||||
# a higher server-side cap. The widget still enforces a hard maximum of 3600.
|
||||
"OrcaRouter": _cloud_profile(
|
||||
"OrcaRouter", "https://api.orcarouter.ai/v1", "ORCAROUTER_API_KEY",
|
||||
max_timeout_seconds=900,
|
||||
),
|
||||
"Ollama (local)": _local_profile("Ollama", "http://127.0.0.1:11434/v1"),
|
||||
"vLLM (local)": _local_profile("vLLM", "http://127.0.0.1:8000/v1", send_seed=True),
|
||||
"llama.cpp (local)": _local_profile("llama.cpp", "http://127.0.0.1:8080/v1", send_seed=True),
|
||||
}
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Reject redirects so bearer credentials never cross an origin boundary."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
||||
|
||||
|
||||
class _ProviderStreamError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _profile_file_path() -> Path:
|
||||
configured = os.environ.get(PROFILE_FILE_ENV, "").strip()
|
||||
return Path(configured).expanduser() if configured else DEFAULT_PROFILE_FILE
|
||||
|
||||
|
||||
def _has_control_chars(value: str) -> bool:
|
||||
return bool(_CONTROL_CHAR_RE.search(str(value or "")))
|
||||
|
||||
|
||||
def _safe_label(value: str, field: str, max_chars: int = MAX_PROFILE_LABEL_CHARS) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError(f"{field} must not be empty.")
|
||||
if len(text) > max_chars:
|
||||
raise ValueError(f"{field} is too long (maximum {max_chars} characters).")
|
||||
if _has_control_chars(text):
|
||||
raise ValueError(f"{field} must not contain control characters.")
|
||||
return text
|
||||
|
||||
|
||||
def _strict_bool(raw: dict, key: str, default: bool = False) -> bool:
|
||||
value = raw.get(key, default)
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"API profile {key} must be a JSON boolean.")
|
||||
return value
|
||||
|
||||
|
||||
def _profile_int(raw: dict, key: str, default=None, minimum: int = 1, maximum: int | None = None):
|
||||
value = raw.get(key, default)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"API profile {key} must be an integer.")
|
||||
if value < minimum or (maximum is not None and value > maximum):
|
||||
range_text = f">= {minimum}" if maximum is None else f"between {minimum} and {maximum}"
|
||||
raise ValueError(f"API profile {key} must be {range_text}.")
|
||||
return value
|
||||
|
||||
|
||||
def _is_loopback_host(hostname: str | None) -> bool:
|
||||
host = str(hostname or "").strip().rstrip(".").lower()
|
||||
if host == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_base_url(base_url: str, auth: str, allow_insecure_http: bool = False) -> str:
|
||||
value = str(base_url or "").strip().rstrip("/")
|
||||
if not value or _has_control_chars(value):
|
||||
raise ValueError("API profile base_url is empty or contains control characters.")
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
|
||||
raise ValueError(f"Invalid API profile base_url: {value!r}")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("API profile base_url must not contain embedded credentials.")
|
||||
if parsed.query or parsed.fragment:
|
||||
raise ValueError("API profile base_url must not contain a query string or fragment.")
|
||||
try:
|
||||
_ = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ValueError("API profile base_url contains an invalid port.") from exc
|
||||
if auth != "none" and parsed.scheme != "https":
|
||||
raise ValueError("Authenticated API profiles must use HTTPS.")
|
||||
if parsed.scheme == "http" and auth == "none" and not _is_loopback_host(parsed.hostname) and not allow_insecure_http:
|
||||
raise ValueError(
|
||||
"Plain HTTP is allowed only for loopback endpoints by default. "
|
||||
"Set allow_insecure_http=true only for administrator-approved cleartext LAN/internal traffic."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_model_identifier(value: str, field: str = "model_name") -> str:
|
||||
model = str(value or "").strip()
|
||||
if not model:
|
||||
raise ValueError(f"{field} must not be empty.")
|
||||
if len(model) > MAX_MODEL_NAME_CHARS:
|
||||
raise ValueError(f"{field} is too long (maximum {MAX_MODEL_NAME_CHARS} characters).")
|
||||
if _has_control_chars(model):
|
||||
raise ValueError(f"{field} must not contain control characters.")
|
||||
return model
|
||||
|
||||
|
||||
def _normalize_profile(name: str, raw: dict) -> dict:
|
||||
profile_name = _safe_label(name, "API profile name")
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"API profile {profile_name!r} must be a JSON object.")
|
||||
forbidden = sorted({"api_key", "token", "bearer_token", "authorization", "headers", "secret"} & set(raw))
|
||||
if forbidden:
|
||||
raise ValueError(f"API profile {profile_name!r} contains forbidden secret/header fields: {', '.join(forbidden)}.")
|
||||
if "allow_extra_body" in raw:
|
||||
raise ValueError(f"API profile {profile_name!r} uses deprecated allow_extra_body; use allowed_extra_body_fields.")
|
||||
unknown = sorted(set(raw) - _PROFILE_ALLOWED_FIELDS)
|
||||
if unknown:
|
||||
raise ValueError(f"API profile {profile_name!r} contains unknown fields: {', '.join(unknown)}")
|
||||
|
||||
provider = _safe_label(raw.get("provider") or profile_name, "provider")
|
||||
auth = str(raw.get("auth") or "bearer_env").strip().lower()
|
||||
if auth not in {"bearer_env", "none"}:
|
||||
raise ValueError(f"API profile {profile_name!r} auth must be 'bearer_env' or 'none'.")
|
||||
allow_insecure_http = _strict_bool(raw, "allow_insecure_http", False)
|
||||
base_url = _validate_base_url(raw.get("base_url", ""), auth, allow_insecure_http)
|
||||
|
||||
api_key_env = str(raw.get("api_key_env") or "").strip()
|
||||
if auth == "bearer_env":
|
||||
if not _ENV_NAME_RE.fullmatch(api_key_env):
|
||||
raise ValueError(f"API profile {profile_name!r} requires a valid environment-variable name in api_key_env.")
|
||||
elif api_key_env:
|
||||
raise ValueError(f"API profile {profile_name!r} must not set api_key_env when auth='none'.")
|
||||
|
||||
raw_models = raw.get("allowed_models")
|
||||
allowed_models = None
|
||||
if raw_models is not None:
|
||||
if not isinstance(raw_models, list) or not all(isinstance(item, str) for item in raw_models):
|
||||
raise ValueError(f"API profile {profile_name!r} allowed_models must be a list of strings.")
|
||||
allowed_models = [_validate_model_identifier(item, "allowed model") for item in raw_models]
|
||||
|
||||
raw_extra = raw.get("allowed_extra_body_fields") or []
|
||||
if not isinstance(raw_extra, list) or not all(isinstance(item, str) for item in raw_extra):
|
||||
raise ValueError(f"API profile {profile_name!r} allowed_extra_body_fields must be a list of strings.")
|
||||
allowed_extra = []
|
||||
for item in raw_extra:
|
||||
field = item.strip()
|
||||
if not field or len(field) > 128 or _has_control_chars(field):
|
||||
raise ValueError(f"API profile {profile_name!r} contains an invalid extra-body field name.")
|
||||
if field in _RESERVED_EXTRA_BODY_FIELDS:
|
||||
raise ValueError(f"API profile {profile_name!r} cannot allow protected extra-body field {field!r}.")
|
||||
if field not in allowed_extra:
|
||||
allowed_extra.append(field)
|
||||
|
||||
thinking_mode = str(raw.get("thinking_mode") or "").strip().lower()
|
||||
if thinking_mode not in {"", "qwen"}:
|
||||
raise ValueError(f"API profile {profile_name!r} has unsupported thinking_mode {thinking_mode!r}.")
|
||||
max_tokens_field = str(raw.get("max_tokens_field") or "max_tokens").strip()
|
||||
if max_tokens_field not in {"max_tokens", "max_completion_tokens"}:
|
||||
raise ValueError(f"API profile {profile_name!r} has invalid max_tokens_field.")
|
||||
|
||||
allow_images = _strict_bool(raw, "allow_images", False)
|
||||
allow_image_url = _strict_bool(raw, "allow_image_url", False)
|
||||
if allow_image_url and not allow_images:
|
||||
raise ValueError(f"API profile {profile_name!r} cannot enable allow_image_url while allow_images is false.")
|
||||
|
||||
return {
|
||||
"name": profile_name,
|
||||
"provider": provider,
|
||||
"base_url": base_url,
|
||||
"auth": auth,
|
||||
"api_key_env": api_key_env,
|
||||
"allowed_models": allowed_models,
|
||||
"max_tokens_limit": _profile_int(raw, "max_tokens_limit", None, maximum=65_536),
|
||||
"max_input_chars": _profile_int(raw, "max_input_chars", DEFAULT_MAX_INPUT_CHARS, maximum=20_000_000),
|
||||
"max_timeout_seconds": _profile_int(raw, "max_timeout_seconds", DEFAULT_MAX_TIMEOUT_SECONDS, maximum=3_600),
|
||||
"max_thinking_budget": _profile_int(raw, "max_thinking_budget", 262_144, minimum=0, maximum=262_144),
|
||||
"allowed_extra_body_fields": allowed_extra,
|
||||
"thinking_mode": thinking_mode,
|
||||
"send_seed": _strict_bool(raw, "send_seed", False),
|
||||
"max_tokens_field": max_tokens_field,
|
||||
"allow_insecure_http": allow_insecure_http,
|
||||
"allow_images": allow_images,
|
||||
"allow_image_url": allow_image_url,
|
||||
"max_image_pixels": _profile_int(raw, "max_image_pixels", DEFAULT_MAX_IMAGE_PIXELS, maximum=100_000_000),
|
||||
"max_image_bytes": _profile_int(raw, "max_image_bytes", DEFAULT_MAX_IMAGE_BYTES, maximum=100_000_000),
|
||||
}
|
||||
|
||||
|
||||
def _load_api_profiles() -> dict[str, dict]:
|
||||
profiles = {}
|
||||
if os.environ.get(DISABLE_BUILTINS_ENV, "").strip() != "1":
|
||||
for name, raw in _BUILTIN_PROFILES.items():
|
||||
profiles[name] = _normalize_profile(name, raw)
|
||||
path = _profile_file_path()
|
||||
if not path.exists():
|
||||
return profiles
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[ThinkingLLM API] Failed to load API profiles from {path}: {exc}")
|
||||
return profiles
|
||||
custom = payload.get("profiles", payload) if isinstance(payload, dict) else {}
|
||||
if not isinstance(custom, dict):
|
||||
print(f"[ThinkingLLM API] Ignoring invalid API profile file {path}: expected object.")
|
||||
return profiles
|
||||
for name, raw in custom.items():
|
||||
profile_name = str(name or "").strip()
|
||||
if not profile_name:
|
||||
continue
|
||||
profiles.pop(profile_name, None)
|
||||
try:
|
||||
profiles[profile_name] = _normalize_profile(profile_name, raw)
|
||||
except ValueError as exc:
|
||||
print(f"[ThinkingLLM API] Ignoring invalid profile {profile_name!r}: {exc}")
|
||||
return profiles
|
||||
|
||||
|
||||
def _resolve_profile(profile_name: str) -> dict:
|
||||
name = str(profile_name or "").strip()
|
||||
profile = _load_api_profiles().get(name)
|
||||
if profile is None:
|
||||
print(f"[ThinkingLLM API] Unknown API profile {name!r}; profile file is {_profile_file_path()}.")
|
||||
raise ValueError("Unknown or unavailable server-side API profile.")
|
||||
return profile
|
||||
|
||||
|
||||
def _load_model_catalog() -> dict:
|
||||
"""Return the curated convenience model catalog (UX only, never an allowlist).
|
||||
|
||||
Read-only and failure-tolerant: a missing or broken catalog must never
|
||||
block the node. The catalog only feeds the front-end dropdown; request
|
||||
security is enforced by the profile's allowed_models, not by this file.
|
||||
"""
|
||||
global _CATALOG_CACHE
|
||||
if _CATALOG_CACHE is not None:
|
||||
return _CATALOG_CACHE
|
||||
try:
|
||||
with CATALOG_PATH.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[ThinkingLLM API] Failed to load model catalog from {CATALOG_PATH}: {exc}")
|
||||
_CATALOG_CACHE = {}
|
||||
return _CATALOG_CACHE
|
||||
profiles = payload.get("profiles") if isinstance(payload, dict) else None
|
||||
_CATALOG_CACHE = profiles if isinstance(profiles, dict) else {}
|
||||
return _CATALOG_CACHE
|
||||
|
||||
|
||||
def _catalog_models_for_profile(profile_name: str) -> list[str]:
|
||||
"""Convenience model IDs for a profile name, used by the node UI only."""
|
||||
entry = _load_model_catalog().get(str(profile_name or ""))
|
||||
if not isinstance(entry, dict):
|
||||
return []
|
||||
models = entry.get("models")
|
||||
if not isinstance(models, list):
|
||||
return []
|
||||
ids: list[str] = []
|
||||
for item in models:
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
||||
ids.append(item["id"])
|
||||
return ids
|
||||
|
||||
|
||||
def _catalog_all_model_ids() -> list[str]:
|
||||
"""Union of every curated model ID across all profiles (deduplicated).
|
||||
|
||||
Used as the backend combo list so that any saved model_name from any
|
||||
profile still passes ComfyUI's front-end validation ("Value not in list").
|
||||
The front-end narrows the visible dropdown to the selected profile.
|
||||
"""
|
||||
seen: dict[str, None] = {}
|
||||
for entry in _load_model_catalog().values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
models = entry.get("models")
|
||||
if not isinstance(models, list):
|
||||
continue
|
||||
for item in models:
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
||||
seen.setdefault(item["id"], None)
|
||||
return list(seen)
|
||||
|
||||
|
||||
def _credential_value(profile: dict) -> str:
|
||||
if profile["auth"] == "none":
|
||||
return ""
|
||||
env_name = profile["api_key_env"]
|
||||
value = os.environ.get(env_name, "").strip()
|
||||
if not value:
|
||||
print(f"[ThinkingLLM API] Profile {profile['name']!r} requires environment variable {env_name!r}, but it is not set.")
|
||||
raise RuntimeError("API credential is not configured for the selected server-side profile.")
|
||||
return value
|
||||
|
||||
|
||||
def _authorization_headers(profile: dict) -> dict[str, str]:
|
||||
value = _credential_value(profile)
|
||||
return {"Authorization": f"Bearer {value}"} if value else {}
|
||||
|
||||
|
||||
def _reject_json_constant(value: str):
|
||||
raise ValueError(f"Non-finite JSON constant {value!r} is not allowed.")
|
||||
|
||||
|
||||
def _parse_extra_json(extra_body_json: str, allowed_fields) -> dict:
|
||||
raw = str(extra_body_json or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
if len(raw) > MAX_EXTRA_BODY_JSON_CHARS:
|
||||
raise ValueError(f"extra_body_json exceeds the {MAX_EXTRA_BODY_JSON_CHARS}-character safety limit.")
|
||||
allowed = set(allowed_fields or [])
|
||||
if not allowed:
|
||||
raise ValueError("extra_body_json is disabled by the selected server-side API profile.")
|
||||
try:
|
||||
value = json.loads(raw, parse_constant=_reject_json_constant)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"extra_body_json is not valid JSON: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("extra_body_json must contain a JSON object.")
|
||||
reserved = sorted(_RESERVED_EXTRA_BODY_FIELDS & set(value))
|
||||
if reserved:
|
||||
raise ValueError(f"extra_body_json cannot override protected request fields: {', '.join(reserved)}")
|
||||
unapproved = sorted(set(value) - allowed)
|
||||
if unapproved:
|
||||
raise ValueError(f"extra_body_json contains fields not allowed by the server-side profile: {', '.join(unapproved)}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_model(profile: dict, model_name: str) -> str:
|
||||
model = _validate_model_identifier(model_name)
|
||||
if profile.get("allowed_models") is not None and model not in profile["allowed_models"]:
|
||||
raise ValueError(f"Model {model!r} is not allowed by API profile {profile['name']!r}.")
|
||||
return model
|
||||
|
||||
|
||||
def _validate_max_tokens(profile: dict, value) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 65_536:
|
||||
raise ValueError("max_tokens must be an integer between 1 and 65536.")
|
||||
limit = profile.get("max_tokens_limit")
|
||||
if limit is not None and value > limit:
|
||||
raise ValueError(f"Requested max_tokens={value} exceeds server-side limit {limit} for API profile {profile['name']!r}.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_timeout(profile: dict, value) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 3_600:
|
||||
raise ValueError("timeout_seconds must be an integer between 1 and 3600.")
|
||||
limit = profile.get("max_timeout_seconds", DEFAULT_MAX_TIMEOUT_SECONDS)
|
||||
if value > limit:
|
||||
raise ValueError(f"Requested timeout_seconds={value} exceeds server-side limit {limit} for API profile {profile['name']!r}.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_sampling(temperature, top_p, seed, thinking_budget):
|
||||
if isinstance(temperature, bool) or not isinstance(temperature, (int, float)):
|
||||
raise ValueError("temperature must be a numeric value.")
|
||||
if isinstance(top_p, bool) or not isinstance(top_p, (int, float)):
|
||||
raise ValueError("top_p must be a numeric value.")
|
||||
t, p = float(temperature), float(top_p)
|
||||
if not math.isfinite(t) or not 0.0 <= t <= 2.0:
|
||||
raise ValueError("temperature must be a finite value between 0.0 and 2.0.")
|
||||
if not math.isfinite(p) or not 0.0 <= p <= 1.0:
|
||||
raise ValueError("top_p must be a finite value between 0.0 and 1.0.")
|
||||
if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= 2**32 - 1:
|
||||
raise ValueError("seed must be an integer in the supported range.")
|
||||
if isinstance(thinking_budget, bool) or not isinstance(thinking_budget, int) or not 0 <= thinking_budget <= 262_144:
|
||||
raise ValueError("thinking_budget must be an integer between 0 and 262144.")
|
||||
return t, p, seed, thinking_budget
|
||||
|
||||
|
||||
def _validate_thinking_budget(profile: dict, value: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 262_144:
|
||||
raise ValueError("thinking_budget must be an integer between 0 and 262144.")
|
||||
limit = profile.get("max_thinking_budget", 262_144)
|
||||
if value > limit:
|
||||
raise ValueError(
|
||||
f"Requested thinking_budget={value} exceeds server-side limit {limit} "
|
||||
f"for API profile {profile['name']!r}."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_input_text(profile: dict, system_prompt, prompt) -> tuple[str, str]:
|
||||
if system_prompt is not None and not isinstance(system_prompt, str):
|
||||
raise ValueError("system_prompt must be a string.")
|
||||
if prompt is not None and not isinstance(prompt, str):
|
||||
raise ValueError("prompt must be a string.")
|
||||
system_text, prompt_text = system_prompt or "", prompt or ""
|
||||
total = len(system_text) + len(prompt_text)
|
||||
limit = profile.get("max_input_chars", DEFAULT_MAX_INPUT_CHARS)
|
||||
if total > limit:
|
||||
raise ValueError(f"Combined system_prompt + prompt length ({total}) exceeds server-side max_input_chars={limit} for API profile {profile['name']!r}.")
|
||||
return system_text, prompt_text
|
||||
|
||||
|
||||
def _validate_image_url(profile: dict, image_url) -> str:
|
||||
value = str(image_url or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
if not profile.get("allow_images", False):
|
||||
raise ValueError(f"Image input is disabled by API profile {profile['name']!r}.")
|
||||
if not profile.get("allow_image_url", False):
|
||||
raise ValueError(f"image_url input is disabled by API profile {profile['name']!r}.")
|
||||
if len(value) > MAX_IMAGE_URL_CHARS or _has_control_chars(value):
|
||||
raise ValueError("image_url is too long or contains control characters.")
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
if parsed.scheme != "https" or not parsed.netloc or not parsed.hostname:
|
||||
raise ValueError("image_url must be a public HTTPS URL.")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("image_url must not contain embedded credentials.")
|
||||
if parsed.fragment:
|
||||
raise ValueError("image_url must not contain a URL fragment.")
|
||||
return value
|
||||
|
||||
|
||||
def _image_to_data_url(profile: dict, image) -> str:
|
||||
if image is None:
|
||||
return ""
|
||||
if not profile.get("allow_images", False):
|
||||
raise ValueError(f"Image input is disabled by API profile {profile['name']!r}.")
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Image input requires NumPy and Pillow in the ComfyUI Python environment.") from exc
|
||||
|
||||
shape = tuple(int(part) for part in getattr(image, "shape", ()))
|
||||
if len(shape) == 4:
|
||||
if shape[0] != 1:
|
||||
raise ValueError("The remote API node currently accepts exactly one IMAGE, not an image batch.")
|
||||
height, width, channels = shape[1], shape[2], shape[3]
|
||||
frame = image[0]
|
||||
elif len(shape) == 3:
|
||||
height, width, channels = shape
|
||||
frame = image
|
||||
else:
|
||||
raise ValueError("IMAGE input must have shape [B,H,W,C] or [H,W,C].")
|
||||
|
||||
if height < 1 or width < 1 or channels not in {1, 3, 4}:
|
||||
raise ValueError("IMAGE input has unsupported dimensions or channel count.")
|
||||
pixels = height * width
|
||||
pixel_limit = profile.get("max_image_pixels", DEFAULT_MAX_IMAGE_PIXELS)
|
||||
if pixels > pixel_limit:
|
||||
raise ValueError(
|
||||
f"IMAGE input has {pixels} pixels, exceeding server-side max_image_pixels={pixel_limit} "
|
||||
f"for API profile {profile['name']!r}."
|
||||
)
|
||||
|
||||
if hasattr(frame, "detach"):
|
||||
frame = frame.detach()
|
||||
if hasattr(frame, "cpu"):
|
||||
frame = frame.cpu()
|
||||
if hasattr(frame, "numpy"):
|
||||
frame = frame.numpy()
|
||||
array = np.asarray(frame, dtype=np.float32)
|
||||
if array.ndim != 3 or array.shape[2] not in {1, 3, 4}:
|
||||
raise ValueError("IMAGE input could not be converted to a supported HWC array.")
|
||||
array = np.nan_to_num(array, nan=0.0, posinf=1.0, neginf=0.0)
|
||||
array = np.rint(np.clip(array, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||
|
||||
if array.shape[2] == 1:
|
||||
array = array[:, :, 0]
|
||||
mode = "L"
|
||||
elif array.shape[2] == 4:
|
||||
mode = "RGBA"
|
||||
else:
|
||||
mode = "RGB"
|
||||
|
||||
buffer = io.BytesIO()
|
||||
Image.fromarray(array, mode=mode).save(buffer, format="PNG")
|
||||
encoded_bytes = buffer.getvalue()
|
||||
byte_limit = profile.get("max_image_bytes", DEFAULT_MAX_IMAGE_BYTES)
|
||||
if len(encoded_bytes) > byte_limit:
|
||||
raise ValueError(
|
||||
f"PNG-encoded IMAGE is {len(encoded_bytes)} bytes, exceeding server-side "
|
||||
f"max_image_bytes={byte_limit} for API profile {profile['name']!r}."
|
||||
)
|
||||
return "data:image/png;base64," + base64.b64encode(encoded_bytes).decode("ascii")
|
||||
|
||||
|
||||
def _build_user_content(profile: dict, prompt_text: str, image=None, image_url=""):
|
||||
url = str(image_url or "").strip()
|
||||
if image is not None and url:
|
||||
raise ValueError("Use either the IMAGE input or image_url, not both in the same request.")
|
||||
if image is None and not url:
|
||||
return prompt_text, False
|
||||
|
||||
if image is not None:
|
||||
source = _image_to_data_url(profile, image)
|
||||
else:
|
||||
source = _validate_image_url(profile, url)
|
||||
|
||||
return [
|
||||
{"type": "text", "text": prompt_text},
|
||||
{"type": "image_url", "image_url": {"url": source}},
|
||||
], True
|
||||
|
||||
|
||||
def _read_error_body(exc: urllib.error.HTTPError) -> str:
|
||||
try:
|
||||
data = exc.read()
|
||||
except Exception:
|
||||
return ""
|
||||
return data.decode("utf-8", errors="replace").strip() if isinstance(data, bytes) else str(data or "").strip()
|
||||
|
||||
|
||||
def _terminal_display_safe(text: str) -> str:
|
||||
return "".join(ch for ch in str(text or "") if ch in {"\n", "\t"} or (ord(ch) >= 0x20 and not 0x7F <= ord(ch) <= 0x9F))
|
||||
|
||||
|
||||
def _redact_for_log(text: str, profile: dict) -> str:
|
||||
value = _terminal_display_safe(text)
|
||||
if profile.get("auth") == "bearer_env":
|
||||
secret = os.environ.get(profile.get("api_key_env", ""), "")
|
||||
if secret:
|
||||
value = value.replace(secret, "<redacted>")
|
||||
return value[:4096]
|
||||
|
||||
|
||||
def _reasoning_details_text(container) -> str:
|
||||
if not isinstance(container, dict) or not isinstance(container.get("reasoning_details"), list):
|
||||
return ""
|
||||
parts = []
|
||||
for item in container["reasoning_details"]:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
for key in ("text", "summary", "content", "reasoning"):
|
||||
if isinstance(item.get(key), str) and item[key]:
|
||||
parts.append(item[key])
|
||||
break
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _extract_stream_token_extended(chunk) -> dict[str, str]:
|
||||
token = extract_stream_token(chunk)
|
||||
if token.get("reasoning") or not isinstance(chunk, dict):
|
||||
return token
|
||||
choices = chunk.get("choices") or []
|
||||
choice = choices[0] if isinstance(choices, list) and choices else {}
|
||||
delta = choice.get("delta") if isinstance(choice, dict) else {}
|
||||
message = choice.get("message") if isinstance(choice, dict) else {}
|
||||
for container in (delta, message, choice, chunk):
|
||||
reasoning = _reasoning_details_text(container)
|
||||
if reasoning:
|
||||
return {"reasoning": reasoning, "content": token.get("content", "")}
|
||||
return token
|
||||
|
||||
|
||||
def _provider_error_from_payload(payload) -> str:
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
error = payload.get("error")
|
||||
if error:
|
||||
if isinstance(error, dict):
|
||||
code = error.get("code")
|
||||
message = error.get("message") or error.get("type") or "provider stream error"
|
||||
return f"{code}: {message}" if code is not None else str(message)
|
||||
return str(error)
|
||||
choices = payload.get("choices") or []
|
||||
if isinstance(choices, list) and choices and isinstance(choices[0], dict) and choices[0].get("finish_reason") == "error":
|
||||
return "provider reported finish_reason=error"
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_nonstream_response(payload: dict) -> tuple[str, str]:
|
||||
error = _provider_error_from_payload(payload)
|
||||
if error:
|
||||
raise _ProviderStreamError(error)
|
||||
choices = payload.get("choices") or []
|
||||
choice = choices[0] if choices else {}
|
||||
message = choice.get("message") or {}
|
||||
content = message.get("content") or choice.get("text") or ""
|
||||
reasoning = (
|
||||
message.get("reasoning_content") or message.get("reasoning") or message.get("thinking")
|
||||
or choice.get("reasoning_content") or _reasoning_details_text(message)
|
||||
or _reasoning_details_text(choice) or ""
|
||||
)
|
||||
return str(content or ""), str(reasoning or "")
|
||||
|
||||
|
||||
def _open_request(req: urllib.request.Request, timeout_seconds: int):
|
||||
return _NO_REDIRECT_OPENER.open(req, timeout=timeout_seconds)
|
||||
|
||||
|
||||
def _image_rejection_message(profile: dict, model: str, status=None) -> str:
|
||||
status_text = f" (HTTP {status})" if status is not None else ""
|
||||
return (
|
||||
f"Image input was rejected by API profile {profile['name']!r} / model {model!r}{status_text}. "
|
||||
"Use a vision-capable model supported by that provider, or remove the IMAGE/image_url input."
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_image_error(detail: str) -> bool:
|
||||
return bool(_IMAGE_ERROR_RE.search(str(detail or "")))
|
||||
|
||||
|
||||
class ThinkingLLMOpenAICompatibleAPI:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
profiles = list(_load_api_profiles()) or ["No API profiles configured"]
|
||||
default_profile = "OpenRouter" if "OpenRouter" in profiles else profiles[0]
|
||||
default_models = _catalog_models_for_profile(default_profile)
|
||||
if not default_models:
|
||||
default_models = ["provider/model-id"]
|
||||
# Use the union of all profile models so any saved model_name from any
|
||||
# profile passes ComfyUI's front-end combo validation. The JS narrows
|
||||
# the visible dropdown to the selected api_profile.
|
||||
all_models = _catalog_all_model_ids()
|
||||
if not all_models:
|
||||
all_models = default_models
|
||||
return {
|
||||
"required": {
|
||||
"api_profile": (profiles, {"default": default_profile, "tooltip": "Server-side profile binding endpoint, authentication, credential reference, capabilities, and safety limits."}),
|
||||
"model_name": (all_models, {"default": default_models[0], "tooltip": "Curated model for the selected profile. For anything not listed, leave this as-is and type the ID in custom_model_name below."}),
|
||||
"system_prompt": ("STRING", {"default": "You are a helpful assistant.", "multiline": True}),
|
||||
"prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"max_tokens": ("INT", {"default": 8192, "min": 1, "max": 65536}),
|
||||
"temperature": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.05}),
|
||||
"top_p": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"seed": ("INT", {"default": 1, "min": 0, "max": 2**32 - 1, "tooltip": "Sent only when the server profile enables send_seed."}),
|
||||
"enable_thinking": ("BOOLEAN", {"default": True, "tooltip": "Used by profiles with thinking_mode='qwen'."}),
|
||||
"thinking_budget": ("INT", {"default": 8192, "min": 0, "max": 262144}),
|
||||
"stream_tokens_to_terminal": ("BOOLEAN", {"default": False, "tooltip": "Print a control-character-sanitized display copy to the ComfyUI terminal."}),
|
||||
"timeout_seconds": ("INT", {"default": 300, "min": 1, "max": 3600, "tooltip": "Idle timeout: aborts only if the provider sends nothing for this many seconds. Long reasoning streams are allowed to continue. Server profile enforces max_timeout_seconds (300 by default; OrcaRouter 900)."}),
|
||||
},
|
||||
"optional": {
|
||||
"custom_model_name": ("STRING", {"default": "", "multiline": False, "tooltip": "Optional: exact model ID for anything not in the curated list. When set, it overrides model_name."}),
|
||||
"image": ("IMAGE", {"tooltip": "Single ComfyUI IMAGE. Encoded as PNG Base64 data URL and sent as OpenAI-style image_url content."}),
|
||||
"image_url": ("STRING", {"default": "", "multiline": False, "tooltip": "Alternative to IMAGE: public HTTPS image URL. Do not connect IMAGE at the same time."}),
|
||||
"extra_body_json": ("STRING", {"default": "", "multiline": True, "tooltip": "Every top-level field must be explicitly allowlisted by the server profile."}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("RESPONSE", "RAW_TRACE")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "ThinkingLLM/API"
|
||||
DESCRIPTION = "Calls a server-approved OpenAI-compatible /chat/completions profile with optional image input; credentials and endpoint configuration stay server-side."
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, *args, **kwargs):
|
||||
return float("NaN")
|
||||
|
||||
def generate(
|
||||
self, api_profile, model_name, system_prompt, prompt, max_tokens, temperature, top_p,
|
||||
seed, enable_thinking, thinking_budget, stream_tokens_to_terminal, timeout_seconds,
|
||||
custom_model_name="", image=None, image_url="", extra_body_json="",
|
||||
):
|
||||
profile = _resolve_profile(api_profile)
|
||||
if not isinstance(enable_thinking, bool) or not isinstance(stream_tokens_to_terminal, bool):
|
||||
raise ValueError("Boolean node inputs must be booleans.")
|
||||
# A custom model ID overrides the curated dropdown selection.
|
||||
if str(custom_model_name or "").strip():
|
||||
model_name = custom_model_name
|
||||
model = _validate_model(profile, model_name)
|
||||
max_out = _validate_max_tokens(profile, max_tokens)
|
||||
timeout = _validate_timeout(profile, timeout_seconds)
|
||||
temperature, top_p, seed, thinking_budget = _validate_sampling(temperature, top_p, seed, thinking_budget)
|
||||
thinking_budget = _validate_thinking_budget(profile, thinking_budget)
|
||||
system_text, prompt_text = _validate_input_text(profile, system_prompt, prompt)
|
||||
user_content, image_supplied = _build_user_content(profile, prompt_text, image=image, image_url=image_url)
|
||||
|
||||
messages = []
|
||||
if system_text.strip():
|
||||
messages.append({"role": "system", "content": system_text})
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
request_body = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
profile.get("max_tokens_field", "max_tokens"): max_out,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
"stream": True,
|
||||
}
|
||||
if profile.get("send_seed"):
|
||||
request_body["seed"] = seed
|
||||
if profile.get("thinking_mode") == "qwen":
|
||||
request_body["enable_thinking"] = enable_thinking
|
||||
if thinking_budget > 0:
|
||||
request_body["thinking_budget"] = thinking_budget
|
||||
request_body.update(_parse_extra_json(extra_body_json, profile.get("allowed_extra_body_fields", [])))
|
||||
request_body["stream"] = True
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
"User-Agent": "ComfyUI-ThinkingLLM/OpenAI-Compatible-API",
|
||||
}
|
||||
headers.update(_authorization_headers(profile))
|
||||
try:
|
||||
payload = json.dumps(request_body, ensure_ascii=False, allow_nan=False).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Unable to serialize the API request safely.") from exc
|
||||
req = urllib.request.Request(profile["base_url"] + "/chat/completions", data=payload, headers=headers, method="POST")
|
||||
|
||||
reasoning_parts, content_parts = [], []
|
||||
display = get_thinking_stream_display() if stream_tokens_to_terminal else None
|
||||
if display is not None:
|
||||
display.start_stage(_terminal_display_safe(f"API {profile['provider']} / {model}"))
|
||||
# Idle timeout: abort only when the provider sends nothing for `timeout`
|
||||
# seconds. A long reasoning stream that keeps emitting tokens must be
|
||||
# allowed to run — reasoning-heavy models (e.g. obsidian/* uncensored)
|
||||
# routinely stream for longer than the configured timeout in total.
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
with _open_request(req, timeout) as response:
|
||||
if "text/event-stream" not in str(response.headers.get("Content-Type", "")).lower():
|
||||
raw = response.read().decode("utf-8", errors="replace")
|
||||
content, reasoning = _extract_nonstream_response(json.loads(raw, parse_constant=_reject_json_constant))
|
||||
content_parts.append(content)
|
||||
reasoning_parts.append(reasoning)
|
||||
if display is not None:
|
||||
display.push_compact(_terminal_display_safe(reasoning or content))
|
||||
else:
|
||||
for raw_line in response:
|
||||
# Idle detection: reset the deadline whenever any line arrives.
|
||||
deadline = time.monotonic() + timeout
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line or line.startswith(":"):
|
||||
# Heartbeat/keep-alive lines also count as activity.
|
||||
deadline = time.monotonic() + timeout
|
||||
continue
|
||||
line = line[5:].strip() if line.startswith("data:") else line
|
||||
if line == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(line, parse_constant=_reject_json_constant)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
error = _provider_error_from_payload(chunk)
|
||||
if error:
|
||||
raise _ProviderStreamError(error)
|
||||
token = _extract_stream_token_extended(chunk)
|
||||
reasoning, content = token.get("reasoning", ""), token.get("content", "")
|
||||
if reasoning:
|
||||
reasoning_parts.append(reasoning)
|
||||
if display is not None:
|
||||
display.push_compact(_terminal_display_safe(reasoning))
|
||||
if content:
|
||||
content_parts.append(content)
|
||||
if display is not None:
|
||||
display.push_compact(_terminal_display_safe(content))
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError("provider stream idle timeout: no data received within the configured request deadline")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = f"HTTP {exc.code} {exc.reason}"
|
||||
body = _redact_for_log(_read_error_body(exc), profile)
|
||||
if body:
|
||||
detail += f": {body}"
|
||||
print(f"[ThinkingLLM API] Request failed for profile {profile['name']!r}: {detail}")
|
||||
if 300 <= int(exc.code) < 400:
|
||||
raise RuntimeError(f"ThinkingLLM API redirect rejected for profile {profile['name']!r}.") from exc
|
||||
if image_supplied and (int(exc.code) in {400, 415, 422} or _looks_like_image_error(body)):
|
||||
raise RuntimeError(_image_rejection_message(profile, model, exc.code)) from exc
|
||||
raise RuntimeError(f"ThinkingLLM API request failed for profile {profile['name']!r} with HTTP status {exc.code}.") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"[ThinkingLLM API] Network error for profile {profile['name']!r}: {_redact_for_log(str(exc.reason), profile)}")
|
||||
raise RuntimeError(f"ThinkingLLM API network request failed for profile {profile['name']!r}.") from exc
|
||||
except (TimeoutError, _ProviderStreamError) as exc:
|
||||
print(f"[ThinkingLLM API] Provider stream failed for profile {profile['name']!r}: {_redact_for_log(str(exc), profile)}")
|
||||
if image_supplied and _looks_like_image_error(str(exc)):
|
||||
raise RuntimeError(_image_rejection_message(profile, model)) from exc
|
||||
raise RuntimeError(f"ThinkingLLM API stream failed for profile {profile['name']!r}.") from exc
|
||||
finally:
|
||||
if display is not None:
|
||||
display.end_stage()
|
||||
|
||||
content_text = "".join(content_parts).strip()
|
||||
reasoning_text = "".join(reasoning_parts).strip()
|
||||
raw_trace = compose_streamed_model_output(reasoning_text, content_text)
|
||||
response_text = clean_model_output(
|
||||
content_text,
|
||||
OutputCleanConfig(
|
||||
mode="text", strip_think=True, strip_code_fences=False, strip_role_prefixes=False,
|
||||
strip_json_wrappers=False, strip_leading_preamble=False, strip_planning=False,
|
||||
),
|
||||
)
|
||||
return (response_text, raw_trace)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"ThinkingLLM_OpenAICompatibleAPI": ThinkingLLMOpenAICompatibleAPI}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"ThinkingLLM_OpenAICompatibleAPI": "ThinkingLLM API (OpenAI Compatible)"}
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Standalone system-prompt preset selector for ThinkingLLM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SYSTEM_PROMPTS_PATH = Path(__file__).resolve().parent.parent / "AILab_System_Prompts.json"
|
||||
NO_PRESET_PROMPT = "🚫 No preset (image-only)"
|
||||
|
||||
|
||||
def _load_system_prompt_presets() -> tuple[list[str], dict[str, str]]:
|
||||
"""Load the same preset order and prompt strings used by ThinkingLLM."""
|
||||
try:
|
||||
with SYSTEM_PROMPTS_PATH.open("r", encoding="utf-8") as file:
|
||||
data = json.load(file) or {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[ThinkingLLM System Prompt Preset] Failed to load presets: {exc}")
|
||||
return [NO_PRESET_PROMPT], {NO_PRESET_PROMPT: ""}
|
||||
|
||||
raw_prompts = data.get("qwenvl") or {}
|
||||
system_prompts = raw_prompts if isinstance(raw_prompts, dict) else {}
|
||||
system_prompts = {
|
||||
str(name): str(prompt)
|
||||
for name, prompt in system_prompts.items()
|
||||
if isinstance(prompt, str)
|
||||
}
|
||||
system_prompts.setdefault(NO_PRESET_PROMPT, "")
|
||||
|
||||
raw_presets = data.get("_preset_prompts") or []
|
||||
preset_prompts = [
|
||||
name
|
||||
for name in raw_presets
|
||||
if isinstance(name, str) and name in system_prompts
|
||||
]
|
||||
if not preset_prompts:
|
||||
preset_prompts = list(system_prompts)
|
||||
if NO_PRESET_PROMPT not in preset_prompts:
|
||||
preset_prompts.insert(0, NO_PRESET_PROMPT)
|
||||
|
||||
return preset_prompts, system_prompts
|
||||
|
||||
|
||||
class ThinkingLLMSystemPromptPreset:
|
||||
"""Select a ThinkingLLM preset and return its full system prompt."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
preset_prompts, _ = _load_system_prompt_presets()
|
||||
return {
|
||||
"required": {
|
||||
"preset_prompt": (
|
||||
preset_prompts,
|
||||
{
|
||||
"default": preset_prompts[0],
|
||||
"tooltip": "Select a ThinkingLLM system-prompt preset.",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("system_prompt",)
|
||||
OUTPUT_TOOLTIPS = ("The full system prompt text for the selected preset.",)
|
||||
FUNCTION = "get_system_prompt"
|
||||
CATEGORY = "ThinkingLLM/Utils"
|
||||
DESCRIPTION = "Outputs the full system prompt string for a ThinkingLLM preset."
|
||||
|
||||
def get_system_prompt(self, preset_prompt: str):
|
||||
_, system_prompts = _load_system_prompt_presets()
|
||||
return (system_prompts.get(preset_prompt, ""),)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ThinkingLLM_SystemPromptPreset": ThinkingLLMSystemPromptPreset,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ThinkingLLM_SystemPromptPreset": "ThinkingLLM System Prompt Preset",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ComfyUI-ThinkingLLM"
|
||||
description = "A multimodal ComfyUI AI node with Qwen3.5, Qwen3-VL, Qwen2.5-VL, Qwen3, and Gemma 4 integrations. Features live thinking in the terminal to see what the LLM is doing in real time."
|
||||
version = "2.3.7"
|
||||
version = "2.5.8"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["transformers>=5.2.0", "torch", "huggingface-hub", "hf_xet", "psutil", "numpy", "Pillow", "opencv-python", "bitsandbytes", "accelerate", "sentencepiece", "tiktoken", "faster-whisper", "llama-cpp-python"]
|
||||
|
||||
|
||||
+94
-109
@@ -1,144 +1,129 @@
|
||||
"""
|
||||
SageAttention patching functions for QwenVL models.
|
||||
This module contains the safe SageAttention implementation with proper error handling.
|
||||
"""
|
||||
"""Model-local SageAttention patching for supported Qwen attention layers."""
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def set_sage_attention(model):
|
||||
"""
|
||||
Apply SageAttention patching to the model.
|
||||
Patches Qwen2Attention and Qwen3VLTextAttention modules to use SageAttention kernels.
|
||||
"""
|
||||
from AILab_QwenVL import sage_attn_available, get_sage_attention_config
|
||||
|
||||
if not sage_attn_available():
|
||||
raise ImportError("SageAttention library is not installed or GPU doesn't support it.")
|
||||
"""Patch supported Qwen attention layers, falling back per call when needed."""
|
||||
from thinkingllm_core.hf_models import get_sage_attention_config
|
||||
|
||||
SAGE_ATTN_FUNC, QK_QUANT_GRAN, PV_ACCUM_DTYPE = get_sage_attention_config()
|
||||
if SAGE_ATTN_FUNC is None:
|
||||
raise RuntimeError("No compatible SageAttention kernel found for this GPU.")
|
||||
attn_func, qk_quant_gran, pv_accum_dtype = get_sage_attention_config()
|
||||
if attn_func is None:
|
||||
raise RuntimeError("No compatible SageAttention kernel found for this GPU")
|
||||
|
||||
# Try to import different attention classes for different Qwen models
|
||||
attention_classes = []
|
||||
|
||||
# Qwen2 models
|
||||
try:
|
||||
from transformers.models.qwen2.modeling_qwen2 import Qwen2Attention, apply_rotary_pos_emb as qwen2_apply_rotary
|
||||
attention_classes.append((Qwen2Attention, qwen2_apply_rotary))
|
||||
from transformers.models.qwen2.modeling_qwen2 import Qwen2Attention, apply_rotary_pos_emb
|
||||
attention_classes.append((Qwen2Attention, apply_rotary_pos_emb))
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from transformers.models.qwen3.modeling_qwen3 import Qwen3Attention, apply_rotary_pos_emb
|
||||
attention_classes.append((Qwen3Attention, apply_rotary_pos_emb))
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLTextAttention, apply_rotary_pos_emb
|
||||
attention_classes.append((Qwen3VLTextAttention, apply_rotary_pos_emb))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Qwen3 models (Qwen3-VL, etc.)
|
||||
try:
|
||||
from transformers.models.qwen3.modeling_qwen3 import Qwen3Attention, apply_rotary_pos_emb as qwen3_apply_rotary
|
||||
attention_classes.append((Qwen3Attention, qwen3_apply_rotary))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Qwen3-VL specific
|
||||
try:
|
||||
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLTextAttention, apply_rotary_pos_emb as qwen3vl_apply_rotary
|
||||
attention_classes.append((Qwen3VLTextAttention, qwen3vl_apply_rotary))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not attention_classes:
|
||||
print("[QwenVL] Could not import any attention classes for SageAttention patching")
|
||||
return
|
||||
|
||||
def make_sage_forward(AttentionClass, apply_rotary_pos_emb_func):
|
||||
def sage_attention_forward(
|
||||
def make_sage_forward(original_forward, apply_rotary):
|
||||
def sage_forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
position_embeddings: tuple = None,
|
||||
attention_mask: torch.Tensor = None,
|
||||
past_key_values=None,
|
||||
cache_position: torch.LongTensor = None,
|
||||
position_ids: torch.LongTensor = None,
|
||||
**kwargs,
|
||||
):
|
||||
original_dtype = hidden_states.dtype
|
||||
def fallback():
|
||||
return original_forward(
|
||||
hidden_states,
|
||||
position_embeddings=position_embeddings,
|
||||
attention_mask=attention_mask,
|
||||
past_key_values=past_key_values,
|
||||
cache_position=cache_position,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Determine target dtype
|
||||
is_4bit = hasattr(self.q_proj, 'quant_state')
|
||||
if is_4bit:
|
||||
target_dtype = torch.bfloat16
|
||||
else:
|
||||
target_dtype = self.q_proj.weight.dtype
|
||||
|
||||
if hidden_states.dtype != target_dtype:
|
||||
hidden_states = hidden_states.to(target_dtype)
|
||||
# The Triton v1 kernel supports these dimensions and causal
|
||||
# attention only when query and key sequence lengths agree.
|
||||
if (
|
||||
hidden_states.ndim != 3
|
||||
or hidden_states.shape[1] < 1
|
||||
or (self.head_dim not in (64, 96, 128) if qk_quant_gran is None
|
||||
else not 0 < self.head_dim <= 128)
|
||||
or attention_mask is not None
|
||||
or position_embeddings is None
|
||||
or len(position_embeddings) != 2
|
||||
or getattr(self, "sliding_window", None) is not None
|
||||
or self.training
|
||||
or any(name not in ("position_ids", "use_cache") for name in kwargs)
|
||||
or (kwargs.get("use_cache") is False and past_key_values is not None)
|
||||
or (past_key_values is not None and hidden_states.shape[1] != 1)
|
||||
or hidden_states.dtype not in (torch.float16, torch.bfloat16)
|
||||
):
|
||||
return fallback()
|
||||
|
||||
input_shape = hidden_states.shape[:-1]
|
||||
hidden_shape = (*input_shape, -1, self.head_dim)
|
||||
bsz, q_len = input_shape[0], input_shape[1] if len(input_shape) > 1 else hidden_states.size(1)
|
||||
query_states = self.q_proj(hidden_states).view(hidden_shape)
|
||||
key_states = self.k_proj(hidden_states).view(hidden_shape)
|
||||
value_states = self.v_proj(hidden_states).view(hidden_shape)
|
||||
if hasattr(self, "q_norm"):
|
||||
query_states = self.q_norm(query_states)
|
||||
if hasattr(self, "k_norm"):
|
||||
key_states = self.k_norm(key_states)
|
||||
query_states = query_states.transpose(1, 2)
|
||||
key_states = key_states.transpose(1, 2)
|
||||
value_states = value_states.transpose(1, 2)
|
||||
cos, sin = position_embeddings
|
||||
query_states, key_states = apply_rotary(query_states, key_states, cos, sin)
|
||||
|
||||
# Handle q_norm and k_norm for Qwen3-VL
|
||||
query_states = self.q_proj(hidden_states)
|
||||
key_states = self.k_proj(hidden_states)
|
||||
value_states = self.v_proj(hidden_states)
|
||||
|
||||
# Apply normalization if available (Qwen3-VL specific)
|
||||
if hasattr(self, 'q_norm'):
|
||||
query_states = self.q_norm(query_states.view(hidden_shape)).transpose(1, 2)
|
||||
else:
|
||||
query_states = query_states.view(hidden_shape).transpose(1, 2)
|
||||
|
||||
if hasattr(self, 'k_norm'):
|
||||
key_states = self.k_norm(key_states.view(hidden_shape)).transpose(1, 2)
|
||||
else:
|
||||
key_states = key_states.view(hidden_shape).transpose(1, 2)
|
||||
|
||||
value_states = value_states.view(hidden_shape).transpose(1, 2)
|
||||
|
||||
# Apply rotary embeddings
|
||||
if position_embeddings is not None:
|
||||
cos, sin = position_embeddings
|
||||
query_states, key_states = apply_rotary_pos_emb_func(query_states, key_states, cos, sin)
|
||||
# This check precedes cache.update(), since calling the original
|
||||
# forward after an update would append the same token twice.
|
||||
if (query_states.dtype != key_states.dtype or key_states.dtype != value_states.dtype
|
||||
or query_states.dtype not in (torch.float16, torch.bfloat16)):
|
||||
return fallback()
|
||||
|
||||
if past_key_values is not None:
|
||||
cache_kwargs = {"sin": sin if position_embeddings else None, "cos": cos if position_embeddings else None, "cache_position": cache_position}
|
||||
key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx, cache_kwargs)
|
||||
|
||||
is_causal = attention_mask is None and q_len > 1
|
||||
|
||||
attn_output = SAGE_ATTN_FUNC(
|
||||
query_states.to(target_dtype),
|
||||
key_states.to(target_dtype),
|
||||
value_states.to(target_dtype),
|
||||
tensor_layout="HND",
|
||||
is_causal=is_causal,
|
||||
qk_quant_gran=QK_QUANT_GRAN,
|
||||
pv_accum_dtype=PV_ACCUM_DTYPE,
|
||||
)
|
||||
cache_kwargs = {"sin": sin, "cos": cos, "cache_position": cache_position}
|
||||
key_states, value_states = past_key_values.update(
|
||||
key_states, value_states, self.layer_idx, cache_kwargs
|
||||
)
|
||||
|
||||
is_causal = input_shape[1] > 1
|
||||
if qk_quant_gran is None:
|
||||
# SageAttention 1 smooths K in place, including cached K.
|
||||
attn_output = attn_func(
|
||||
query_states, key_states.clone(), value_states,
|
||||
tensor_layout="HND", is_causal=is_causal,
|
||||
sm_scale=getattr(self, "scaling", self.head_dim ** -0.5),
|
||||
)
|
||||
else:
|
||||
attn_output = attn_func(
|
||||
query_states, key_states, value_states,
|
||||
tensor_layout="HND", is_causal=is_causal,
|
||||
qk_quant_gran=qk_quant_gran, pv_accum_dtype=pv_accum_dtype,
|
||||
)
|
||||
if isinstance(attn_output, tuple):
|
||||
attn_output = attn_output[0]
|
||||
attn_output = attn_output.transpose(1, 2).contiguous().reshape(*input_shape, -1)
|
||||
return self.o_proj(attn_output), None
|
||||
|
||||
attn_output = attn_output.transpose(1, 2).contiguous()
|
||||
attn_output = attn_output.reshape(*input_shape, -1)
|
||||
return sage_forward
|
||||
|
||||
attn_output = self.o_proj(attn_output)
|
||||
|
||||
if attn_output.dtype != original_dtype:
|
||||
attn_output = attn_output.to(original_dtype)
|
||||
|
||||
return attn_output, None
|
||||
|
||||
return sage_attention_forward
|
||||
|
||||
# Apply patching to all supported attention modules
|
||||
patched_count = 0
|
||||
for AttentionClass, apply_rotary_func in attention_classes:
|
||||
sage_forward = make_sage_forward(AttentionClass, apply_rotary_func)
|
||||
for module in model.modules():
|
||||
if isinstance(module, AttentionClass):
|
||||
module.forward = sage_forward.__get__(module, AttentionClass)
|
||||
for module in model.modules():
|
||||
for attention_class, apply_rotary in attention_classes:
|
||||
if isinstance(module, attention_class):
|
||||
sage_forward = make_sage_forward(module.forward, apply_rotary)
|
||||
module.forward = sage_forward.__get__(module, attention_class)
|
||||
patched_count += 1
|
||||
break
|
||||
|
||||
if patched_count > 0:
|
||||
print(f"[QwenVL] SageAttention: Patched {patched_count} attention layers")
|
||||
else:
|
||||
print("[QwenVL] SageAttention: No compatible attention layers found to patch")
|
||||
if not patched_count:
|
||||
raise RuntimeError("SageAttention: No compatible attention layers found to patch")
|
||||
print(f"[QwenVL] SageAttention: Patched {patched_count} attention layers")
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test runtimes
|
||||
|
||||
| Command | Environment | Successful runtime | Measured | Recommended timeout |
|
||||
| --- | --- | ---: | --- | ---: |
|
||||
| `python -c "import sys,unittest; sys.path[:0]=['.','tests']; import test_new_features as m; unittest.main(m, argv=['unittest','-v'], exit=False)"` | Windows, ComfyUI embedded Python 3.12, CPU test path | 48.8 s | 2026-08-24 | 120 s |
|
||||
+2948
-15
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
||||
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()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Regression checks for SageAttention selection and model-local fallback."""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def load_attention_selector(*, hip, sage_version="1.0.6", triton_kernel=None, cuda_kernels=False):
|
||||
tree = ast.parse((ROOT / "thinkingllm_core/hf_models.py").read_text(encoding="utf-8"))
|
||||
names = {"sage_attn_available", "get_sage_attention_config", "resolve_attention_mode"}
|
||||
functions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names]
|
||||
cuda = types.SimpleNamespace(
|
||||
is_available=lambda: True,
|
||||
get_device_capability=(
|
||||
(lambda: (_ for _ in ()).throw(AssertionError("HIP queried CUDA SM")))
|
||||
if hip else (lambda: (8, 6))
|
||||
),
|
||||
)
|
||||
metadata = types.SimpleNamespace(
|
||||
version=lambda package: sage_version,
|
||||
PackageNotFoundError=Exception,
|
||||
)
|
||||
namespace = {
|
||||
"torch": types.SimpleNamespace(cuda=cuda, version=types.SimpleNamespace(hip=hip)),
|
||||
"importlib": types.SimpleNamespace(metadata=metadata),
|
||||
"sageattn_triton": triton_kernel,
|
||||
"SAGE_ATTENTION_AVAILABLE": cuda_kernels,
|
||||
"sageattn_qk_int8_pv_fp16_cuda": triton_kernel if cuda_kernels else None,
|
||||
"sageattn_qk_int8_pv_fp8_cuda": None,
|
||||
"sageattn_qk_int8_pv_fp8_cuda_sm90": None,
|
||||
"flash_attn_available": lambda: False,
|
||||
}
|
||||
exec(compile(ast.Module(body=functions, type_ignores=[]), "hf_models.py", "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
class TestSageBackendSelection(unittest.TestCase):
|
||||
def test_rocm_v1_selects_triton_without_nvidia_capability_query(self):
|
||||
kernel = lambda *args, **kwargs: None
|
||||
selector = load_attention_selector(hip="7.0", triton_kernel=kernel)
|
||||
self.assertIs(selector["get_sage_attention_config"]()[0], kernel)
|
||||
self.assertEqual("sage", selector["resolve_attention_mode"]("sage"))
|
||||
self.assertEqual("sage", selector["resolve_attention_mode"]("auto"))
|
||||
|
||||
def test_rocm_unavailable_or_unsupported_version_falls_back(self):
|
||||
for version, kernel in (("2.2.0", lambda: None), ("1.0.6", None)):
|
||||
with self.subTest(version=version, installed=kernel is not None):
|
||||
selector = load_attention_selector(hip="7.0", sage_version=version, triton_kernel=kernel)
|
||||
self.assertEqual("sdpa", selector["resolve_attention_mode"]("sage"))
|
||||
|
||||
def test_forced_sdpa_still_wins(self):
|
||||
selector = load_attention_selector(hip="7.0", triton_kernel=lambda: None)
|
||||
self.assertEqual("sdpa", selector["resolve_attention_mode"]("sage", force_sdpa=True))
|
||||
|
||||
def test_nvidia_cuda_kernel_selection_is_preserved(self):
|
||||
kernel = lambda *args, **kwargs: None
|
||||
selector = load_attention_selector(hip=None, triton_kernel=kernel, cuda_kernels=True)
|
||||
self.assertEqual((kernel, "per_warp", "fp32"), selector["get_sage_attention_config"]())
|
||||
|
||||
|
||||
class FakeAttention(torch.nn.Module):
|
||||
def __init__(self, head_dim=64):
|
||||
super().__init__()
|
||||
self.head_dim = head_dim
|
||||
self.layer_idx = 0
|
||||
self.scaling = head_dim ** -0.5
|
||||
self.q_proj = torch.nn.Linear(head_dim, head_dim, bias=False)
|
||||
self.k_proj = torch.nn.Linear(head_dim, head_dim, bias=False)
|
||||
self.v_proj = torch.nn.Linear(head_dim, head_dim, bias=False)
|
||||
self.o_proj = torch.nn.Linear(head_dim, head_dim, bias=False)
|
||||
self.original_calls = 0
|
||||
|
||||
def forward(self, hidden_states, **kwargs):
|
||||
self.original_calls += 1
|
||||
return hidden_states, None
|
||||
|
||||
|
||||
class FakeCache:
|
||||
def __init__(self):
|
||||
self.keys = None
|
||||
|
||||
def update(self, keys, values, layer_idx, cache_kwargs):
|
||||
self.keys = keys
|
||||
return keys, values
|
||||
|
||||
|
||||
class TestSageModelPatch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
|
||||
def kernel(q, k, v, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
k.add_(1) # SageAttention 1 smooths K in place.
|
||||
return q
|
||||
|
||||
hf_models = types.ModuleType("thinkingllm_core.hf_models")
|
||||
hf_models.get_sage_attention_config = lambda: (kernel, None, None)
|
||||
qwen2 = types.ModuleType("transformers.models.qwen2.modeling_qwen2")
|
||||
qwen2.Qwen2Attention = FakeAttention
|
||||
qwen2.apply_rotary_pos_emb = lambda q, k, cos, sin: (q, k)
|
||||
self.modules = mock.patch.dict(sys.modules, {
|
||||
"thinkingllm_core.hf_models": hf_models,
|
||||
"transformers.models.qwen2.modeling_qwen2": qwen2,
|
||||
})
|
||||
self.modules.start()
|
||||
self.addCleanup(self.modules.stop)
|
||||
spec = importlib.util.spec_from_file_location("_test_sageattention_patch", ROOT / "sageattention_patch.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.patch_model = module.set_sage_attention
|
||||
|
||||
@staticmethod
|
||||
def embeddings():
|
||||
return torch.zeros(1, 1, 64), torch.zeros(1, 1, 64)
|
||||
|
||||
def test_unmasked_prefill_and_cached_decode_use_sage_without_mutating_cache(self):
|
||||
layer = FakeAttention().half().eval()
|
||||
self.patch_model(torch.nn.Sequential(layer))
|
||||
prefill = torch.randn(1, 2, 64, dtype=torch.float16)
|
||||
output, weights = layer(prefill, position_embeddings=self.embeddings(), use_cache=True, position_ids=None)
|
||||
self.assertEqual((1, 2, 64), tuple(output.shape))
|
||||
self.assertIsNone(weights)
|
||||
self.assertTrue(self.calls[-1]["is_causal"])
|
||||
self.assertEqual(0, layer.original_calls)
|
||||
|
||||
cache = FakeCache()
|
||||
token = torch.randn(1, 1, 64, dtype=torch.float16)
|
||||
layer(token, position_embeddings=self.embeddings(), past_key_values=cache, use_cache=True)
|
||||
self.assertFalse(self.calls[-1]["is_causal"])
|
||||
self.assertEqual(2, len(self.calls))
|
||||
self.assertIsNotNone(cache.keys)
|
||||
expected_keys = layer.k_proj(token).view(1, 1, 1, 64).transpose(1, 2)
|
||||
torch.testing.assert_close(cache.keys, expected_keys)
|
||||
|
||||
def test_mask_and_unsupported_head_size_call_original_forward(self):
|
||||
layer = FakeAttention().half().eval()
|
||||
self.patch_model(torch.nn.Sequential(layer))
|
||||
inputs = torch.randn(1, 2, 64, dtype=torch.float16)
|
||||
layer(inputs, position_embeddings=self.embeddings(), attention_mask=torch.zeros(1, 1, 2, 2))
|
||||
self.assertEqual(1, layer.original_calls)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
unsupported = FakeAttention(head_dim=80).half().eval()
|
||||
self.patch_model(torch.nn.Sequential(unsupported))
|
||||
unsupported(torch.randn(1, 2, 80, dtype=torch.float16), position_embeddings=self.embeddings())
|
||||
self.assertEqual(1, unsupported.original_calls)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_no_supported_layers_is_an_error(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "No compatible attention layers"):
|
||||
self.patch_model(torch.nn.Sequential(torch.nn.Linear(2, 2)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"profiles": {
|
||||
"OpenRouter Production": {
|
||||
"provider": "OpenRouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"auth": "bearer_env",
|
||||
"api_key_env": "OPENROUTER_PRODUCTION_API_KEY",
|
||||
"allowed_models": [
|
||||
"provider/vision-model-id"
|
||||
],
|
||||
"max_tokens_limit": 8192,
|
||||
"max_input_chars": 131072,
|
||||
"max_timeout_seconds": 300,
|
||||
"allow_images": true,
|
||||
"allow_image_url": true,
|
||||
"max_image_pixels": 16777216,
|
||||
"max_image_bytes": 20000000,
|
||||
"allowed_extra_body_fields": [],
|
||||
"send_seed": false
|
||||
},
|
||||
"Internal vLLM": {
|
||||
"provider": "vLLM",
|
||||
"base_url": "http://127.0.0.1:8000/v1",
|
||||
"auth": "none",
|
||||
"allowed_models": [
|
||||
"my-qwen-model"
|
||||
],
|
||||
"max_tokens_limit": 8192,
|
||||
"max_input_chars": 1000000,
|
||||
"max_timeout_seconds": 300,
|
||||
"allowed_extra_body_fields": [],
|
||||
"send_seed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Internal implementation package for ComfyUI-ThinkingLLM."""
|
||||
@@ -0,0 +1,790 @@
|
||||
"""Hugging Face model configuration, discovery, device, and attention support."""
|
||||
|
||||
import gc
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
import torch
|
||||
try:
|
||||
from transformers import BitsAndBytesConfig
|
||||
except ImportError:
|
||||
BitsAndBytesConfig = None
|
||||
|
||||
import folder_paths
|
||||
|
||||
from thinkingllm_core.model_access import (
|
||||
COMMERCIAL_RELEASE,
|
||||
_download_model_snapshot,
|
||||
_model_snapshot_has_required_files,
|
||||
enforce_model_access,
|
||||
normalize_commercial_status,
|
||||
resolve_model_catalog_name,
|
||||
)
|
||||
from thinkingllm_core.prompt_contracts import VIDEO_PRESET_METADATA
|
||||
|
||||
try:
|
||||
from sageattention.core import (
|
||||
sageattn_qk_int8_pv_fp16_cuda,
|
||||
sageattn_qk_int8_pv_fp8_cuda,
|
||||
sageattn_qk_int8_pv_fp8_cuda_sm90,
|
||||
)
|
||||
SAGE_ATTENTION_AVAILABLE = True
|
||||
except Exception:
|
||||
sageattn_qk_int8_pv_fp16_cuda = None
|
||||
sageattn_qk_int8_pv_fp8_cuda = None
|
||||
sageattn_qk_int8_pv_fp8_cuda_sm90 = None
|
||||
SAGE_ATTENTION_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from sageattention import sageattn as sageattn_triton
|
||||
except Exception:
|
||||
sageattn_triton = None
|
||||
|
||||
NODE_DIR = Path(__file__).resolve().parent.parent
|
||||
CONFIG_PATH = NODE_DIR / ("hf_models.commercial.json" if COMMERCIAL_RELEASE else "hf_models.json")
|
||||
SYSTEM_PROMPTS_PATH = NODE_DIR / "AILab_System_Prompts.json"
|
||||
HF_VL_MODELS: dict[str, dict] = {}
|
||||
HF_TEXT_MODELS: dict[str, dict] = {}
|
||||
HF_ALL_MODELS: dict[str, dict] = {}
|
||||
SYSTEM_PROMPTS = {}
|
||||
NO_PRESET_PROMPT = "🚫 No preset (image-only)"
|
||||
PRESET_PROMPTS: list[str] = [NO_PRESET_PROMPT, "Describe this image in detail."]
|
||||
|
||||
|
||||
TOOLTIPS = {
|
||||
"model_name": "Pick the checkpoint. [installed] means a catalog model is available in a configured LLM location; [local] means an uncatalogued local model, including compatible ComfyUI text_encoders. Missing catalog models download on first use.",
|
||||
"quantization": "Precision vs VRAM. FP16 gives the best quality if memory allows; 8-bit suits 8–16 GB GPUs; 4-bit fits 6 GB or lower but is slower.",
|
||||
"attention_mode": "auto tries compatible SageAttention (including Triton Sage 1 on ROCm) → FlashAttention 2 → SDPA in order. SDPA is stable and recommended. Only override when debugging attention backends.",
|
||||
"preset_prompt": "Built-in instruction describing how Qwen-VL should analyze the media input.",
|
||||
"custom_prompt": "Additional user input that gets combined with the preset template. Leave empty to use only the template.",
|
||||
"max_tokens": "Maximum number of new tokens to decode. Larger values yield longer answers but consume more time and memory.",
|
||||
"keep_model_loaded": "Keeps the model resident in VRAM/RAM after the run so the next prompt skips loading.",
|
||||
"seed": "Seed controlling sampling and frame picking; reuse it to reproduce results.",
|
||||
"use_torch_compile": "Enable torch.compile('reduce-overhead') on supported CUDA/Torch 2.1+ builds for extra throughput after the first compile.",
|
||||
"device": "Choose where to run the model: auto, cpu, mps, or cuda:x for multi-GPU systems.",
|
||||
"temperature": "Sampling randomness when num_beams == 1. 0.2–0.4 is focused, 0.7+ is creative.",
|
||||
"top_p": "Nucleus sampling cutoff when num_beams == 1. Lower values keep only top tokens; 0.9–0.95 allows more variety.",
|
||||
"num_beams": "Beam-search width. Values >1 disable temperature/top_p and trade speed for more stable answers.",
|
||||
"repetition_penalty": "Values >1 (e.g., 1.1–1.3) penalize repeated phrases; 1.0 leaves logits untouched.",
|
||||
"frame_count": "Number of frames extracted from video inputs before prompting Qwen-VL. More frames provide context but cost time.",
|
||||
"hf_token": "Optional Hugging Face access token for private or gated model downloads. It is passed only to the download call, never logged or cached, and the in-memory copy is dropped after the download attempt. Clear this field before saving or sharing workflows.",
|
||||
}
|
||||
|
||||
|
||||
def _configured_llm_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
try:
|
||||
if "LLM" in folder_paths.folder_names_and_paths:
|
||||
roots.extend(Path(path) for path in folder_paths.get_folder_paths("LLM"))
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
pass
|
||||
default_root = Path(folder_paths.models_dir) / "LLM"
|
||||
if default_root not in roots:
|
||||
roots.append(default_root)
|
||||
return roots
|
||||
|
||||
|
||||
def _hf_qwen_vl_dirs() -> list[Path]:
|
||||
return [root / "Qwen-VL" for root in _configured_llm_roots()]
|
||||
|
||||
|
||||
def _hf_catalog_search_dirs() -> list[Path]:
|
||||
search_dirs: list[Path] = []
|
||||
for root in _configured_llm_roots():
|
||||
for directory in (root / "Qwen-VL", root):
|
||||
if directory not in search_dirs:
|
||||
search_dirs.append(directory)
|
||||
return search_dirs
|
||||
|
||||
|
||||
def _find_installed_hf_model_dir(info: dict, *, require_processor: bool = False) -> Path | None:
|
||||
repo_id = info.get("repo_id") if isinstance(info, dict) else None
|
||||
if not isinstance(repo_id, str) or not repo_id.strip():
|
||||
return None
|
||||
directory_name = repo_id.rstrip("/").rsplit("/", 1)[-1]
|
||||
installed_path = info.get("installed_path")
|
||||
candidates = [Path(installed_path)] if installed_path else []
|
||||
candidates.extend(directory / directory_name for directory in _hf_catalog_search_dirs())
|
||||
seen: set[Path] = set()
|
||||
for candidate in candidates:
|
||||
if candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
if _model_snapshot_has_required_files(candidate, require_processor=require_processor):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _mark_installed_hf_catalog_models() -> None:
|
||||
for info in HF_ALL_MODELS.values():
|
||||
if not isinstance(info, dict) or info.get("is_local"):
|
||||
continue
|
||||
installed_path = _find_installed_hf_model_dir(info)
|
||||
if installed_path is None:
|
||||
info.pop("is_installed", None)
|
||||
info.pop("installed_path", None)
|
||||
continue
|
||||
info["is_installed"] = True
|
||||
info["installed_path"] = str(installed_path)
|
||||
|
||||
|
||||
def ensure_cuda_vram_headroom(module_name="QwenVL", min_free_gb=1.0, min_free_ratio=0.08):
|
||||
if not torch.cuda.is_available():
|
||||
return True
|
||||
try:
|
||||
torch.cuda.synchronize()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
free_before, total = torch.cuda.mem_get_info()
|
||||
except Exception:
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
return True
|
||||
|
||||
allocated = torch.cuda.memory_allocated()
|
||||
reserved = torch.cuda.memory_reserved()
|
||||
reclaimable = max(reserved - allocated, 0)
|
||||
threshold = max(int(min_free_gb * 1024**3), int(total * min_free_ratio))
|
||||
|
||||
if free_before >= threshold and reclaimable < 512 * 1024**2:
|
||||
return True
|
||||
|
||||
print(
|
||||
f"[{module_name}] VRAM headroom low before run: "
|
||||
f"free={free_before / 1024**3:.2f}GB, "
|
||||
f"reserved={reserved / 1024**3:.2f}GB, "
|
||||
f"allocated={allocated / 1024**3:.2f}GB. Cleaning CUDA cache..."
|
||||
)
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
try:
|
||||
torch.cuda.ipc_collect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
torch.cuda.synchronize()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
free_after, _ = torch.cuda.mem_get_info()
|
||||
print(f"[{module_name}] VRAM after cleanup: free={free_after / 1024**3:.2f}GB")
|
||||
return free_after >= threshold
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
class Quantization(str, Enum):
|
||||
Q4 = "4-bit (VRAM-friendly)"
|
||||
Q8 = "8-bit (Balanced)"
|
||||
FP16 = "None (FP16)"
|
||||
|
||||
@classmethod
|
||||
def get_values(cls):
|
||||
return [item.value for item in cls]
|
||||
|
||||
@classmethod
|
||||
def from_value(cls, value):
|
||||
for item in cls:
|
||||
if item.value == value:
|
||||
return item
|
||||
raise ValueError(f"Unsupported quantization: {value}")
|
||||
|
||||
ATTENTION_MODES = ["auto", "sage", "flash_attention_2", "sdpa"]
|
||||
|
||||
# Debug: Check SageAttention availability
|
||||
print(f"[QwenVL Debug] SAGE_ATTENTION_AVAILABLE: {SAGE_ATTENTION_AVAILABLE}")
|
||||
if torch.cuda.is_available():
|
||||
if getattr(torch.version, "hip", None):
|
||||
print(f"[QwenVL Debug] ROCm/HIP: {torch.version.hip}")
|
||||
else:
|
||||
major, minor = torch.cuda.get_device_capability()
|
||||
print(f"[QwenVL Debug] CUDA capability: {major}.{minor}")
|
||||
else:
|
||||
print("[QwenVL Debug] CUDA not available")
|
||||
print(f"[QwenVL Debug] Final ATTENTION_MODES: {ATTENTION_MODES}")
|
||||
|
||||
# Temporarily show sage option even if not available for testing
|
||||
print("[QwenVL] NOTE: SageAttention option shown for testing. Install with: pip install sageattention")
|
||||
|
||||
def load_model_configs():
|
||||
next_vl_models: dict[str, dict] = {}
|
||||
next_text_models: dict[str, dict] = {}
|
||||
next_system_prompts: dict = {}
|
||||
next_preset_prompts = list(PRESET_PROMPTS)
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh) or {}
|
||||
if "hf_vl_models" in data or "hf_text_models" in data:
|
||||
next_vl_models = data.get("hf_vl_models") or {}
|
||||
next_text_models = data.get("hf_text_models") or {}
|
||||
else:
|
||||
next_vl_models = {k: v for k, v in data.items() if not k.startswith("_")}
|
||||
if COMMERCIAL_RELEASE:
|
||||
next_vl_models = {name: info for name, info in next_vl_models.items() if normalize_commercial_status(info) == "cleared"}
|
||||
next_text_models = {name: info for name, info in next_text_models.items() if normalize_commercial_status(info) == "cleared"}
|
||||
next_system_prompts = data.get("_system_prompts", {})
|
||||
next_preset_prompts = data.get("_preset_prompts", next_preset_prompts)
|
||||
except Exception as exc:
|
||||
print(f"[QwenVL] Config load failed: {exc}")
|
||||
try:
|
||||
with open(SYSTEM_PROMPTS_PATH, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh) or {}
|
||||
qwenvl_prompts = data.get("qwenvl") or {}
|
||||
preset_override = data.get("_preset_prompts") or []
|
||||
video_metadata = data.get("_video_presets") or {}
|
||||
if isinstance(qwenvl_prompts, dict) and qwenvl_prompts:
|
||||
next_system_prompts = qwenvl_prompts
|
||||
if isinstance(preset_override, list) and preset_override:
|
||||
next_preset_prompts = preset_override
|
||||
if isinstance(video_metadata, dict):
|
||||
VIDEO_PRESET_METADATA.clear()
|
||||
VIDEO_PRESET_METADATA.update(
|
||||
{
|
||||
name: dict(entry)
|
||||
for name, entry in video_metadata.items()
|
||||
if isinstance(name, str) and isinstance(entry, dict)
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
print(f"[QwenVL] System prompts load failed: {exc}")
|
||||
|
||||
if NO_PRESET_PROMPT not in next_preset_prompts:
|
||||
next_preset_prompts = [NO_PRESET_PROMPT, *next_preset_prompts]
|
||||
if isinstance(next_system_prompts, dict):
|
||||
next_system_prompts.setdefault(NO_PRESET_PROMPT, "")
|
||||
custom = NODE_DIR / "custom_models.json"
|
||||
if custom.exists() and not COMMERCIAL_RELEASE:
|
||||
try:
|
||||
with open(custom, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh) or {}
|
||||
custom_vl = data.get("hf_vl_models") or {}
|
||||
custom_text = data.get("hf_text_models") or {}
|
||||
legacy = data.get("hf_models", {}) or data.get("models", {})
|
||||
if isinstance(custom_vl, dict) and custom_vl:
|
||||
next_vl_models.update(custom_vl)
|
||||
print(f"[QwenVL] Loaded {len(custom_vl)} custom VL models")
|
||||
if isinstance(custom_text, dict) and custom_text:
|
||||
next_text_models.update(custom_text)
|
||||
print(f"[QwenVL] Loaded {len(custom_text)} custom text models")
|
||||
if isinstance(legacy, dict) and legacy:
|
||||
next_vl_models.update(legacy)
|
||||
print(f"[QwenVL] Loaded {len(legacy)} custom legacy models")
|
||||
except Exception as exc:
|
||||
print(f"[QwenVL] custom_models.json skipped: {exc}")
|
||||
|
||||
HF_VL_MODELS.clear()
|
||||
HF_VL_MODELS.update(next_vl_models)
|
||||
HF_TEXT_MODELS.clear()
|
||||
HF_TEXT_MODELS.update(next_text_models)
|
||||
HF_ALL_MODELS.clear()
|
||||
HF_ALL_MODELS.update(HF_VL_MODELS)
|
||||
HF_ALL_MODELS.update(HF_TEXT_MODELS)
|
||||
SYSTEM_PROMPTS.clear()
|
||||
SYSTEM_PROMPTS.update(next_system_prompts)
|
||||
PRESET_PROMPTS[:] = next_preset_prompts
|
||||
|
||||
# Commercial releases expose only reviewed catalog entries, but still show
|
||||
# whether those entries are available in any configured LLM location.
|
||||
if COMMERCIAL_RELEASE:
|
||||
_mark_installed_hf_catalog_models()
|
||||
else:
|
||||
_scan_local_hf_models()
|
||||
|
||||
|
||||
def _scan_local_hf_models():
|
||||
"""Scan local HF directories and compatible ComfyUI text encoders."""
|
||||
_mark_installed_hf_catalog_models()
|
||||
scan_dirs = _hf_qwen_vl_dirs()
|
||||
|
||||
# Collect known local directory names from JSON config
|
||||
known_dirs = set()
|
||||
for info in HF_ALL_MODELS.values():
|
||||
repo_id = info.get("repo_id", "")
|
||||
if isinstance(repo_id, str) and "/" in repo_id:
|
||||
known_dirs.add(repo_id.split("/")[-1])
|
||||
local_path = info.get("local_path")
|
||||
if local_path:
|
||||
known_dirs.add(Path(local_path).name)
|
||||
|
||||
count = 0
|
||||
for models_dir in scan_dirs:
|
||||
if not models_dir.exists() or not models_dir.is_dir():
|
||||
continue
|
||||
try:
|
||||
for entry in models_dir.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if entry.name in known_dirs:
|
||||
continue
|
||||
# Check if the directory contains model weights
|
||||
has_weights = any(entry.glob("*.safetensors")) or any(entry.glob("*.bin"))
|
||||
if not has_weights:
|
||||
continue
|
||||
display = f"[local] {entry.name}"
|
||||
model_info = {
|
||||
"local_path": str(entry),
|
||||
"repo_id": None,
|
||||
"is_local": True,
|
||||
"commercial_status": "local",
|
||||
"quantized": False,
|
||||
}
|
||||
HF_VL_MODELS[display] = model_info
|
||||
HF_ALL_MODELS[display] = model_info
|
||||
known_dirs.add(entry.name)
|
||||
count += 1
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
try:
|
||||
text_encoders = folder_paths.get_filename_list("text_encoders")
|
||||
except (AttributeError, KeyError):
|
||||
text_encoders = []
|
||||
for filename in text_encoders:
|
||||
normalized = Path(filename).name.lower().replace("-", "").replace("_", "")
|
||||
family = None
|
||||
if "gemma312b" in normalized:
|
||||
family = "gemma3"
|
||||
elif "qwen3vl" in normalized and ("4b" in normalized or "8b" in normalized):
|
||||
family = "qwen3vl"
|
||||
if not family or not normalized.endswith(".safetensors"):
|
||||
continue
|
||||
display = f"[ComfyUI] {filename}"
|
||||
model_info = {
|
||||
"comfy_text_encoder": filename,
|
||||
"repo_id": None,
|
||||
"is_local": True,
|
||||
"local_display_name": filename,
|
||||
"storage_label": "text_encoders",
|
||||
"commercial_status": "local",
|
||||
"quantized": True,
|
||||
"native_family": family,
|
||||
}
|
||||
HF_VL_MODELS[display] = model_info
|
||||
HF_ALL_MODELS[display] = model_info
|
||||
count += 1
|
||||
|
||||
if count:
|
||||
print(f"[QwenVL] Discovered {count} local model(s) on disk")
|
||||
|
||||
|
||||
if not HF_ALL_MODELS:
|
||||
load_model_configs()
|
||||
|
||||
|
||||
def _default_model_from_config(models: dict[str, dict], fallback: str) -> str:
|
||||
for name, info in models.items():
|
||||
if isinstance(info, dict) and info.get("default"):
|
||||
return name
|
||||
return next(iter(models.keys()), fallback)
|
||||
|
||||
|
||||
def read_hf_model_type(model_dir: str) -> str | None:
|
||||
"""Read model_type from a HuggingFace model's config.json."""
|
||||
try:
|
||||
config_path = Path(model_dir) / "config.json"
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
return config.get("model_type")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def check_pytorch_memory():
|
||||
"""Check current PyTorch memory settings and allow user to set fraction"""
|
||||
try:
|
||||
import torch
|
||||
print(f"[QwenVL] PyTorch {torch.__version__}")
|
||||
print(f"[QwenVL] CUDA Available: {torch.cuda.is_available()}")
|
||||
|
||||
if torch.cuda.is_available():
|
||||
current_fraction = torch.cuda.get_per_process_memory_fraction()
|
||||
print(f"[QwenVL] Current Memory Fraction: {current_fraction:.3f} ({current_fraction*100:.1f}% of GPU)")
|
||||
print(f"[QwenVL] GPU Memory: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB")
|
||||
|
||||
# Allow user to set new fraction
|
||||
try:
|
||||
prompt = f"[QwenVL] Enter new memory fraction (0.1-0.9"
|
||||
if current_fraction is not None:
|
||||
prompt += f", current={current_fraction:.3f})"
|
||||
new_fraction = float(input(prompt + ": ") or current_fraction)
|
||||
if 0.1 <= new_fraction <= 0.9:
|
||||
torch.cuda.set_per_process_memory_fraction(new_fraction)
|
||||
print(f"[QwenVL] ✅ Memory fraction set to: {new_fraction:.3f} ({new_fraction*100:.1f}%)")
|
||||
else:
|
||||
print(f"[QwenVL] ❌ Invalid fraction. Must be between 0.1 and 0.9")
|
||||
except KeyboardInterrupt:
|
||||
print(f"[QwenVL] ✅ Keeping current fraction: {current_fraction:.3f}")
|
||||
except Exception as e:
|
||||
print(f"[QwenVL] ❌ Error setting fraction: {e}")
|
||||
else:
|
||||
print("[QwenVL] CUDA not available")
|
||||
except Exception as e:
|
||||
print(f"[QwenVL] Error checking PyTorch: {e}")
|
||||
|
||||
def set_pytorch_memory_fraction(fraction):
|
||||
"""Set PyTorch memory fraction if CUDA is available"""
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
if 0.1 <= fraction <= 0.9:
|
||||
torch.cuda.set_per_process_memory_fraction(fraction)
|
||||
print(f"[QwenVL] Memory fraction set to: {fraction:.3f} ({fraction*100:.1f}%)")
|
||||
return True
|
||||
else:
|
||||
print(f"[QwenVL] Invalid fraction: {fraction:.3f}. Must be between 0.1 and 0.9")
|
||||
return False
|
||||
else:
|
||||
print("[QwenVL] CUDA not available")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[QwenVL] Error: {e}")
|
||||
return False
|
||||
|
||||
def get_device_info():
|
||||
gpu = {"available": False, "total_memory": 0, "free_memory": 0}
|
||||
device_type = "cpu"
|
||||
recommended = "cpu"
|
||||
if torch.cuda.is_available():
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
total = props.total_memory / 1024**3
|
||||
allocated = torch.cuda.memory_allocated(0) / 1024**3
|
||||
reserved = torch.cuda.memory_reserved(0) / 1024**3
|
||||
free = total - allocated - reserved
|
||||
|
||||
gpu = {
|
||||
"available": True,
|
||||
"total_memory": total,
|
||||
"allocated_memory": allocated,
|
||||
"reserved_memory": reserved,
|
||||
"free_memory": free,
|
||||
}
|
||||
device_type = "nvidia_gpu"
|
||||
recommended = "cuda"
|
||||
|
||||
# Detailed memory debugging
|
||||
print(f"[QwenVL] GPU Memory Debug:")
|
||||
print(f" Total VRAM: {total / 1024**3:.2f} GB")
|
||||
print(f" Allocated: {allocated / 1024**3:.2f} GB")
|
||||
print(f" Reserved: {reserved / 1024**3:.2f} GB")
|
||||
print(f" Free: {free / 1024**3:.2f} GB")
|
||||
print(f" Model requires: 0.74 GB")
|
||||
print(f" Available ratio: {(free / total) * 100:.1f}%")
|
||||
|
||||
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
||||
device_type = "apple_silicon"
|
||||
recommended = "mps"
|
||||
gpu = {"available": True, "total_memory": 0, "free_memory": 0}
|
||||
sys_mem = psutil.virtual_memory()
|
||||
return {
|
||||
"gpu": gpu,
|
||||
"system_memory": {
|
||||
"total": sys_mem.total / 1024**3,
|
||||
"available": sys_mem.available / 1024**3,
|
||||
},
|
||||
"device_type": device_type,
|
||||
"recommended_device": recommended,
|
||||
}
|
||||
|
||||
def normalize_device_choice(device: str) -> str:
|
||||
device = (device or "auto").strip()
|
||||
if device == "auto":
|
||||
return "auto"
|
||||
|
||||
if device.isdigit():
|
||||
device = f"cuda:{int(device)}"
|
||||
|
||||
if device == "cuda":
|
||||
if not torch.cuda.is_available():
|
||||
print("[QwenVL] CUDA requested but not available, falling back to CPU")
|
||||
return "cpu"
|
||||
return "cuda"
|
||||
|
||||
if device.startswith("cuda"):
|
||||
if not torch.cuda.is_available():
|
||||
print("[QwenVL] CUDA requested but not available, falling back to CPU")
|
||||
return "cpu"
|
||||
if ":" in device:
|
||||
try:
|
||||
device_idx = int(device.split(":", 1)[1])
|
||||
if device_idx >= torch.cuda.device_count():
|
||||
print(f"[QwenVL] CUDA device {device_idx} not available, using cuda:0")
|
||||
return "cuda:0"
|
||||
except (ValueError, IndexError):
|
||||
print(f"[QwenVL] Invalid CUDA device format '{device}', using cuda:0")
|
||||
return "cuda:0"
|
||||
return device
|
||||
|
||||
if device == "mps":
|
||||
if not (getattr(torch.backends, "mps", None) and torch.backends.mps.is_available()):
|
||||
print("[QwenVL] MPS requested but not available, falling back to CPU")
|
||||
return "cpu"
|
||||
return "mps"
|
||||
|
||||
return device
|
||||
|
||||
def flash_attn_available():
|
||||
if not torch.cuda.is_available():
|
||||
return False
|
||||
|
||||
if platform.system().lower() != "linux":
|
||||
return False
|
||||
|
||||
major, _ = torch.cuda.get_device_capability()
|
||||
if major < 8:
|
||||
return False
|
||||
|
||||
try:
|
||||
import flash_attn # noqa: F401
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
import importlib.metadata as importlib_metadata
|
||||
_ = importlib_metadata.version("flash_attn")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def sage_attn_available():
|
||||
"""Check if SageAttention is available and GPU supports it."""
|
||||
return get_sage_attention_config()[0] is not None
|
||||
|
||||
|
||||
def get_sage_attention_config():
|
||||
"""Get the appropriate SageAttention kernel based on GPU architecture."""
|
||||
if not torch.cuda.is_available():
|
||||
return None, None, None
|
||||
|
||||
if getattr(torch.version, "hip", None):
|
||||
try:
|
||||
is_v1 = importlib.metadata.version("sageattention").split(".", 1)[0] == "1"
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
is_v1 = False
|
||||
if is_v1 and callable(sageattn_triton):
|
||||
return sageattn_triton, None, None
|
||||
return None, None, None
|
||||
|
||||
if not SAGE_ATTENTION_AVAILABLE:
|
||||
return None, None, None
|
||||
|
||||
major, minor = torch.cuda.get_device_capability()
|
||||
arch_code = major * 10 + minor
|
||||
if arch_code < 80:
|
||||
return None, None, None
|
||||
|
||||
attn_func = None
|
||||
pv_accum_dtype = "fp32"
|
||||
|
||||
if arch_code >= 120: # Blackwell
|
||||
pv_accum_dtype = "fp32+fp32"
|
||||
attn_func = sageattn_qk_int8_pv_fp8_cuda
|
||||
print(f"[QwenVL] SageAttention: Using SM120 (Blackwell) FP8 kernel")
|
||||
elif arch_code >= 90: # Hopper
|
||||
pv_accum_dtype = "fp32+fp32"
|
||||
attn_func = sageattn_qk_int8_pv_fp8_cuda_sm90
|
||||
print(f"[QwenVL] SageAttention: Using SM90 (Hopper) FP8 kernel")
|
||||
elif arch_code == 89: # Ada Lovelace
|
||||
pv_accum_dtype = "fp32+fp32"
|
||||
attn_func = sageattn_qk_int8_pv_fp8_cuda
|
||||
print(f"[QwenVL] SageAttention: Using SM89 (Ada) FP8 kernel")
|
||||
elif arch_code >= 80: # Ampere
|
||||
pv_accum_dtype = "fp32"
|
||||
attn_func = sageattn_qk_int8_pv_fp16_cuda
|
||||
print(f"[QwenVL] SageAttention: Using SM80+ (Ampere) FP16 kernel")
|
||||
else:
|
||||
print(f"[QwenVL] SageAttention not supported on SM{arch_code}")
|
||||
return None, None, None
|
||||
|
||||
return (attn_func, "per_warp", pv_accum_dtype) if callable(attn_func) else (None, None, None)
|
||||
|
||||
def is_fp8_model(model_name: str) -> bool:
|
||||
"""Check if model name indicates it's a pre-quantized FP8 model."""
|
||||
fp8_indicators = ["-fp8", "_fp8", "-FP8", "_FP8"]
|
||||
return any(indicator in model_name for indicator in fp8_indicators)
|
||||
|
||||
def resolve_attention_mode(mode, force_sdpa=False):
|
||||
"""Resolve attention mode with fallback logic.
|
||||
|
||||
Args:
|
||||
mode: The requested attention mode
|
||||
force_sdpa: If True, always return SDPA (for FP8/BnB models)
|
||||
"""
|
||||
if force_sdpa:
|
||||
return "sdpa"
|
||||
|
||||
if mode == "sdpa":
|
||||
return "sdpa"
|
||||
if mode == "sage":
|
||||
if sage_attn_available():
|
||||
return "sage"
|
||||
print("[QwenVL] SageAttention forced but unavailable, falling back to SDPA")
|
||||
return "sdpa"
|
||||
if mode == "flash_attention_2":
|
||||
if flash_attn_available():
|
||||
return "flash_attention_2"
|
||||
print("[QwenVL] Flash-Attn forced but unavailable, falling back to SDPA")
|
||||
return "sdpa"
|
||||
|
||||
# Auto mode: try sage → flash → sdpa
|
||||
if sage_attn_available():
|
||||
print("[QwenVL] Auto mode: Using SageAttention")
|
||||
return "sage"
|
||||
if flash_attn_available():
|
||||
print("[QwenVL] Auto mode: Using Flash Attention 2")
|
||||
return "flash_attention_2"
|
||||
print("[QwenVL] Auto mode: Using SDPA")
|
||||
return "sdpa"
|
||||
|
||||
|
||||
|
||||
def ensure_model(model_name, require_processor=False, node_id=None, progress_label: str | None = None, hf_token: str | None = None):
|
||||
model_name = resolve_model_catalog_name(HF_ALL_MODELS, model_name)
|
||||
info = HF_ALL_MODELS.get(model_name)
|
||||
if not info:
|
||||
raise ValueError(f"Model '{model_name}' not in config")
|
||||
|
||||
# Local models have a direct path — use it, skip download
|
||||
local_path = info.get("local_path")
|
||||
if local_path:
|
||||
target = Path(local_path)
|
||||
if target.exists() and target.is_dir():
|
||||
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
|
||||
return str(target)
|
||||
enforce_model_access(info, model_name, local_exists=False, hf_token=hf_token)
|
||||
raise FileNotFoundError(f"[QwenVL] Local HF model directory not found: {target}")
|
||||
|
||||
repo_id = info.get("repo_id")
|
||||
if not repo_id:
|
||||
raise ValueError(f"Model '{model_name}' has no repo_id or local_path")
|
||||
|
||||
installed_target = _find_installed_hf_model_dir(info, require_processor=require_processor)
|
||||
if installed_target is not None:
|
||||
info["is_installed"] = True
|
||||
info["installed_path"] = str(installed_target)
|
||||
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
|
||||
return str(installed_target)
|
||||
|
||||
if COMMERCIAL_RELEASE:
|
||||
enforce_model_access(info, model_name, local_exists=False, hf_token=hf_token)
|
||||
target = Path(folder_paths.models_dir) / "LLM" / repo_id.split("/")[-1]
|
||||
if _model_snapshot_has_required_files(target, require_processor=require_processor):
|
||||
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
|
||||
return str(target)
|
||||
raise FileNotFoundError(
|
||||
f"[QwenVL] Locked commercial model is missing or incomplete: {target}"
|
||||
)
|
||||
|
||||
# Download into ComfyUI's first configured LLM location.
|
||||
models_dir = _hf_qwen_vl_dirs()[0]
|
||||
|
||||
models_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = models_dir / repo_id.split("/")[-1]
|
||||
|
||||
# Only trust an existing snapshot if it also contains the metadata needed
|
||||
# by the selected backend. This avoids reusing stale or partial downloads.
|
||||
if _model_snapshot_has_required_files(target, require_processor=require_processor):
|
||||
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
|
||||
return str(target)
|
||||
|
||||
enforce_model_access(info, repo_id, local_exists=False, hf_token=hf_token)
|
||||
|
||||
if target.exists() and target.is_dir():
|
||||
print(f"[QwenVL] Existing model snapshot is incomplete, refreshing: {target}")
|
||||
|
||||
_download_model_snapshot(
|
||||
repo_id,
|
||||
target,
|
||||
require_processor=require_processor,
|
||||
node_id=node_id,
|
||||
progress_label=progress_label or f"QwenVL HF Download: {model_name}",
|
||||
hf_token=hf_token,
|
||||
)
|
||||
|
||||
if not _model_snapshot_has_required_files(target, require_processor=require_processor):
|
||||
missing = []
|
||||
if not (target / "config.json").exists():
|
||||
missing.append("config.json")
|
||||
if require_processor and not any(
|
||||
(target / name).exists()
|
||||
for name in (
|
||||
"preprocessor_config.json",
|
||||
"processor_config.json",
|
||||
"image_processor_config.json",
|
||||
"video_preprocessor_config.json",
|
||||
)
|
||||
):
|
||||
missing.append("processor metadata")
|
||||
if not _model_snapshot_has_weights(target):
|
||||
missing.append("model weights")
|
||||
missing_str = ", ".join(missing) if missing else "required files"
|
||||
raise FileNotFoundError(
|
||||
f"[QwenVL] Downloaded model snapshot is incomplete for {repo_id}: missing {missing_str} in {target}"
|
||||
)
|
||||
|
||||
return str(target)
|
||||
|
||||
def enforce_memory(model_name, quantization, device_info):
|
||||
info = HF_ALL_MODELS.get(model_name, {})
|
||||
requirements = info.get("vram_requirement", {})
|
||||
mapping = {
|
||||
Quantization.Q4: requirements.get("4bit", 0),
|
||||
Quantization.Q8: requirements.get("8bit", 0),
|
||||
Quantization.FP16: requirements.get("full", 0),
|
||||
}
|
||||
needed = mapping.get(quantization, 0)
|
||||
if not needed:
|
||||
return quantization # Always return quantization
|
||||
|
||||
if device_info["recommended_device"] in {"cpu", "mps"}:
|
||||
needed *= 1.5
|
||||
available = device_info["system_memory"]["available"]
|
||||
else:
|
||||
available = device_info["gpu"]["free_memory"]
|
||||
|
||||
# More conservative memory management
|
||||
if needed * 1.5 > available: # More conservative threshold
|
||||
if quantization == Quantization.FP16:
|
||||
print("[QwenVL] ⚠️ Auto-switch to 8-bit due to VRAM pressure")
|
||||
return Quantization.Q8
|
||||
if quantization == Quantization.Q8:
|
||||
print("[QwenVL] ⚠️ Auto-switch to 4-bit due to VRAM pressure")
|
||||
return Quantization.Q4
|
||||
raise RuntimeError(f"Insufficient memory for {quantization.value} mode. Required: {needed * 1.5:.1f}GB, Available: {available / 1024**3:.1f}GB")
|
||||
|
||||
# Conservative memory check with safety margin
|
||||
if needed * 1.3 > available: # Reduced from 1.5 to 1.3
|
||||
if quantization == Quantization.FP16:
|
||||
print("[QwenVL] ⚠️ Auto-switch to 8-bit due to VRAM pressure")
|
||||
return Quantization.Q8
|
||||
if quantization == Quantization.Q8:
|
||||
print("[QwenVL] ⚠️ Auto-switch to 4-bit due to VRAM pressure")
|
||||
return Quantization.Q4
|
||||
print(f"[QwenVL] Memory pressure detected. Consider: 1) Use 4-bit quantization, 2) Reduce max_tokens below 1024, 3) Close other applications")
|
||||
return quantization # Always return quantization
|
||||
|
||||
def quantization_config(model_name, quantization):
|
||||
info = HF_ALL_MODELS.get(model_name, {})
|
||||
if info.get("quantized"):
|
||||
return None, None
|
||||
if quantization == Quantization.Q4:
|
||||
cfg = BitsAndBytesConfig(
|
||||
load_in_4bit=True,
|
||||
bnb_4bit_compute_dtype=torch.float16,
|
||||
bnb_4bit_quant_type="nf4",
|
||||
bnb_4bit_use_double_quant=True,
|
||||
)
|
||||
return cfg, None
|
||||
if quantization == Quantization.Q8:
|
||||
return BitsAndBytesConfig(load_in_8bit=True), None
|
||||
return None, torch.float16
|
||||
@@ -0,0 +1,665 @@
|
||||
"""Hugging Face multimodal runtime independent from ComfyUI node declarations."""
|
||||
|
||||
import gc
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
try:
|
||||
from transformers import AutoProcessor, AutoTokenizer
|
||||
except ImportError:
|
||||
AutoProcessor = None
|
||||
AutoTokenizer = None
|
||||
try:
|
||||
from transformers import AutoModelForVision2Seq
|
||||
except ImportError:
|
||||
from transformers import AutoModelForImageTextToText as AutoModelForVision2Seq
|
||||
|
||||
import folder_paths
|
||||
from AILab_StreamDisplay import TerminalStreamDisplay
|
||||
from comfy.model_management import throw_exception_if_processing_interrupted
|
||||
|
||||
from thinkingllm_core.hf_models import (
|
||||
COMMERCIAL_RELEASE,
|
||||
HF_ALL_MODELS,
|
||||
NO_PRESET_PROMPT,
|
||||
SYSTEM_PROMPTS,
|
||||
Quantization,
|
||||
ensure_cuda_vram_headroom,
|
||||
ensure_model,
|
||||
get_device_info,
|
||||
is_fp8_model,
|
||||
normalize_device_choice,
|
||||
quantization_config,
|
||||
read_hf_model_type,
|
||||
resolve_attention_mode,
|
||||
)
|
||||
from thinkingllm_core.media import (
|
||||
MASK_FOCUS_INSTRUCTION,
|
||||
apply_mask_highlight,
|
||||
get_image_hash,
|
||||
get_video_hash,
|
||||
)
|
||||
from thinkingllm_core.model_access import resolve_model_catalog_name
|
||||
from thinkingllm_core.prompt_contracts import (
|
||||
apply_qwen_soft_thinking_directive,
|
||||
apply_video_duration_context,
|
||||
ensure_video_prompt_duration,
|
||||
resolve_qwen_context_window,
|
||||
resolve_qwen_thinking_mode,
|
||||
resolve_video_duration,
|
||||
validate_minimax_reference_request,
|
||||
validate_minimax_source_duration,
|
||||
)
|
||||
from thinkingllm_core.prompt_state import (
|
||||
PROMPT_CACHE,
|
||||
build_node_input_signature,
|
||||
get_cache_key,
|
||||
get_node_saved_prompt_with_seed,
|
||||
log_llm_input,
|
||||
save_prompt_cache,
|
||||
set_node_saved_prompt,
|
||||
)
|
||||
|
||||
_STREAM_HEARTBEAT_INTERVAL_SECONDS = 3.0
|
||||
|
||||
|
||||
def _maybe_emit_hf_stream_heartbeat(stage_label: str, started_at: float, last_status_at: float, full_text: str) -> float:
|
||||
now = time.monotonic()
|
||||
if (now - last_status_at) < _STREAM_HEARTBEAT_INTERVAL_SECONDS:
|
||||
return last_status_at
|
||||
elapsed = max(0.0, now - started_at)
|
||||
print(f"[QwenVL HF] {stage_label}: still generating... ({len(full_text)} chars, {elapsed:.1f}s)")
|
||||
return now
|
||||
|
||||
|
||||
class QwenVLBase:
|
||||
def __init__(self):
|
||||
self.device_info = get_device_info()
|
||||
self.model = None
|
||||
self.processor = None
|
||||
self.tokenizer = None
|
||||
self.current_signature = None
|
||||
print(f"[QwenVL] Node on {self.device_info['device_type']}")
|
||||
|
||||
def clear(self):
|
||||
self.model = None
|
||||
self.processor = None
|
||||
self.tokenizer = None
|
||||
self.current_signature = None
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
try:
|
||||
torch.cuda.ipc_collect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def load_model(
|
||||
self,
|
||||
model_name,
|
||||
quant_value,
|
||||
attention_mode,
|
||||
use_compile,
|
||||
device_choice,
|
||||
keep_model_loaded,
|
||||
unique_id=None,
|
||||
hf_token: str | None = None,
|
||||
):
|
||||
model_name = resolve_model_catalog_name(HF_ALL_MODELS, model_name)
|
||||
model_info = HF_ALL_MODELS.get(model_name, {})
|
||||
comfy_filename = model_info.get("comfy_text_encoder")
|
||||
if comfy_filename:
|
||||
model_path = folder_paths.get_full_path_or_raise("text_encoders", comfy_filename)
|
||||
signature = (model_name, "comfy", model_path)
|
||||
if keep_model_loaded and self.model is not None and self.current_signature == signature:
|
||||
return
|
||||
self.clear()
|
||||
from comfy import sd as comfy_sd
|
||||
self.model = comfy_sd.load_clip(
|
||||
ckpt_paths=[model_path],
|
||||
embedding_directory=folder_paths.get_folder_paths("embeddings"),
|
||||
)
|
||||
self.current_signature = signature
|
||||
print(f"[QwenVL] Loaded shared ComfyUI text encoder: {comfy_filename}")
|
||||
return
|
||||
|
||||
quant = Quantization.from_value(quant_value) # Skip enforce_memory for now
|
||||
|
||||
# Safety check: ensure quant is not None
|
||||
if quant is None:
|
||||
print(f"[QwenVL] Invalid quantization value: {quant_value}, falling back to FP16")
|
||||
quant = Quantization.FP16
|
||||
|
||||
# Check if BitsAndBytes quantization is being used
|
||||
is_bnb_quantization = quant in [Quantization.Q4, Quantization.Q8]
|
||||
|
||||
# Check if this is a pre-quantized FP8 model
|
||||
is_prequantized_fp8 = is_fp8_model(model_name) or HF_ALL_MODELS.get(model_name, {}).get("quantized", False)
|
||||
|
||||
# Determine if we need to force SDPA (for FP8 or BitsAndBytes models)
|
||||
force_sdpa = is_prequantized_fp8 or is_bnb_quantization
|
||||
|
||||
# Resolve attention mode with force_sdpa flag
|
||||
attn_impl = resolve_attention_mode(attention_mode, force_sdpa=force_sdpa)
|
||||
|
||||
# Additional info messages for forced SDPA
|
||||
if force_sdpa and attention_mode in ["auto", "sage", "flash_attention_2"]:
|
||||
if is_prequantized_fp8:
|
||||
print("[QwenVL] FP8 model detected - forcing SDPA attention")
|
||||
elif is_bnb_quantization:
|
||||
print("[QwenVL] BitsAndBytes quantization detected - forcing SDPA attention")
|
||||
|
||||
print(f"[QwenVL] Attention backend selected: {attn_impl}")
|
||||
|
||||
device_requested = self.device_info["recommended_device"] if device_choice == "auto" else device_choice
|
||||
device = normalize_device_choice(device_requested)
|
||||
signature = (model_name, quant.value, attn_impl, device, use_compile)
|
||||
if keep_model_loaded and self.model is not None and self.current_signature == signature:
|
||||
ensure_cuda_vram_headroom("QwenVL", min_free_gb=1.0, min_free_ratio=0.08)
|
||||
return
|
||||
self.clear()
|
||||
model_path = ensure_model(
|
||||
model_name,
|
||||
require_processor=True,
|
||||
node_id=unique_id,
|
||||
progress_label=f"QwenVL HF Download: {model_name}",
|
||||
hf_token=hf_token,
|
||||
)
|
||||
quant_config, dtype = quantization_config(model_name, quant)
|
||||
|
||||
# Handle attention mode for loading
|
||||
# SageAttention requires loading with SDPA first, then patching
|
||||
actual_attn_impl = attn_impl
|
||||
if attn_impl == "sage":
|
||||
actual_attn_impl = "sdpa"
|
||||
|
||||
# MEMORY DEBUGGING: Check memory before loading
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
allocated_before = torch.cuda.memory_allocated() / 1024**3
|
||||
reserved_before = torch.cuda.memory_reserved() / 1024**3
|
||||
total_memory = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
||||
print(f"[QwenVL] 📊 Memory BEFORE load - Allocated: {allocated_before:.1f}GB, Reserved: {reserved_before:.1f}GB, Total: {total_memory:.1f}GB")
|
||||
|
||||
# Check what's using memory
|
||||
if allocated_before > 2.0:
|
||||
print(f"[QwenVL] ⚠️ WARNING: {allocated_before:.1f}GB already allocated before loading!")
|
||||
|
||||
# DEBUG: Print quantization config details
|
||||
print(f"[QwenVL] 🔍 DEBUG - Model: {model_name}, Quant: {quant}, Quant config: {quant_config}")
|
||||
print(f"[QwenVL] 🔍 DEBUG - Device: {device}, Dtype: {dtype}")
|
||||
print(f"[QwenVL] 🔍 DEBUG - Attention impl: {actual_attn_impl}")
|
||||
|
||||
# Quantization is back - enforce_memory was the problem
|
||||
|
||||
load_kwargs = {
|
||||
"device_map": "auto" if device != "cpu" and torch.cuda.is_available() else "cpu",
|
||||
"dtype": dtype or torch.float16,
|
||||
"attn_implementation": actual_attn_impl,
|
||||
"local_files_only": COMMERCIAL_RELEASE,
|
||||
"use_safetensors": True,
|
||||
"low_cpu_mem_usage": True,
|
||||
}
|
||||
|
||||
if device != "cpu" and torch.cuda.is_available():
|
||||
gpu_mem = torch.cuda.get_device_properties(0).total_memory
|
||||
gpu_mem_gb = max(1, int(gpu_mem / (1024**3)))
|
||||
load_kwargs["max_memory"] = {
|
||||
0: f"{gpu_mem_gb}GB",
|
||||
"cpu": "16GB",
|
||||
}
|
||||
|
||||
if quant_config:
|
||||
load_kwargs["quantization_config"] = quant_config
|
||||
|
||||
self.model = AutoModelForVision2Seq.from_pretrained(model_path, **load_kwargs).eval()
|
||||
|
||||
# Apply SageAttention patching if needed
|
||||
if attn_impl == "sage":
|
||||
try:
|
||||
from sageattention_patch import set_sage_attention
|
||||
set_sage_attention(self.model)
|
||||
print("[QwenVL] SageAttention patching applied successfully")
|
||||
except Exception as e:
|
||||
print(f"[QwenVL] SageAttention patching failed: {e}")
|
||||
print("[QwenVL] Falling back to SDPA attention")
|
||||
# Model is already loaded with SDPA, so we can continue
|
||||
|
||||
# MEMORY CLEANUP: Clear cache after model loading
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.synchronize()
|
||||
print(f"[QwenVL] GPU memory after load: {torch.cuda.memory_allocated() / 1024**3:.1f}GB / {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f}GB")
|
||||
|
||||
self.model.config.use_cache = True
|
||||
if hasattr(self.model, "generation_config"):
|
||||
self.model.generation_config.use_cache = True
|
||||
if use_compile and device.startswith("cuda") and torch.cuda.is_available():
|
||||
try:
|
||||
self.model = torch.compile(self.model, mode="reduce-overhead")
|
||||
print("[QwenVL] torch.compile enabled")
|
||||
except Exception as exc:
|
||||
print(f"[QwenVL] torch.compile skipped: {exc}")
|
||||
self.processor = AutoProcessor.from_pretrained(
|
||||
model_path,
|
||||
local_files_only=COMMERCIAL_RELEASE,
|
||||
trust_remote_code=not COMMERCIAL_RELEASE,
|
||||
)
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(
|
||||
model_path,
|
||||
local_files_only=COMMERCIAL_RELEASE,
|
||||
trust_remote_code=not COMMERCIAL_RELEASE,
|
||||
)
|
||||
# Detect architecture from config.json instead of relying on model name
|
||||
hf_model_type = read_hf_model_type(model_path)
|
||||
self.hf_model_type = hf_model_type
|
||||
self.is_qwen35 = hf_model_type in ("qwen3_5", "qwen3_5_moe", "qwen3_5_vl") if hf_model_type else "qwen3.5-" in model_name.lower()
|
||||
self.supports_qwen_soft_think = hf_model_type == "qwen3" if hf_model_type else "qwen3-" in model_name.lower()
|
||||
if self.is_qwen35:
|
||||
print(f"[QwenVL] Qwen3.5 detected (model_type={hf_model_type}): Will disable thinking in chat template.")
|
||||
self.current_signature = signature
|
||||
|
||||
@staticmethod
|
||||
def tensor_to_pil(tensor):
|
||||
if tensor is None:
|
||||
return None
|
||||
if tensor.dim() == 4:
|
||||
tensor = tensor[0]
|
||||
array = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
|
||||
if array.ndim == 2:
|
||||
return Image.fromarray(array, mode="L")
|
||||
if array.shape[-1] == 1:
|
||||
return Image.fromarray(array[..., 0], mode="L")
|
||||
if array.shape[-1] == 4:
|
||||
return Image.fromarray(array, mode="RGBA")
|
||||
return Image.fromarray(array[..., :3], mode="RGB")
|
||||
|
||||
@torch.no_grad()
|
||||
def generate(
|
||||
self,
|
||||
prompt_text,
|
||||
image,
|
||||
video,
|
||||
frame_count,
|
||||
max_tokens,
|
||||
temperature,
|
||||
top_p,
|
||||
num_beams,
|
||||
repetition_penalty,
|
||||
model_name="",
|
||||
stream_to_terminal=False,
|
||||
enable_thinking=True,
|
||||
seed=0,
|
||||
preset_name="",
|
||||
):
|
||||
model_info = HF_ALL_MODELS.get(model_name, {})
|
||||
native_family = model_info.get("native_family")
|
||||
if native_family:
|
||||
if num_beams != 1:
|
||||
raise ValueError("[QwenVL] ComfyUI text encoders support num_beams=1 only")
|
||||
if native_family == "gemma3" and video is not None:
|
||||
raise ValueError("[QwenVL] Gemma 3 shared text encoders support text and image input, not video")
|
||||
|
||||
tokenize_kwargs = {"skip_template": False, "thinking": bool(enable_thinking)}
|
||||
if native_family == "gemma3":
|
||||
tokenize_kwargs["image"] = image[:1] if image is not None else None
|
||||
else:
|
||||
images = []
|
||||
if image is not None:
|
||||
if image.dim() == 4 and image.shape[0] > 1:
|
||||
print(f"[QwenVL] IMAGE input contains {image.shape[0]} items; using the first item only. Use the video input for multi-frame analysis.")
|
||||
images.append(image[:1] if image.dim() == 4 else image)
|
||||
if video is not None:
|
||||
frame_indexes = list(range(video.shape[0]))
|
||||
if len(frame_indexes) > frame_count:
|
||||
frame_indexes = np.linspace(0, len(frame_indexes) - 1, frame_count, dtype=int).tolist()
|
||||
images.extend(video[index:index + 1] for index in frame_indexes)
|
||||
tokenize_kwargs["images"] = images
|
||||
|
||||
throw_exception_if_processing_interrupted()
|
||||
log_llm_input(
|
||||
"QwenVL ComfyUI Native",
|
||||
"INITIAL GENERATION",
|
||||
preset_name,
|
||||
prompt_text,
|
||||
media={
|
||||
"image": 1 if image is not None else 0,
|
||||
"video_frames": max(0, len(tokenize_kwargs.get("images", [])) - (1 if image is not None else 0)),
|
||||
},
|
||||
)
|
||||
tokens = self.model.tokenize(prompt_text, **tokenize_kwargs)
|
||||
output_tokens = self.model.generate(
|
||||
tokens,
|
||||
do_sample=True,
|
||||
max_length=max_tokens,
|
||||
temperature=temperature,
|
||||
top_k=50,
|
||||
top_p=top_p,
|
||||
min_p=0.0,
|
||||
repetition_penalty=repetition_penalty,
|
||||
seed=seed,
|
||||
presence_penalty=0.0,
|
||||
)
|
||||
throw_exception_if_processing_interrupted()
|
||||
raw_text = self.model.decode(output_tokens).strip()
|
||||
if not raw_text:
|
||||
raise RuntimeError(
|
||||
"[QwenVL] The model ended generation without producing text. Change the seed or add a more "
|
||||
"specific custom prompt; the empty result was not cached."
|
||||
)
|
||||
if stream_to_terminal:
|
||||
print("[QwenVL] ComfyUI native generation completed (live token streaming is unavailable):")
|
||||
print(raw_text)
|
||||
return raw_text, raw_text
|
||||
|
||||
# Memory optimization: clear cache before generation
|
||||
ensure_cuda_vram_headroom("QwenVL", min_free_gb=1.0, min_free_ratio=0.08)
|
||||
supports_soft_think = getattr(self, "supports_qwen_soft_think", False)
|
||||
is_qwen35 = getattr(self, "is_qwen35", False)
|
||||
context_window = resolve_qwen_context_window(getattr(self.model, "config", None))
|
||||
|
||||
def _build_processed(thinking_enabled: bool):
|
||||
effective_prompt_text = apply_qwen_soft_thinking_directive(
|
||||
prompt_text,
|
||||
thinking_enabled,
|
||||
supports_soft_switch=supports_soft_think,
|
||||
)
|
||||
conversation = [{"role": "user", "content": []}]
|
||||
if image is not None:
|
||||
if image.dim() == 4 and image.shape[0] > 1:
|
||||
print(f"[QwenVL] IMAGE input contains {image.shape[0]} items; using the first item only. Use the video input for multi-frame analysis.")
|
||||
conversation[0]["content"].append({"type": "image", "image": self.tensor_to_pil(image)})
|
||||
if video is not None:
|
||||
frames = [self.tensor_to_pil(frame) for frame in video]
|
||||
if len(frames) > frame_count:
|
||||
idx = np.linspace(0, len(frames) - 1, frame_count, dtype=int)
|
||||
frames = [frames[i] for i in idx]
|
||||
if frames:
|
||||
conversation[0]["content"].append({"type": "video", "video": frames})
|
||||
conversation[0]["content"].append({"type": "text", "text": effective_prompt_text})
|
||||
|
||||
chat_kwargs = {}
|
||||
if is_qwen35 or supports_soft_think:
|
||||
chat_kwargs["enable_thinking"] = thinking_enabled
|
||||
|
||||
chat = self.processor.apply_chat_template(
|
||||
conversation,
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
**chat_kwargs
|
||||
)
|
||||
|
||||
images = [item["image"] for item in conversation[0]["content"] if item["type"] == "image"]
|
||||
video_frames = [frame for item in conversation[0]["content"] if item["type"] == "video" for frame in item["video"]]
|
||||
videos = [video_frames] if video_frames else None
|
||||
processed = self.processor(text=chat, images=images or None, videos=videos, return_tensors="pt")
|
||||
return effective_prompt_text, chat, processed, {
|
||||
"image": len(images),
|
||||
"video_frames": len(video_frames),
|
||||
}
|
||||
|
||||
requested_thinking = bool(enable_thinking)
|
||||
effective_prompt_text, chat, processed, media = _build_processed(requested_thinking)
|
||||
input_ids = processed.get("input_ids")
|
||||
prompt_tokens = int(input_ids.shape[-1]) if torch.is_tensor(input_ids) else 0
|
||||
effective_thinking = resolve_qwen_thinking_mode(
|
||||
enable_thinking,
|
||||
max_tokens,
|
||||
label="QwenVL HF",
|
||||
prompt_tokens=prompt_tokens,
|
||||
context_window=context_window,
|
||||
quiet=stream_to_terminal,
|
||||
)
|
||||
if effective_thinking != requested_thinking:
|
||||
effective_prompt_text, chat, processed, media = _build_processed(effective_thinking)
|
||||
if supports_soft_think:
|
||||
directive = "/think" if effective_thinking else "/no_think"
|
||||
print(f"[QwenVL] Qwen3 detected: Thinking {'enabled' if effective_thinking else 'disabled'} via chat template and {directive}.")
|
||||
|
||||
# Move to device more efficiently
|
||||
model_device = next(self.model.parameters()).device
|
||||
model_inputs = {
|
||||
key: value.to(model_device, non_blocking=True) if torch.is_tensor(value) else value
|
||||
for key, value in processed.items()
|
||||
}
|
||||
stop_tokens = [self.tokenizer.eos_token_id]
|
||||
if hasattr(self.tokenizer, "eot_id") and self.tokenizer.eot_id is not None:
|
||||
stop_tokens.append(self.tokenizer.eot_id)
|
||||
|
||||
# Memory-efficient generation parameters
|
||||
kwargs = {
|
||||
"max_new_tokens": max_tokens,
|
||||
"repetition_penalty": repetition_penalty,
|
||||
"num_beams": num_beams,
|
||||
"eos_token_id": stop_tokens,
|
||||
"pad_token_id": self.tokenizer.pad_token_id,
|
||||
}
|
||||
if num_beams == 1:
|
||||
kwargs.update({"do_sample": True, "temperature": temperature, "top_p": top_p})
|
||||
# --- Qwen3.5 Heretic Logic: Top K ---
|
||||
if is_qwen35:
|
||||
kwargs["top_k"] = 20
|
||||
print("[QwenVL] Qwen3.5 detected: Forcing top_k=20 for recommended tuning.")
|
||||
else:
|
||||
kwargs["do_sample"] = False
|
||||
|
||||
log_llm_input(
|
||||
"QwenVL HF",
|
||||
"INITIAL GENERATION",
|
||||
preset_name,
|
||||
effective_prompt_text,
|
||||
formatted_text=chat,
|
||||
media=media,
|
||||
)
|
||||
|
||||
# Optional: staged readable terminal streaming
|
||||
if stream_to_terminal:
|
||||
try:
|
||||
from AILab_StreamDisplay import TerminalStreamDisplay
|
||||
from transformers import TextIteratorStreamer
|
||||
from threading import Thread
|
||||
stage_label = "STREAMING"
|
||||
stream_display = TerminalStreamDisplay("QwenVL HF", suppress_planning=True, compact=True)
|
||||
streamer = TextIteratorStreamer(self.tokenizer, skip_prompt=True, skip_special_tokens=True)
|
||||
kwargs["streamer"] = streamer
|
||||
thread = Thread(target=self.model.generate, kwargs={**model_inputs, **kwargs})
|
||||
stream_display.start_stage(stage_label)
|
||||
thread.start()
|
||||
full_streamed = ""
|
||||
for token_str in streamer:
|
||||
if token_str:
|
||||
throw_exception_if_processing_interrupted()
|
||||
full_streamed += token_str
|
||||
stream_display.push_compact(token_str)
|
||||
thread.join()
|
||||
stream_display.end_compact()
|
||||
stream_display.end_stage()
|
||||
if not full_streamed.strip():
|
||||
raise RuntimeError("[QwenVL] HF streaming returned empty response")
|
||||
cleaned_text = full_streamed.strip()
|
||||
raw_text = full_streamed.strip()
|
||||
return cleaned_text, raw_text
|
||||
except ImportError:
|
||||
print("[QwenVL] TextIteratorStreamer not available — falling back to non-streaming")
|
||||
stream_to_terminal = False
|
||||
|
||||
# Generate with interrupt support via background thread
|
||||
import threading, queue as qmod
|
||||
abort_event = threading.Event()
|
||||
output_queue: qmod.Queue = qmod.Queue()
|
||||
worker_error: Exception | None = None
|
||||
|
||||
def _generate_worker():
|
||||
nonlocal worker_error
|
||||
try:
|
||||
result = self.model.generate(**model_inputs, **kwargs)
|
||||
output_queue.put(("ok", result))
|
||||
except Exception as exc:
|
||||
worker_error = exc
|
||||
output_queue.put(("error", None))
|
||||
|
||||
worker = threading.Thread(target=_generate_worker, daemon=True)
|
||||
worker.start()
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
kind, result = output_queue.get(timeout=0.25)
|
||||
except qmod.Empty:
|
||||
throw_exception_if_processing_interrupted()
|
||||
continue
|
||||
if kind == "error":
|
||||
break
|
||||
outputs = result
|
||||
break
|
||||
finally:
|
||||
abort_event.set()
|
||||
worker.join(timeout=5.0)
|
||||
if worker_error:
|
||||
raise worker_error
|
||||
throw_exception_if_processing_interrupted()
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.synchronize()
|
||||
input_len = model_inputs["input_ids"].shape[-1]
|
||||
raw_text = self.tokenizer.decode(outputs[0, input_len:], skip_special_tokens=True).strip()
|
||||
cleaned_text = raw_text # generate() returns raw; caller cleans if needed
|
||||
return cleaned_text, raw_text
|
||||
|
||||
def run(self, model_name, quantization, preset_prompt, custom_prompt, image, video, frame_count, max_tokens, temperature, top_p, num_beams, repetition_penalty, seed, keep_model_loaded, attention_mode, use_torch_compile, device, keep_last_prompt=False, unique_id=None, extra_pnginfo=None, node_class="QwenVL", stream_to_terminal=False, enable_thinking=True, hf_token: str | None = None, mask=None, llm_input_preset_name: str | None = None, duration_seconds=5.0):
|
||||
model_name = resolve_model_catalog_name(HF_ALL_MODELS, model_name)
|
||||
validate_minimax_reference_request(preset_prompt, custom_prompt)
|
||||
duration_preset_name = llm_input_preset_name or preset_prompt
|
||||
resolved_duration = resolve_video_duration(duration_preset_name, duration_seconds)
|
||||
validate_minimax_source_duration(duration_preset_name, duration_seconds, custom_prompt)
|
||||
effective_duration_seconds = resolved_duration["effective_seconds"] if resolved_duration else None
|
||||
duration_signature = ({
|
||||
"effective_duration_seconds": effective_duration_seconds,
|
||||
"video_prompt_contract_version": resolved_duration.get("contract_version"),
|
||||
} if resolved_duration else {})
|
||||
torch.manual_seed(seed)
|
||||
image, mask_hash = apply_mask_highlight(image, mask)
|
||||
image_hash = get_image_hash(image)
|
||||
if mask_hash:
|
||||
image_hash = f"{image_hash}:{mask_hash}"
|
||||
video_hash = get_video_hash(video)
|
||||
input_signature = build_node_input_signature(
|
||||
model_name=model_name,
|
||||
quantization=quantization,
|
||||
preset_prompt=preset_prompt,
|
||||
custom_prompt=custom_prompt,
|
||||
image_hash=image_hash,
|
||||
video_hash=video_hash,
|
||||
frame_count=frame_count,
|
||||
attention_mode=attention_mode,
|
||||
use_torch_compile=bool(use_torch_compile),
|
||||
device=device,
|
||||
enable_thinking=bool(enable_thinking),
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
repetition_penalty=repetition_penalty,
|
||||
**duration_signature,
|
||||
)
|
||||
|
||||
# Auto-retrieve saved prompt when seed is fixed
|
||||
saved = get_node_saved_prompt_with_seed(node_class, unique_id, extra_pnginfo, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
|
||||
if saved and not stream_to_terminal:
|
||||
print(f"[QwenVL] Preset {llm_input_preset_name or preset_prompt}: fixed seed {seed} matched; no LLM call was made.")
|
||||
return (saved,)
|
||||
if saved and stream_to_terminal:
|
||||
pass
|
||||
if keep_last_prompt:
|
||||
print(f"[QwenVL] Keep last prompt enabled — looking up per-node state")
|
||||
if saved:
|
||||
print(f"[QwenVL] Preset {llm_input_preset_name or preset_prompt}: using saved prompt; no LLM call was made.")
|
||||
return (saved,)
|
||||
else:
|
||||
print(f"[QwenVL] No per-node prompt found, returning empty")
|
||||
return ("",)
|
||||
|
||||
# Always generate unless keep_last_prompt was requested
|
||||
if not stream_to_terminal:
|
||||
print(f"[QwenVL] Generating new prompt")
|
||||
|
||||
prompt_template = "" if preset_prompt == NO_PRESET_PROMPT else SYSTEM_PROMPTS.get(preset_prompt, preset_prompt)
|
||||
|
||||
# Generate cache key with all inputs including seed
|
||||
cache_key = get_cache_key(model_name, preset_prompt, custom_prompt, image_hash, video_hash, seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking, effective_duration_seconds=effective_duration_seconds, video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None)
|
||||
|
||||
# Check cache first (only for random mode)
|
||||
if cache_key in PROMPT_CACHE:
|
||||
cached_text = PROMPT_CACHE[cache_key].get("text", "")
|
||||
if cached_text:
|
||||
print(f"[QwenVL] Preset {llm_input_preset_name or preset_prompt}: cache hit for seed {seed}; no LLM call was made.")
|
||||
return (cached_text,)
|
||||
|
||||
if custom_prompt and custom_prompt.strip():
|
||||
# Combine user input with template - custom prompt first for priority
|
||||
prompt = f"{custom_prompt.strip()}\n\n{prompt_template}" if prompt_template else custom_prompt.strip()
|
||||
else:
|
||||
prompt = prompt_template
|
||||
if mask_hash:
|
||||
prompt = f"{prompt}\n\n{MASK_FOCUS_INSTRUCTION}".strip()
|
||||
prompt = apply_video_duration_context(prompt, duration_preset_name, duration_seconds)
|
||||
|
||||
self.load_model(
|
||||
model_name,
|
||||
quantization,
|
||||
attention_mode,
|
||||
use_torch_compile,
|
||||
device,
|
||||
keep_model_loaded,
|
||||
unique_id=unique_id,
|
||||
hf_token=hf_token,
|
||||
)
|
||||
hf_token = None
|
||||
try:
|
||||
text, raw_text = self.generate(
|
||||
prompt,
|
||||
image,
|
||||
video,
|
||||
frame_count,
|
||||
max_tokens,
|
||||
temperature,
|
||||
top_p,
|
||||
num_beams,
|
||||
repetition_penalty,
|
||||
model_name=model_name,
|
||||
stream_to_terminal=stream_to_terminal,
|
||||
enable_thinking=enable_thinking,
|
||||
seed=seed,
|
||||
preset_name=llm_input_preset_name or preset_prompt,
|
||||
)
|
||||
text = ensure_video_prompt_duration(
|
||||
text,
|
||||
duration_preset_name,
|
||||
duration_seconds,
|
||||
source_prompt_text=custom_prompt,
|
||||
)
|
||||
|
||||
# Cache the generated text
|
||||
PROMPT_CACHE[cache_key] = {
|
||||
"text": text,
|
||||
"timestamp": torch.cuda.Event().record() if torch.cuda.is_available() else None,
|
||||
"model": model_name,
|
||||
"preset": preset_prompt,
|
||||
"seed": seed,
|
||||
"image_hash": image_hash,
|
||||
"mask_hash": mask_hash,
|
||||
"video_hash": video_hash,
|
||||
**duration_signature,
|
||||
}
|
||||
save_prompt_cache() # Save cache to file
|
||||
|
||||
print(f"[QwenVL] Cached new prompt for seed {seed}: {cache_key[:8]}...")
|
||||
|
||||
# Save the generated prompt for future per-node keep-last-prompt
|
||||
set_node_saved_prompt(node_class, unique_id, extra_pnginfo, text, raw_trace=raw_text, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
|
||||
print(f"[QwenVL] Saved per-node prompt: {text[:50]}...")
|
||||
|
||||
return (text,)
|
||||
finally:
|
||||
if not keep_model_loaded:
|
||||
self.clear()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Media hashing and mask-focus helpers shared by HF and GGUF backends."""
|
||||
|
||||
import hashlib
|
||||
|
||||
import torch
|
||||
|
||||
MASK_FOCUS_INSTRUCTION = (
|
||||
"The image has a mask highlight: the selected area is shown at normal brightness and the surrounding "
|
||||
"context is dimmed. Analyze and describe only the normal-brightness selected area. Use the dimmed "
|
||||
"surroundings only for spatial context and do not describe them."
|
||||
)
|
||||
|
||||
MASK_RECONSTRUCTION_INSTRUCTION = (
|
||||
"The selected area is deliberately concealed and contains no usable information about its former "
|
||||
"contents. Never identify or describe anything that may have occupied it. Use only the visible "
|
||||
"surroundings to infer the most natural scene continuation through that area, and describe the "
|
||||
"completed scene without mentioning masks, removal, editing, or missing content."
|
||||
)
|
||||
|
||||
|
||||
def tensor_to_pil(tensor):
|
||||
"""Convert tensor to PIL Image with memory optimization"""
|
||||
if tensor is None:
|
||||
return None
|
||||
try:
|
||||
if tensor.dim() == 4:
|
||||
tensor = tensor[0]
|
||||
|
||||
# More aggressive memory management
|
||||
if tensor.is_floating_point():
|
||||
# Scale to 0-255 range more efficiently
|
||||
tensor = tensor.clamp(0, 1)
|
||||
|
||||
# Reduce sample size for memory efficiency
|
||||
array = tensor.cpu().numpy()
|
||||
if tensor.numel() > 0:
|
||||
# Take smaller sample for hash to save memory
|
||||
sample_size = min(50, tensor.numel() // 4) # Reduced from 100
|
||||
sample_pixels = array.flatten()[:sample_size].tolist() if array.size > 0 else []
|
||||
else:
|
||||
sample_pixels = []
|
||||
|
||||
content = f"{tensor.shape}_{tensor.dtype}_{sample_pixels[:10]}"
|
||||
return hashlib.md5(content.encode()).hexdigest()[:16]
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_image_hash(image):
|
||||
"""Generate hash for image tensor"""
|
||||
if image is None:
|
||||
return None
|
||||
try:
|
||||
# Use image tensor properties for hash
|
||||
shape = str(image.shape)
|
||||
dtype = str(image.dtype)
|
||||
# Sample a few pixels for content hash (avoid full tensor for performance)
|
||||
if len(image.shape) >= 3:
|
||||
sample_pixels = image.flatten()[:100].tolist() if image.numel() > 0 else []
|
||||
else:
|
||||
sample_pixels = image.flatten().tolist() if image.numel() > 0 else []
|
||||
|
||||
content = f"{shape}_{dtype}_{sample_pixels[:10]}" # Limit sample size
|
||||
return hashlib.md5(content.encode()).hexdigest()[:16]
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def apply_mask_highlight(image, mask, mode="focus"):
|
||||
"""Prepare a masked image for focus analysis or background reconstruction."""
|
||||
if mask is None:
|
||||
return image, None
|
||||
if image is None:
|
||||
raise ValueError("[QwenVL] A mask requires an image input.")
|
||||
if not torch.is_tensor(image) or image.ndim not in (3, 4):
|
||||
raise ValueError("[QwenVL] IMAGE must have shape [H,W,C] or [B,H,W,C].")
|
||||
if not torch.is_tensor(mask):
|
||||
raise ValueError("[QwenVL] MASK must be a tensor.")
|
||||
if mode not in {"focus", "reconstruct"}:
|
||||
raise ValueError("[QwenVL] mask mode must be 'focus' or 'reconstruct'.")
|
||||
|
||||
if mask.ndim == 2:
|
||||
selected_mask = mask
|
||||
elif mask.ndim == 3:
|
||||
selected_mask = mask[0]
|
||||
else:
|
||||
raise ValueError("[QwenVL] MASK must have shape [H,W] or [B,H,W].")
|
||||
|
||||
selected_mask = selected_mask.detach().float().clamp(0.0, 1.0)
|
||||
if not torch.isfinite(selected_mask).all():
|
||||
raise ValueError("[QwenVL] MASK contains non-finite values.")
|
||||
if not torch.any(selected_mask > 0):
|
||||
raise ValueError("[QwenVL] MASK is empty; select at least one pixel.")
|
||||
|
||||
frame = image[0] if image.ndim == 4 else image
|
||||
resized_mask = torch.nn.functional.interpolate(
|
||||
selected_mask[None, None],
|
||||
size=frame.shape[:2],
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)[0, 0].to(device=frame.device, dtype=frame.dtype)
|
||||
highlighted_frame = frame.clone()
|
||||
color_channels = min(int(frame.shape[-1]), 3)
|
||||
if mode == "reconstruct":
|
||||
# Fully hide the selected subject, including anti-aliased mask edges, so the
|
||||
# vision model can reason from the surrounding scene instead of naming it.
|
||||
binary_mask = (resized_mask > 0.01).to(dtype=frame.dtype)
|
||||
expanded_mask = torch.nn.functional.max_pool2d(
|
||||
binary_mask[None, None], kernel_size=9, stride=1, padding=4,
|
||||
)[0, 0]
|
||||
visible = 1.0 - expanded_mask
|
||||
visible_weight = visible.sum()
|
||||
if float(visible_weight.detach().cpu()) > 0:
|
||||
fill = (
|
||||
frame[..., :color_channels] * visible.unsqueeze(-1)
|
||||
).sum(dim=(0, 1)) / visible_weight
|
||||
else:
|
||||
fill = torch.full(
|
||||
(color_channels,), 0.5, device=frame.device, dtype=frame.dtype,
|
||||
)
|
||||
highlighted_frame[..., :color_channels] = (
|
||||
frame[..., :color_channels] * visible.unsqueeze(-1)
|
||||
+ fill.view(1, 1, -1) * expanded_mask.unsqueeze(-1)
|
||||
)
|
||||
else:
|
||||
brightness = 0.2 + 0.8 * resized_mask
|
||||
highlighted_frame[..., :color_channels] *= brightness.unsqueeze(-1)
|
||||
highlighted = image.clone()
|
||||
if image.ndim == 4:
|
||||
highlighted[0] = highlighted_frame
|
||||
else:
|
||||
highlighted = highlighted_frame
|
||||
|
||||
digest = hashlib.md5()
|
||||
digest.update(mode.encode("ascii"))
|
||||
digest.update(str(tuple(mask.shape)).encode("ascii"))
|
||||
digest.update(mask.detach().contiguous().cpu().numpy().tobytes())
|
||||
return highlighted, digest.hexdigest()[:16]
|
||||
|
||||
def get_video_hash(video):
|
||||
"""Generate hash for video tensor (same as image)"""
|
||||
return get_image_hash(video)
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Model catalog policy and authenticated Hugging Face download helpers."""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
|
||||
except ImportError:
|
||||
from huggingface_hub import snapshot_download
|
||||
HfApi = None
|
||||
hf_hub_download = None
|
||||
try:
|
||||
from tqdm.auto import tqdm
|
||||
except ImportError:
|
||||
tqdm = None
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
except Exception:
|
||||
ProgressBar = None
|
||||
try:
|
||||
from server import PromptServer
|
||||
except Exception:
|
||||
PromptServer = None
|
||||
|
||||
COMMERCIAL_RELEASE = os.environ.get("THINKINGLLM_COMMERCIAL_RELEASE") == "1"
|
||||
_DOWNLOAD_STATUS_INTERVAL_SECONDS = 0.25
|
||||
_HF_TOKEN_ENV_VARS = ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN")
|
||||
VALID_COMMERCIAL_STATUSES = {"cleared", "external_gated", "unclear", "noncommercial"}
|
||||
MODEL_STATUS_PRESENTATION = {
|
||||
"cleared": (0, "✅ Commercial | "),
|
||||
"external_gated": (1, "🔑 External / gated | "),
|
||||
"local": (2, "📁 Local / user-supplied | "),
|
||||
"unclear": (3, "⚠ Rights unclear | "),
|
||||
"noncommercial": (4, "🚫 Non-commercial | "),
|
||||
}
|
||||
MODEL_INSTALLED_SUFFIX = " [installed]"
|
||||
MODEL_LOCAL_PREFIX = "[local] "
|
||||
_PROCESSOR_CONFIG_FILENAMES = (
|
||||
"preprocessor_config.json",
|
||||
"processor_config.json",
|
||||
"image_processor_config.json",
|
||||
"video_preprocessor_config.json",
|
||||
)
|
||||
|
||||
|
||||
def _format_download_size(num_bytes) -> str:
|
||||
if num_bytes is None:
|
||||
return "? B"
|
||||
value = float(num_bytes)
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
for unit in units:
|
||||
if value < 1024.0 or unit == units[-1]:
|
||||
if unit == "B":
|
||||
return f"{int(value)} {unit}"
|
||||
return f"{value:.1f} {unit}"
|
||||
value /= 1024.0
|
||||
return f"{value:.1f} TB"
|
||||
|
||||
|
||||
class _DownloadProgressReporter:
|
||||
def __init__(self, node_id=None, label: str = "QwenVL Download", repo_id: str | None = None):
|
||||
self.node_id = node_id
|
||||
self.label = label
|
||||
self.repo_id = repo_id
|
||||
self.progress_bar = ProgressBar(1, node_id=node_id) if ProgressBar is not None else None
|
||||
self._last_text_at = 0.0
|
||||
self._last_terminal_at = 0.0
|
||||
self._last_percent = None
|
||||
|
||||
def _emit_ui_text(self, text: str, *, force: bool = False) -> None:
|
||||
if PromptServer is None or self.node_id is None:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if not force and (now - self._last_text_at) < _DOWNLOAD_STATUS_INTERVAL_SECONDS:
|
||||
return
|
||||
PromptServer.instance.send_progress_text(text, self.node_id)
|
||||
self._last_text_at = now
|
||||
|
||||
def _emit_terminal_text(self, text: str, *, force: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
if not force and (now - self._last_terminal_at) < _DOWNLOAD_STATUS_INTERVAL_SECONDS:
|
||||
return
|
||||
print(text)
|
||||
self._last_terminal_at = now
|
||||
|
||||
def _build_message(self, stage: str, detail: str | None = None, current: int | float | None = None, total: int | float | None = None) -> str:
|
||||
lines = [self.label, f"Stage: {stage}"]
|
||||
if self.repo_id:
|
||||
lines.append(f"Source: {self.repo_id}")
|
||||
if detail:
|
||||
lines.append(f"File: {detail}")
|
||||
if total and total > 0:
|
||||
percent = int(min(100, max(0, round((float(current or 0) / float(total)) * 100))))
|
||||
lines.append(f"Progress: {percent}% ({_format_download_size(current)} / {_format_download_size(total)})")
|
||||
elif current is not None:
|
||||
lines.append(f"Downloaded: {_format_download_size(current)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def stage(self, stage: str, *, detail: str | None = None, force: bool = True) -> None:
|
||||
message = self._build_message(stage, detail=detail)
|
||||
self._emit_ui_text(message, force=force)
|
||||
self._emit_terminal_text(f"[{self.label}] {stage}{f' - {detail}' if detail else ''}", force=force)
|
||||
|
||||
def progress(self, stage: str, *, detail: str | None = None, current: int | float | None = None, total: int | float | None = None, force: bool = False) -> None:
|
||||
if self.progress_bar is not None and total is not None and total > 0:
|
||||
self.progress_bar.update_absolute(min(float(current or 0), float(total)), total=float(total))
|
||||
percent = None
|
||||
if total and total > 0:
|
||||
percent = int(min(100, max(0, round((float(current or 0) / float(total)) * 100))))
|
||||
message = self._build_message(stage, detail=detail, current=current, total=total)
|
||||
self._emit_ui_text(message, force=force or percent != self._last_percent)
|
||||
suffix = ""
|
||||
if percent is not None:
|
||||
suffix = f" {percent}% ({_format_download_size(current)} / {_format_download_size(total)})"
|
||||
elif current is not None:
|
||||
suffix = f" {_format_download_size(current)}"
|
||||
self._emit_terminal_text(f"[{self.label}] {stage}{f' - {detail}' if detail else ''}{suffix}", force=force or percent != self._last_percent)
|
||||
self._last_percent = percent
|
||||
|
||||
def finish(self, *, detail: str | None = None, force: bool = True) -> None:
|
||||
if self.progress_bar is not None:
|
||||
self.progress_bar.update_absolute(1, total=1)
|
||||
message = self._build_message("Completed", detail=detail, current=1, total=1)
|
||||
self._emit_ui_text(message, force=force)
|
||||
self._emit_terminal_text(f"[{self.label}] Completed{f' - {detail}' if detail else ''}", force=force)
|
||||
|
||||
def fail(self, detail: str) -> None:
|
||||
message = self._build_message("Failed", detail=detail)
|
||||
self._emit_ui_text(message, force=True)
|
||||
self._emit_terminal_text(f"[{self.label}] Failed - {detail}", force=True)
|
||||
|
||||
def make_tqdm_class(self):
|
||||
if tqdm is None:
|
||||
return None
|
||||
reporter = self
|
||||
|
||||
class _NodeDownloadTqdm(tqdm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
reporter.progress(
|
||||
"Downloading",
|
||||
detail=getattr(self, "desc", None),
|
||||
current=getattr(self, "n", 0),
|
||||
total=getattr(self, "total", None),
|
||||
force=True,
|
||||
)
|
||||
|
||||
def update(self, n=1):
|
||||
result = super().update(n)
|
||||
reporter.progress(
|
||||
"Downloading",
|
||||
detail=getattr(self, "desc", None),
|
||||
current=getattr(self, "n", 0),
|
||||
total=getattr(self, "total", None),
|
||||
)
|
||||
return result
|
||||
|
||||
def refresh(self, *args, **kwargs):
|
||||
result = super().refresh(*args, **kwargs)
|
||||
reporter.progress(
|
||||
"Downloading",
|
||||
detail=getattr(self, "desc", None),
|
||||
current=getattr(self, "n", 0),
|
||||
total=getattr(self, "total", None),
|
||||
)
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
reporter.progress(
|
||||
"Downloading",
|
||||
detail=getattr(self, "desc", None),
|
||||
current=(getattr(self, "total", None) or getattr(self, "n", 0)),
|
||||
total=(getattr(self, "total", None) or getattr(self, "n", 0) or None),
|
||||
force=True,
|
||||
)
|
||||
return super().close()
|
||||
|
||||
return _NodeDownloadTqdm
|
||||
|
||||
|
||||
def _iter_hf_token_candidates(hf_token: str | None):
|
||||
token = str(hf_token or "").strip()
|
||||
if token:
|
||||
yield token
|
||||
for env_name in _HF_TOKEN_ENV_VARS:
|
||||
token = str(os.environ.get(env_name) or "").strip()
|
||||
if token:
|
||||
yield token
|
||||
|
||||
|
||||
def _clean_hf_token(hf_token: str | None) -> str | None:
|
||||
return next(_iter_hf_token_candidates(hf_token), None)
|
||||
|
||||
|
||||
def normalize_commercial_status(info: dict | None) -> str:
|
||||
info = info if isinstance(info, dict) else {}
|
||||
if info.get("is_local"):
|
||||
return "local"
|
||||
status = str(info.get("commercial_status") or "unclear").strip().lower()
|
||||
if status == "local":
|
||||
return "local"
|
||||
return status if status in VALID_COMMERCIAL_STATUSES else "unclear"
|
||||
|
||||
|
||||
def _model_display_name(name: str, info: dict | None) -> str:
|
||||
info = info if isinstance(info, dict) else {}
|
||||
local_display_name = str(info.get("local_display_name") or "").strip()
|
||||
if info.get("is_local") and local_display_name:
|
||||
label = f"{MODEL_LOCAL_PREFIX}{local_display_name}"
|
||||
storage_label = str(info.get("storage_label") or "").strip()
|
||||
if storage_label:
|
||||
label = f"{label} ({storage_label})"
|
||||
return label
|
||||
if info.get("is_installed") and not info.get("is_local") and not name.endswith(MODEL_INSTALLED_SUFFIX):
|
||||
return f"{name}{MODEL_INSTALLED_SUFFIX}"
|
||||
return name
|
||||
|
||||
|
||||
def model_catalog_label(name: str, info: dict | None) -> str:
|
||||
display_name = _model_display_name(name, info)
|
||||
if not COMMERCIAL_RELEASE:
|
||||
return display_name
|
||||
return f"{MODEL_STATUS_PRESENTATION[normalize_commercial_status(info)][1]}{display_name}"
|
||||
|
||||
|
||||
def model_catalog_options(models: dict[str, dict], predicate=None) -> list[str]:
|
||||
entries = [
|
||||
(name, info)
|
||||
for name, info in models.items()
|
||||
if predicate is None or predicate(name, info or {})
|
||||
]
|
||||
if COMMERCIAL_RELEASE:
|
||||
entries.sort(
|
||||
key=lambda item: (
|
||||
MODEL_STATUS_PRESENTATION[normalize_commercial_status(item[1])][0],
|
||||
item[0].casefold(),
|
||||
),
|
||||
)
|
||||
return [model_catalog_label(name, info) for name, info in entries]
|
||||
|
||||
|
||||
def resolve_model_catalog_name(models: dict[str, dict], selected: str) -> str:
|
||||
if selected in models:
|
||||
return selected
|
||||
for name, info in models.items():
|
||||
prefix = MODEL_STATUS_PRESENTATION[normalize_commercial_status(info)][1]
|
||||
if selected in {model_catalog_label(name, info), f"{prefix}{name}"}:
|
||||
return name
|
||||
undecorated = selected
|
||||
for _order, prefix in MODEL_STATUS_PRESENTATION.values():
|
||||
if undecorated.startswith(prefix):
|
||||
undecorated = undecorated[len(prefix):]
|
||||
break
|
||||
if undecorated.endswith(MODEL_INSTALLED_SUFFIX):
|
||||
legacy_name = undecorated[:-len(MODEL_INSTALLED_SUFFIX)]
|
||||
if legacy_name in models:
|
||||
return legacy_name
|
||||
return selected
|
||||
|
||||
|
||||
def _catalog_filename_candidates(info: dict) -> set[str]:
|
||||
candidates: set[str] = set()
|
||||
for key in ("filename", "local_filenames", "local_model_files"):
|
||||
value = info.get(key)
|
||||
if isinstance(value, str):
|
||||
values = [value]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
values = value
|
||||
elif isinstance(value, dict):
|
||||
values = [*value.keys(), *value.values()]
|
||||
else:
|
||||
values = []
|
||||
for candidate in values:
|
||||
if isinstance(candidate, (list, tuple, set)):
|
||||
nested = candidate
|
||||
else:
|
||||
nested = [candidate]
|
||||
for item in nested:
|
||||
filename = Path(str(item)).name.casefold()
|
||||
if filename:
|
||||
candidates.add(filename)
|
||||
return candidates
|
||||
|
||||
|
||||
def mark_catalog_files_installed(models: dict[str, dict], search_dirs) -> None:
|
||||
"""Mark catalog entries found in any configured directory without changing their keys."""
|
||||
installed_files: dict[str, Path] = {}
|
||||
for directory in search_dirs:
|
||||
directory = Path(directory)
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
continue
|
||||
try:
|
||||
for path in directory.rglob("*.gguf"):
|
||||
if path.is_file():
|
||||
installed_files.setdefault(path.name.casefold(), path)
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
|
||||
for info in models.values():
|
||||
if not isinstance(info, dict) or info.get("is_local"):
|
||||
continue
|
||||
installed_path = next(
|
||||
(installed_files[name] for name in _catalog_filename_candidates(info) if name in installed_files),
|
||||
None,
|
||||
)
|
||||
if installed_path is None:
|
||||
info.pop("is_installed", None)
|
||||
info.pop("installed_path", None)
|
||||
continue
|
||||
info["is_installed"] = True
|
||||
info["installed_path"] = str(installed_path)
|
||||
|
||||
|
||||
def enforce_model_access(info: dict | None, model_name: str, *, local_exists: bool, hf_token: str | None = None) -> None:
|
||||
status = normalize_commercial_status(info)
|
||||
if COMMERCIAL_RELEASE:
|
||||
if status == "cleared":
|
||||
return
|
||||
raise PermissionError(f"[QwenVL] Commercial mode rejects model '{model_name}' with status '{status}'.")
|
||||
if not local_exists and status == "external_gated" and not _clean_hf_token(hf_token):
|
||||
raise PermissionError(
|
||||
f"[QwenVL] Cannot download {model_name}: no Hugging Face access token was supplied. "
|
||||
"Accept the model's access terms on Hugging Face, then add a read token to the hf_token field "
|
||||
"or set the HF_TOKEN environment variable."
|
||||
)
|
||||
if not local_exists and status == "local":
|
||||
raise FileNotFoundError(f"[QwenVL] Local user-supplied model not found: {model_name}")
|
||||
|
||||
|
||||
def _redact_hf_token(text: str, hf_token: str | None) -> str:
|
||||
redacted = str(text)
|
||||
for token in _iter_hf_token_candidates(hf_token):
|
||||
redacted = redacted.replace(token, "<redacted HF token>")
|
||||
return redacted
|
||||
|
||||
|
||||
def _hf_download_error_message(exc: Exception, *, repo_id: str, hf_token: str | None) -> str:
|
||||
message = _redact_hf_token(str(exc), hf_token)
|
||||
lower = message.lower()
|
||||
access_markers = (
|
||||
"401",
|
||||
"403",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"gated",
|
||||
"private",
|
||||
"repository not found",
|
||||
"requires authentication",
|
||||
)
|
||||
if any(marker in lower for marker in access_markers):
|
||||
if _clean_hf_token(hf_token):
|
||||
message += (
|
||||
f"\n[QwenVL] Hugging Face access hint for {repo_id}: a token was supplied but access was rejected. "
|
||||
"Confirm the token has read permission and that you accepted the model license/gate on Hugging Face."
|
||||
)
|
||||
else:
|
||||
message += (
|
||||
f"\n[QwenVL] Cannot download {repo_id}: no Hugging Face access token was supplied. "
|
||||
"Accept the model's access terms on Hugging Face, then add a read token to the hf_token field "
|
||||
"or set the HF_TOKEN environment variable."
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
def _call_hf_hub_download(*, repo_id: str, filename: str, local_dir: Path, token: str | None, reporter: _DownloadProgressReporter) -> Path:
|
||||
if hf_hub_download is None:
|
||||
raise RuntimeError("huggingface_hub.hf_hub_download is unavailable in this environment")
|
||||
download_kwargs = {
|
||||
"repo_id": repo_id,
|
||||
"repo_type": "model",
|
||||
"filename": filename,
|
||||
"local_dir": str(local_dir),
|
||||
}
|
||||
if token:
|
||||
download_kwargs["token"] = token
|
||||
tqdm_cls = reporter.make_tqdm_class()
|
||||
if tqdm_cls is not None:
|
||||
try:
|
||||
if "tqdm_class" in inspect.signature(hf_hub_download).parameters:
|
||||
download_kwargs["tqdm_class"] = tqdm_cls
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
return Path(hf_hub_download(**download_kwargs))
|
||||
finally:
|
||||
download_kwargs.pop("token", None)
|
||||
|
||||
|
||||
|
||||
def _model_snapshot_has_weights(target: Path) -> bool:
|
||||
return any(target.glob("*.safetensors")) or any(target.glob("*.bin"))
|
||||
|
||||
|
||||
def _cleanup_unneeded_snapshot_weights(target: Path) -> None:
|
||||
unwanted_markers = ("fp32", "bf16", "fp8", "q2_k", "q3_k", "q4_k", "q5_k", "q6_k", "q8_0", "f32")
|
||||
for weight_file in target.rglob("*.safetensors"):
|
||||
name = weight_file.name.lower()
|
||||
if any(marker in name for marker in unwanted_markers):
|
||||
print(f"[QwenVL] Removing unneeded quantized weight: {weight_file.name}")
|
||||
weight_file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _model_snapshot_has_required_files(target: Path, require_processor: bool = False) -> bool:
|
||||
if not target.exists() or not target.is_dir():
|
||||
return False
|
||||
if not _model_snapshot_has_weights(target):
|
||||
return False
|
||||
if not (target / "config.json").exists():
|
||||
return False
|
||||
if require_processor and not any((target / name).exists() for name in _PROCESSOR_CONFIG_FILENAMES):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_transformers_repo_files(repo_id: str, repo_files: list[str], *, require_processor: bool = False) -> None:
|
||||
root_files = {Path(str(filename)).as_posix() for filename in repo_files if filename}
|
||||
missing = []
|
||||
if "config.json" not in root_files:
|
||||
missing.append("config.json")
|
||||
if not any("/" not in filename and filename.endswith((".safetensors", ".bin")) for filename in root_files):
|
||||
missing.append("Transformers model weights")
|
||||
if require_processor and not any(filename in root_files for filename in _PROCESSOR_CONFIG_FILENAMES):
|
||||
missing.append("processor metadata")
|
||||
if not missing:
|
||||
return
|
||||
|
||||
gguf_hint = " The repository contains GGUF files; select it through a ThinkingLLM GGUF node instead." if any(
|
||||
filename.lower().endswith(".gguf") for filename in root_files
|
||||
) else ""
|
||||
raise ValueError(
|
||||
f"[QwenVL] {repo_id} is not a compatible Transformers snapshot: missing {', '.join(missing)}.{gguf_hint}"
|
||||
)
|
||||
|
||||
|
||||
def _download_model_snapshot(repo_id: str, target: Path, *, require_processor: bool = False, node_id=None, progress_label: str | None = None, hf_token: str | None = None) -> None:
|
||||
if COMMERCIAL_RELEASE:
|
||||
raise RuntimeError("[QwenVL] Commercial release mode only permits preinstalled, hash-locked models.")
|
||||
reporter = _DownloadProgressReporter(node_id=node_id, label=progress_label or "QwenVL HF Download", repo_id=repo_id)
|
||||
token_for_download = _clean_hf_token(hf_token)
|
||||
download_kwargs = {}
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
reporter.stage("Checking repository", detail=target.name)
|
||||
try:
|
||||
if HfApi is None:
|
||||
raise RuntimeError("huggingface_hub.HfApi is unavailable; update huggingface-hub before downloading models")
|
||||
api = HfApi(token=token_for_download) if token_for_download else HfApi()
|
||||
repo_files = api.list_repo_files(repo_id=repo_id, repo_type="model")
|
||||
_validate_transformers_repo_files(repo_id, repo_files, require_processor=require_processor)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f".{target.name}.download-", dir=target.parent) as temp_dir:
|
||||
staging_target = Path(temp_dir) / "snapshot"
|
||||
reporter.stage("Preparing download", detail=target.name)
|
||||
download_kwargs = {
|
||||
"repo_id": repo_id,
|
||||
"local_dir": str(staging_target),
|
||||
"ignore_patterns": ["*.md", ".git*"],
|
||||
}
|
||||
if token_for_download:
|
||||
download_kwargs["token"] = token_for_download
|
||||
tqdm_cls = reporter.make_tqdm_class()
|
||||
if tqdm_cls is not None:
|
||||
download_kwargs["tqdm_class"] = tqdm_cls
|
||||
snapshot_download(**download_kwargs)
|
||||
_cleanup_unneeded_snapshot_weights(staging_target)
|
||||
if not _model_snapshot_has_required_files(staging_target, require_processor=require_processor):
|
||||
raise FileNotFoundError(
|
||||
f"[QwenVL] Downloaded model snapshot is incomplete for {repo_id}; the existing target was preserved"
|
||||
)
|
||||
|
||||
reporter.stage("Installing validated snapshot", detail=target.name)
|
||||
if target.exists():
|
||||
if not target.is_dir():
|
||||
raise FileExistsError(f"[QwenVL] Model target exists and is not a directory: {target}")
|
||||
shutil.copytree(staging_target, target, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.move(str(staging_target), str(target))
|
||||
except Exception as exc:
|
||||
reporter.fail(_hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download))
|
||||
raise RuntimeError(_hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download)) from exc
|
||||
finally:
|
||||
download_kwargs.pop("token", None)
|
||||
token_for_download = None
|
||||
reporter.finish(detail=target.name)
|
||||
|
||||
|
||||
def download_hf_file_to_path(repo_ids: list[str], filename: str, target_path: Path, *, node_id=None, progress_label: str = "QwenVL GGUF Download", hf_token: str | None = None) -> None:
|
||||
if target_path.exists():
|
||||
print(f"[QwenVL] Using cached file: {target_path}")
|
||||
return
|
||||
if COMMERCIAL_RELEASE:
|
||||
raise RuntimeError("[QwenVL] Commercial release mode only permits preinstalled, hash-locked models.")
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
last_exc: Exception | None = None
|
||||
wanted_name = Path(filename).name
|
||||
token_for_download = _clean_hf_token(hf_token)
|
||||
exact_candidates = []
|
||||
for candidate in (filename, wanted_name):
|
||||
if candidate and candidate not in exact_candidates:
|
||||
exact_candidates.append(candidate)
|
||||
|
||||
try:
|
||||
for repo_id in repo_ids:
|
||||
reporter = _DownloadProgressReporter(node_id=node_id, label=progress_label, repo_id=repo_id)
|
||||
auth_label = "Preparing authenticated download" if token_for_download else "Preparing download"
|
||||
reporter.stage(auth_label, detail=wanted_name)
|
||||
if hf_hub_download is not None:
|
||||
for candidate in exact_candidates:
|
||||
try:
|
||||
reporter.stage("Resolving exact file", detail=candidate, force=False)
|
||||
downloaded_path = _call_hf_hub_download(
|
||||
repo_id=repo_id,
|
||||
filename=candidate,
|
||||
local_dir=target_path.parent,
|
||||
token=token_for_download,
|
||||
reporter=reporter,
|
||||
)
|
||||
if downloaded_path.exists() and downloaded_path.resolve() != target_path.resolve():
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
downloaded_path.replace(target_path)
|
||||
if target_path.exists():
|
||||
reporter.finish(detail=wanted_name)
|
||||
return
|
||||
except Exception as exc:
|
||||
hint = _hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download)
|
||||
last_exc = RuntimeError(hint)
|
||||
print(f"[QwenVL] exact file download failed from {repo_id}/{candidate}: {hint}")
|
||||
reporter.stage("Falling back to snapshot scan", detail=wanted_name, force=False)
|
||||
download_kwargs = {}
|
||||
try:
|
||||
download_kwargs = {
|
||||
"repo_id": repo_id,
|
||||
"repo_type": "model",
|
||||
"local_dir": str(target_path.parent),
|
||||
"allow_patterns": [filename, wanted_name, f"**/{wanted_name}"],
|
||||
}
|
||||
if token_for_download:
|
||||
download_kwargs["token"] = token_for_download
|
||||
tqdm_cls = reporter.make_tqdm_class()
|
||||
if tqdm_cls is not None:
|
||||
download_kwargs["tqdm_class"] = tqdm_cls
|
||||
snapshot_download(**download_kwargs)
|
||||
found = list(target_path.parent.rglob(wanted_name))
|
||||
if found:
|
||||
downloaded_path = found[0]
|
||||
if downloaded_path.exists() and downloaded_path.resolve() != target_path.resolve():
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
downloaded_path.replace(target_path)
|
||||
if target_path.exists():
|
||||
reporter.finish(detail=wanted_name)
|
||||
return
|
||||
reporter.fail(f"{wanted_name} was not found after snapshot download")
|
||||
except Exception as exc:
|
||||
hint = _hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download)
|
||||
last_exc = RuntimeError(hint)
|
||||
reporter.fail(hint)
|
||||
print(f"[QwenVL] snapshot_download failed from {repo_id}: {hint}")
|
||||
finally:
|
||||
download_kwargs.pop("token", None)
|
||||
finally:
|
||||
token_for_download = None
|
||||
|
||||
raise FileNotFoundError(f"[QwenVL] Download failed for {wanted_name}: {last_exc}")
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Prompt budgeting plus duration-aware video prompt contracts."""
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
QWEN_MIN_REASONING_BUDGET_TOKENS = 1024
|
||||
QWEN_FINAL_ANSWER_RESERVE_TOKENS = 256
|
||||
QWEN_CONTEXT_OVERHEAD_TOKENS = 128
|
||||
_QWEN_SOFT_SWITCHES = {"/think", "/no_think", "/nothink"}
|
||||
MINIMAX_H3_REFERENCE_PRESET = "🖼️ MiniMax H3 Reference-to-Video"
|
||||
VIDEO_PROMPT_CONTRACT_VERSION = 10
|
||||
_MINIMAX_BASE_FIELDS = (
|
||||
"integrated_multimodal_description",
|
||||
"overall_soundscape",
|
||||
"non_diegetic_music",
|
||||
)
|
||||
_MINIMAX_REFERENCE_FIELDS = (
|
||||
"subject_definitions",
|
||||
"summary",
|
||||
"retention_analysis",
|
||||
"detailed_description",
|
||||
"overall_soundscape",
|
||||
"non_diegetic_music",
|
||||
)
|
||||
VIDEO_DURATION_INPUT = (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 5.0,
|
||||
"min": 0.2,
|
||||
"max": 150.0,
|
||||
"step": 0.1,
|
||||
"tooltip": (
|
||||
"Target video duration in seconds. It is used only by registered LTX 2.3 and MiniMax H3 "
|
||||
"video presets. Connect the same requested duration to the video generator; MiniMax values "
|
||||
"are normalized to its 17k+5 frame grid at 24 fps. For longer MiniMax scripts, ThinkingLLM "
|
||||
"selects a coherent moment that fits while keeping any selected dialogue verbatim."
|
||||
),
|
||||
},
|
||||
)
|
||||
VIDEO_PRESET_METADATA: dict[str, dict] = {}
|
||||
|
||||
|
||||
def estimate_qwen_text_tokens(*parts):
|
||||
"""Conservatively estimate prompt tokens when an exact tokenizer is unavailable."""
|
||||
total_chars = 0
|
||||
non_empty_parts = 0
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
normalized = " ".join(str(part).split())
|
||||
if not normalized:
|
||||
continue
|
||||
total_chars += len(normalized)
|
||||
non_empty_parts += 1
|
||||
if total_chars <= 0:
|
||||
return 0
|
||||
return max(1, (total_chars + 3) // 4 + max(0, non_empty_parts - 1))
|
||||
|
||||
|
||||
def resolve_qwen_context_window(config, fallback=32768):
|
||||
"""Return the best available HF context window from the model config."""
|
||||
candidates = []
|
||||
for cfg in (config, getattr(config, "text_config", None)):
|
||||
if cfg is None:
|
||||
continue
|
||||
for attr in ("max_position_embeddings", "n_positions", "seq_length"):
|
||||
value = getattr(cfg, attr, None)
|
||||
if isinstance(value, int) and 0 < value <= 2_000_000:
|
||||
candidates.append(value)
|
||||
if candidates:
|
||||
return max(candidates)
|
||||
return int(fallback)
|
||||
|
||||
|
||||
def resolve_qwen_thinking_mode(
|
||||
requested_enable,
|
||||
max_tokens,
|
||||
label="QwenVL",
|
||||
*,
|
||||
prompt_tokens=None,
|
||||
context_window=None,
|
||||
min_reasoning_tokens=QWEN_MIN_REASONING_BUDGET_TOKENS,
|
||||
answer_reserve_tokens=QWEN_FINAL_ANSWER_RESERVE_TOKENS,
|
||||
context_overhead_tokens=QWEN_CONTEXT_OVERHEAD_TOKENS,
|
||||
quiet=False,
|
||||
):
|
||||
"""Enable thinking only when requested and enough generation budget remains."""
|
||||
if not bool(requested_enable):
|
||||
return False
|
||||
requested_output = max(0, int(max_tokens or 0))
|
||||
available_output = requested_output
|
||||
prompt_token_count = max(0, int(prompt_tokens or 0))
|
||||
context_limit = int(context_window) if context_window is not None else None
|
||||
|
||||
if context_limit is not None:
|
||||
remaining_context = max(0, context_limit - prompt_token_count - int(context_overhead_tokens))
|
||||
available_output = min(available_output, remaining_context) if available_output else remaining_context
|
||||
|
||||
minimum_required = int(min_reasoning_tokens) + int(answer_reserve_tokens)
|
||||
if available_output <= 0 or available_output < minimum_required:
|
||||
if context_limit is not None:
|
||||
budget_note = (
|
||||
f"max_tokens={requested_output}, prompt_tokens={prompt_token_count}, "
|
||||
f"context_window={context_limit}, available_output={available_output}"
|
||||
)
|
||||
else:
|
||||
budget_note = f"max_tokens={requested_output}, available_output={available_output}"
|
||||
if not quiet:
|
||||
print(
|
||||
f"[{label}] Thinking requested but {budget_note} is below the required {minimum_required} tokens "
|
||||
f"({int(min_reasoning_tokens)} reasoning + {int(answer_reserve_tokens)} answer reserve); "
|
||||
"forcing non-thinking mode to preserve answer space."
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def apply_qwen_soft_thinking_directive(prompt_text, enable_thinking, supports_soft_switch=False):
|
||||
"""Append the latest Qwen3 soft switch when the backend supports it."""
|
||||
text = prompt_text or ""
|
||||
if not supports_soft_switch:
|
||||
return text
|
||||
lines = [line for line in text.splitlines() if line.strip().lower() not in _QWEN_SOFT_SWITCHES]
|
||||
lines.append("/think" if enable_thinking else "/no_think")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def validate_minimax_reference_request(preset_prompt, custom_prompt):
|
||||
"""Require an explicit target and reference role for MiniMax H3 R2V prompting."""
|
||||
if preset_prompt == MINIMAX_H3_REFERENCE_PRESET and not (custom_prompt or "").strip():
|
||||
raise ValueError(
|
||||
"[QwenVL] MiniMax H3 Reference-to-Video requires a custom prompt describing the target video "
|
||||
"and how each reference should be used. To animate a single image as the first frame, use "
|
||||
"MiniMax H3 Text-to-Video instead."
|
||||
)
|
||||
|
||||
|
||||
def get_video_preset_metadata(preset_name):
|
||||
"""Return duration metadata only for a registered duration-aware video preset."""
|
||||
metadata = VIDEO_PRESET_METADATA.get(preset_name)
|
||||
if not isinstance(metadata, dict) or not metadata.get("duration_required"):
|
||||
return None
|
||||
return metadata
|
||||
|
||||
|
||||
def resolve_video_duration(preset_name, duration_seconds=5.0):
|
||||
"""Resolve an optional node duration to the provider-native effective duration."""
|
||||
metadata = get_video_preset_metadata(preset_name)
|
||||
if metadata is None:
|
||||
return None
|
||||
try:
|
||||
requested_seconds = float(5.0 if duration_seconds is None else duration_seconds)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("[QwenVL] duration_seconds must be a finite number between 0.2 and 150.0.") from exc
|
||||
if not math.isfinite(requested_seconds) or not 0.2 <= requested_seconds <= 150.0:
|
||||
raise ValueError("[QwenVL] duration_seconds must be a finite number between 0.2 and 150.0.")
|
||||
|
||||
provider = metadata.get("provider")
|
||||
frames = None
|
||||
effective_seconds = requested_seconds
|
||||
if provider == "minimax_h3":
|
||||
frames = max(5, round(requested_seconds * 24))
|
||||
frames += (5 - (frames % 17)) % 17
|
||||
effective_seconds = frames / 24
|
||||
duration_text = f"{effective_seconds:.2f}"
|
||||
elif provider == "ltx_2_3":
|
||||
duration_text = f"{effective_seconds:.2f}".rstrip("0").rstrip(".")
|
||||
else:
|
||||
return None
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"video_mode": metadata.get("video_mode"),
|
||||
"requested_seconds": requested_seconds,
|
||||
"effective_seconds": effective_seconds,
|
||||
"duration_text": duration_text,
|
||||
"frames": frames,
|
||||
"contract_version": VIDEO_PROMPT_CONTRACT_VERSION if provider == "minimax_h3" else None,
|
||||
}
|
||||
|
||||
|
||||
def apply_video_duration_context(prompt_text, preset_name, duration_seconds=5.0):
|
||||
"""Append authoritative duration context for the LLM without changing non-video presets."""
|
||||
resolved = resolve_video_duration(preset_name, duration_seconds)
|
||||
if resolved is None:
|
||||
return prompt_text
|
||||
duration_text = resolved["duration_text"]
|
||||
provider_instruction = (
|
||||
f'Express the total duration naturally as a "{duration_text}-second continuous shot" in the single paragraph.'
|
||||
if resolved["provider"] == "ltx_2_3"
|
||||
else (
|
||||
"Use this duration only to plan feasible actions, bound every cut timestamp, and fill any "
|
||||
"official FL2VA/L2VA alignment line. Do not print TARGET_DURATION_SECONDS or a separate "
|
||||
"target-duration declaration in the final MiniMax prompt."
|
||||
)
|
||||
)
|
||||
duration_context = (
|
||||
f"TARGET_DURATION_SECONDS: {duration_text}\n"
|
||||
"This node value is authoritative and overrides any conflicting duration in the user text. "
|
||||
+ ("Mention the total target duration exactly once in the final prompt. " if resolved["provider"] == "ltx_2_3" else "")
|
||||
+ provider_instruction
|
||||
)
|
||||
return "\n\n".join(part for part in ((prompt_text or "").strip(), duration_context) if part)
|
||||
|
||||
|
||||
def _normalize_minimax_dialogue_text(text):
|
||||
return " ".join((text or "").split())
|
||||
|
||||
|
||||
_MINIMAX_LANGUAGE_LABELS = (
|
||||
"English", "German", "French", "Spanish", "Italian", "Portuguese", "Dutch", "Polish",
|
||||
"Russian", "Ukrainian", "Czech", "Slovak", "Swedish", "Norwegian", "Danish", "Finnish",
|
||||
"Greek", "Turkish", "Arabic", "Hebrew", "Hindi", "Bengali", "Urdu", "Persian", "Thai",
|
||||
"Vietnamese", "Indonesian", "Malay", "Chinese", "Mandarin", "Cantonese", "Japanese", "Korean",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_minimax_dialogue_tags(text):
|
||||
"""Normalize safe language-tag variants to official <d>[Language] ...</d> syntax."""
|
||||
language_pattern = "|".join(re.escape(language) for language in _MINIMAX_LANGUAGE_LABELS)
|
||||
|
||||
def _normalize_tag(match):
|
||||
language = match.group(1).strip()
|
||||
payload = match.group(2).strip()
|
||||
return f"<d>[{language}] {payload}</d>"
|
||||
|
||||
normalized = re.sub(
|
||||
r"(?is)<d>\s*\[\s*([^\]\r\n]+?)\s*\]\s*(.+?)\s*</d>",
|
||||
_normalize_tag,
|
||||
text,
|
||||
)
|
||||
return re.sub(
|
||||
rf"(?is)<d>\s*({language_pattern})\s*:?[ \t\r\n]+(.+?)\s*</d>",
|
||||
_normalize_tag,
|
||||
normalized,
|
||||
)
|
||||
|
||||
|
||||
def extract_minimax_source_dialogue(source_prompt_text):
|
||||
"""Extract explicitly quoted, standalone dialogue blocks from a text request."""
|
||||
source = (source_prompt_text or "").strip()
|
||||
if not source:
|
||||
return []
|
||||
|
||||
dialogue = []
|
||||
tagged = re.findall(r"<d>\[[^\]\r\n]+\]\s*(.+?)</d>", source, flags=re.DOTALL | re.IGNORECASE)
|
||||
dialogue.extend(_normalize_minimax_dialogue_text(item) for item in tagged if item.strip())
|
||||
|
||||
quote_pairs = (("“", "”"), ("„", "“"), ("«", "»"), ('"', '"'))
|
||||
for paragraph in re.split(r"\r?\n[ \t]*\r?\n", source):
|
||||
candidate = paragraph.strip()
|
||||
if not candidate or candidate.lower().startswith("<d>"):
|
||||
continue
|
||||
for opening, closing in quote_pairs:
|
||||
if len(candidate) >= 2 and candidate.startswith(opening) and candidate.endswith(closing):
|
||||
payload = _normalize_minimax_dialogue_text(candidate[len(opening):-len(closing)])
|
||||
if payload:
|
||||
dialogue.append(payload)
|
||||
break
|
||||
|
||||
unique = []
|
||||
for item in dialogue:
|
||||
if item not in unique:
|
||||
unique.append(item)
|
||||
return unique
|
||||
|
||||
|
||||
def validate_minimax_source_duration(preset_name, duration_seconds=5.0, source_prompt_text=None):
|
||||
"""Return source dialogue for MiniMax presets without rejecting longer master scripts."""
|
||||
resolved = resolve_video_duration(preset_name, duration_seconds)
|
||||
if resolved is None or resolved["provider"] != "minimax_h3":
|
||||
return []
|
||||
return extract_minimax_source_dialogue(source_prompt_text)
|
||||
|
||||
|
||||
def _normalize_minimax_section_boundaries(text, expected_fields):
|
||||
"""Put uniquely identifiable official fields on their own lines without rewriting content."""
|
||||
found = []
|
||||
for field in expected_fields:
|
||||
matches = list(re.finditer(rf"(?i)(?<![A-Za-z0-9_]){re.escape(field)}[ \t]*:", text))
|
||||
if len(matches) != 1:
|
||||
return text
|
||||
found.append((field, matches[0]))
|
||||
if [match.start() for _, match in found] != sorted(match.start() for _, match in found):
|
||||
return text
|
||||
|
||||
normalized = text
|
||||
for _, match in reversed(found[1:]):
|
||||
start = match.start()
|
||||
normalized = normalized[:start].rstrip() + "\n\n" + normalized[start:].lstrip()
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_minimax_shot_syntax(description):
|
||||
"""Normalize only unambiguous MiniMax shot syntax without validating the timeline."""
|
||||
timestamp_pattern = re.compile(
|
||||
r"(\[Shot\s+(\d+)\]\s+At\s+)(\d{1,2}):(\d{1,2})\.(\d{1,3})\s*,?",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _normalize_timestamp(match):
|
||||
minutes = int(match.group(3))
|
||||
seconds = int(match.group(4))
|
||||
millis = int(match.group(5).ljust(3, "0"))
|
||||
if seconds >= 60:
|
||||
return match.group(0)
|
||||
return f"[Shot {int(match.group(2))}] At {minutes:02d}:{seconds:02d}.{millis:03d},"
|
||||
|
||||
normalized = timestamp_pattern.sub(_normalize_timestamp, description)
|
||||
normalized = re.sub(
|
||||
r"(?i)\[Shot\s+1\]\s+At\s+00:00\.000,?[ \t]*",
|
||||
"[Shot 1] ",
|
||||
normalized,
|
||||
count=1,
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_minimax_prompt_best_effort(prompt_text, preset_name, duration_seconds=5.0):
|
||||
"""Apply safe MiniMax formatting fixes without rejecting or repairing model output."""
|
||||
resolved = resolve_video_duration(preset_name, duration_seconds)
|
||||
if resolved is None or resolved["provider"] != "minimax_h3":
|
||||
return prompt_text
|
||||
|
||||
original = (prompt_text or "").strip()
|
||||
if not original:
|
||||
return original
|
||||
|
||||
try:
|
||||
text = re.sub(
|
||||
r"(?i)\bTarget\s+duration\s*:\s*\d+(?:[.,]\d+)?\s*seconds?\.?[ \t]*",
|
||||
"",
|
||||
original,
|
||||
).strip()
|
||||
text = _normalize_minimax_dialogue_tags(text)
|
||||
expected_fields = (
|
||||
_MINIMAX_REFERENCE_FIELDS
|
||||
if resolved["video_mode"] == "ref2va"
|
||||
else _MINIMAX_BASE_FIELDS
|
||||
)
|
||||
text = _normalize_minimax_section_boundaries(text, expected_fields)
|
||||
text = _normalize_minimax_shot_syntax(text)
|
||||
return text.strip() or original
|
||||
except Exception:
|
||||
# Formatting assistance must never become a second schema gate in front of H3.
|
||||
return original
|
||||
|
||||
|
||||
def ensure_video_prompt_duration(prompt_text, preset_name, duration_seconds=5.0, source_prompt_text=None):
|
||||
"""Apply provider-native duration guidance and safe output normalization."""
|
||||
resolved = resolve_video_duration(preset_name, duration_seconds)
|
||||
if resolved is None:
|
||||
return prompt_text
|
||||
text = (prompt_text or "").strip()
|
||||
duration_text = resolved["duration_text"]
|
||||
|
||||
if resolved["provider"] == "ltx_2_3":
|
||||
text = " ".join(text.split())
|
||||
marker = f"{duration_text}-second continuous shot"
|
||||
number_pattern = re.escape(duration_text).replace(r"\.", r"[.,]")
|
||||
marker_pattern = re.compile(
|
||||
rf"\b{number_pattern}(?:\.0+)?[- ]second continuous shot\b",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
seen = False
|
||||
|
||||
def _canonicalize_ltx_marker(_match):
|
||||
nonlocal seen
|
||||
if seen:
|
||||
return "continuous shot"
|
||||
seen = True
|
||||
return marker
|
||||
|
||||
text = marker_pattern.sub(_canonicalize_ltx_marker, text)
|
||||
if not seen:
|
||||
text = f"A {marker}. {text}".strip()
|
||||
return " ".join(text.split())
|
||||
|
||||
if resolved["provider"] == "minimax_h3":
|
||||
return normalize_minimax_prompt_best_effort(
|
||||
text,
|
||||
preset_name,
|
||||
duration_seconds,
|
||||
)
|
||||
|
||||
return text
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Persistent prompt cache and per-node workflow state shared by all backends."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
|
||||
PROMPT_CACHE: dict = {}
|
||||
CACHE_FILE = PLUGIN_ROOT / "prompt_cache.json"
|
||||
NODE_PROMPT_STATE: dict = {}
|
||||
NODE_STATE_FILE = PLUGIN_ROOT / "node_prompt_state.json"
|
||||
|
||||
|
||||
def _build_workflow_fingerprint(extra_pnginfo):
|
||||
"""Return a short stable fingerprint for a workflow dict so node state
|
||||
can be scoped to a particular workflow window, avoiding cross-workflow
|
||||
leakage while remaining stable across save/reload cycles."""
|
||||
if not isinstance(extra_pnginfo, dict):
|
||||
return None
|
||||
workflow = extra_pnginfo.get("workflow")
|
||||
if not isinstance(workflow, dict):
|
||||
return None
|
||||
try:
|
||||
last_id = workflow.get("last_node_id", 0)
|
||||
node_ids = sorted(
|
||||
str(n.get("id", "")) for n in workflow.get("nodes", []) if isinstance(n, dict)
|
||||
)
|
||||
seed = f"{last_id}|{','.join(node_ids[:50])}"
|
||||
return hashlib.md5(seed.encode()).hexdigest()[:12]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _make_node_state_key(node_class, unique_id, extra_pnginfo):
|
||||
"""Produce a stable per-node key from the node class name, its numeric
|
||||
identity within the workflow, and the workflow fingerprint when available."""
|
||||
wf_fp = _build_workflow_fingerprint(extra_pnginfo)
|
||||
if wf_fp:
|
||||
return f"{node_class}|{wf_fp}|{unique_id}"
|
||||
return f"{node_class}|{unique_id}"
|
||||
|
||||
|
||||
def _normalize_state_signature_value(value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_normalize_state_signature_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): _normalize_state_signature_value(val)
|
||||
for key, val in sorted(value.items(), key=lambda item: str(item[0]))
|
||||
}
|
||||
return str(value)
|
||||
|
||||
|
||||
def build_node_input_signature(**kwargs):
|
||||
"""Return a stable hash for the node inputs that should invalidate saved prompt reuse."""
|
||||
normalized = {
|
||||
str(key): _normalize_state_signature_value(value)
|
||||
for key, value in sorted(kwargs.items(), key=lambda item: str(item[0]))
|
||||
}
|
||||
payload = json.dumps(normalized, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
|
||||
return hashlib.md5(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
def load_node_prompt_state():
|
||||
"""Load per-node prompt state from the sidecar JSON file."""
|
||||
try:
|
||||
if NODE_STATE_FILE.exists():
|
||||
with open(NODE_STATE_FILE, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("node prompt state must be a JSON object")
|
||||
NODE_PROMPT_STATE.clear()
|
||||
NODE_PROMPT_STATE.update(loaded)
|
||||
print(f"[QwenVL] Loaded {len(NODE_PROMPT_STATE)} node prompt states")
|
||||
except Exception as e:
|
||||
print(f"[QwenVL] Failed to load node prompt state: {e}")
|
||||
NODE_PROMPT_STATE.clear()
|
||||
|
||||
def save_node_prompt_state():
|
||||
"""Persist per-node prompt state to the sidecar JSON file."""
|
||||
try:
|
||||
with open(NODE_STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(NODE_PROMPT_STATE, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[QwenVL] Failed to save node prompt state: {e}")
|
||||
|
||||
def get_node_saved_prompt(node_class, unique_id, extra_pnginfo):
|
||||
"""Return the saved prompt text for a specific node, or None."""
|
||||
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
|
||||
entry = NODE_PROMPT_STATE.get(key)
|
||||
if isinstance(entry, dict) and entry.get("text"):
|
||||
return entry["text"]
|
||||
return None
|
||||
|
||||
|
||||
def get_node_saved_prompt_with_seed(node_class, unique_id, extra_pnginfo, seed=None, max_tokens=None, temperature=None, top_p=None, repetition_penalty=None, input_signature=None):
|
||||
"""Return saved prompt text only if the generation parameters haven't changed."""
|
||||
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
|
||||
entry = NODE_PROMPT_STATE.get(key)
|
||||
if not isinstance(entry, dict) or not entry.get("text"):
|
||||
return None
|
||||
# If seed or params changed, invalidate and return None
|
||||
if seed is not None and entry.get("seed") != seed:
|
||||
return None
|
||||
if max_tokens is not None and entry.get("max_tokens") != max_tokens:
|
||||
return None
|
||||
if temperature is not None and entry.get("temperature") != temperature:
|
||||
return None
|
||||
if top_p is not None and entry.get("top_p") != top_p:
|
||||
return None
|
||||
if repetition_penalty is not None and entry.get("repetition_penalty") != repetition_penalty:
|
||||
return None
|
||||
if input_signature is not None and entry.get("input_signature") != input_signature:
|
||||
return None
|
||||
return entry["text"]
|
||||
|
||||
def set_node_saved_prompt(node_class, unique_id, extra_pnginfo, text, raw_trace=None, seed=None, max_tokens=None, temperature=None, top_p=None, repetition_penalty=None, input_signature=None):
|
||||
"""Persist the generated prompt (and optional raw trace) for a specific node."""
|
||||
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
|
||||
NODE_PROMPT_STATE[key] = {
|
||||
"text": text,
|
||||
"raw_trace": raw_trace or "",
|
||||
"timestamp": None,
|
||||
"seed": seed,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
"repetition_penalty": repetition_penalty,
|
||||
"input_signature": input_signature,
|
||||
}
|
||||
save_node_prompt_state()
|
||||
|
||||
def load_prompt_cache():
|
||||
"""Load prompt cache from file"""
|
||||
try:
|
||||
if CACHE_FILE.exists():
|
||||
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("prompt cache must be a JSON object")
|
||||
PROMPT_CACHE.clear()
|
||||
PROMPT_CACHE.update(loaded)
|
||||
print(f"[QwenVL] Loaded {len(PROMPT_CACHE)} cached prompts")
|
||||
except Exception as e:
|
||||
print(f"[QwenVL] Failed to load prompt cache: {e}")
|
||||
PROMPT_CACHE.clear()
|
||||
|
||||
def save_prompt_cache():
|
||||
"""Save prompt cache to file"""
|
||||
try:
|
||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(PROMPT_CACHE, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[QwenVL] Failed to save prompt cache: {e}")
|
||||
|
||||
|
||||
def log_llm_input(
|
||||
backend: str,
|
||||
stage: str,
|
||||
preset_name: str,
|
||||
user_text: str,
|
||||
*,
|
||||
system_text: str = "",
|
||||
formatted_text: str | None = None,
|
||||
media: dict[str, int] | None = None,
|
||||
) -> None:
|
||||
"""Print the complete text input sent to an LLM without dumping media payloads."""
|
||||
lines = [
|
||||
f"\n[{backend}] LLM INPUT",
|
||||
f"Stage: {stage}",
|
||||
f"Preset: {preset_name or '(none)'}",
|
||||
"Media: " + (", ".join(f"{kind}={count}" for kind, count in (media or {}).items()) or "none"),
|
||||
"--- SYSTEM ---",
|
||||
system_text or "(none)",
|
||||
"--- USER ---",
|
||||
user_text or "(empty)",
|
||||
]
|
||||
if formatted_text is not None:
|
||||
lines.extend(("--- FORMATTED CHAT TEMPLATE ---", formatted_text))
|
||||
lines.append("--- END LLM INPUT ---")
|
||||
message = "\n".join(lines)
|
||||
try:
|
||||
print(message)
|
||||
except UnicodeEncodeError:
|
||||
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
|
||||
print(message.encode(encoding, errors="replace").decode(encoding))
|
||||
|
||||
def get_cache_key(model_name, preset_prompt, custom_prompt, image_hash=None, video_hash=None, seed=None, max_tokens=None, temperature=None, top_p=None, repetition_penalty=None, enable_thinking=None, effective_duration_seconds=None, video_prompt_contract_version=None):
|
||||
"""Generate cache key from inputs including all generation parameters.
|
||||
|
||||
All generation-parameter fields are optional so existing callers that omit them
|
||||
continue to work; when provided, changing any parameter produces a different key.
|
||||
"""
|
||||
key_data = {
|
||||
"model": model_name,
|
||||
"preset": preset_prompt,
|
||||
"custom": custom_prompt.strip() if custom_prompt else "",
|
||||
"image": image_hash,
|
||||
"video": video_hash,
|
||||
"seed": seed, # Always include seed to ensure proper caching behavior
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature if temperature is not None else None,
|
||||
"top_p": top_p if top_p is not None else None,
|
||||
"repetition_penalty": repetition_penalty if repetition_penalty is not None else None,
|
||||
"enable_thinking": bool(enable_thinking) if enable_thinking is not None else None,
|
||||
}
|
||||
if effective_duration_seconds is not None:
|
||||
key_data["effective_duration_seconds"] = effective_duration_seconds
|
||||
if video_prompt_contract_version is not None:
|
||||
key_data["video_prompt_contract_version"] = video_prompt_contract_version
|
||||
# Create deterministic hash
|
||||
key_str = json.dumps(key_data, sort_keys=True)
|
||||
return hashlib.md5(key_str.encode()).hexdigest()
|
||||
|
||||
def get_alternative_cache_key(model_name, preset_prompt, custom_prompt, image_hash=None, video_hash=None, seed=None, module_name="QwenVL"):
|
||||
"""Generate alternative cache key for fixed seed mode to find random prompts"""
|
||||
# Only for fixed seed mode (when user wants consistent prompts)
|
||||
# We consider any seed that the user keeps fixed as "fixed seed mode"
|
||||
|
||||
print(f"[{module_name} DEBUG] Searching through cache for model={model_name}, preset={preset_prompt}")
|
||||
|
||||
# Try to find any cached prompt with same model/preset/custom/image but different seed
|
||||
for cached_key, cached_data in PROMPT_CACHE.items():
|
||||
cached_model = cached_data.get("model")
|
||||
cached_preset = cached_data.get("preset")
|
||||
cached_seed = cached_data.get("seed")
|
||||
|
||||
print(f"[{module_name} DEBUG] Checking entry: model={cached_model}, preset={cached_preset}, seed={cached_seed}")
|
||||
|
||||
if (cached_model == model_name and
|
||||
cached_preset == preset_prompt and
|
||||
cached_seed != seed): # Different seed
|
||||
|
||||
# Generate the cache key that would have been created for this cached data
|
||||
# to check if image/video hashes match
|
||||
cached_image_hash = cached_data.get("image_hash")
|
||||
cached_video_hash = cached_data.get("video_hash")
|
||||
|
||||
print(f"[{module_name} DEBUG] Found potential match with hashes: image={cached_image_hash}, video={cached_video_hash}")
|
||||
|
||||
# If the cached data doesn't have hash info, try to match by other criteria
|
||||
if cached_image_hash is None and cached_video_hash is None:
|
||||
# Fallback: if both current and cached have no image/video, consider it a match
|
||||
if image_hash is None and video_hash is None:
|
||||
print(f"[{module_name} DEBUG] Match found (no images/videos)!")
|
||||
return cached_key
|
||||
else:
|
||||
# Match if hashes are the same (including None)
|
||||
if cached_image_hash == image_hash and cached_video_hash == video_hash:
|
||||
print(f"[{module_name} DEBUG] Match found (hashes match)!")
|
||||
return cached_key
|
||||
print(f"[{module_name} DEBUG] No alternative cache found")
|
||||
return None
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Validate the ThinkingLLM API model catalogs.
|
||||
|
||||
Checks that web/api_model_catalogs.json stays well-formed, that every
|
||||
cloud profile shipped by the API node has a curated entry, and that model
|
||||
IDs are safe to send in a request body (no control characters, no
|
||||
leading/trailing whitespace, no duplicates).
|
||||
|
||||
Run in CI on every PR, push to main, and before every release:
|
||||
|
||||
python tools/check_api_model_catalogs.py
|
||||
|
||||
Exit code 0 on success, 1 on any validation failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CATALOG_PATH = REPO_ROOT / "web" / "api_model_catalogs.json"
|
||||
|
||||
# Cloud profiles exposed by the API node (nodes/openai_compatible_api.py).
|
||||
# Local endpoints (Ollama, vLLM, llama.cpp) are intentionally excluded
|
||||
# because their model IDs are runtime/environment specific.
|
||||
REQUIRED_PROFILE_KEYS = {
|
||||
"OpenAI": "OpenAI",
|
||||
"QwenCloud (Singapore)": "QwenCloud",
|
||||
"OpenRouter": "OpenRouter",
|
||||
"Together AI": "Together AI",
|
||||
"Fireworks AI": "Fireworks AI",
|
||||
"DeepInfra": "DeepInfra",
|
||||
"Groq": "Groq",
|
||||
"Featherless": "Featherless",
|
||||
"OrcaRouter": "OrcaRouter",
|
||||
}
|
||||
|
||||
# Reasonable bound to catch accidental mass-paste or generated blobs.
|
||||
# Public providers ship full lists (OpenRouter 413, Featherless up to 500).
|
||||
MAX_MODELS_PER_PROFILE = 600
|
||||
MAX_MODEL_ID_CHARS = 512
|
||||
MAX_MODEL_LABEL_CHARS = 128
|
||||
MAX_NOTE_CHARS = 512
|
||||
|
||||
_MODEL_ID_BLOCKLIST = re.compile(
|
||||
r"[\x00-\x1f\x7f-\x9f]"
|
||||
)
|
||||
|
||||
_ERRORS: list[str] = []
|
||||
|
||||
|
||||
def _error(message: str) -> None:
|
||||
_ERRORS.append(message)
|
||||
|
||||
|
||||
def _require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
_error(message)
|
||||
|
||||
|
||||
def _validate_model_entry(profile_name: str, index: int, entry: object) -> None:
|
||||
if not isinstance(entry, dict):
|
||||
_error(f"profile {profile_name!r}: models[{index}] must be an object.")
|
||||
return
|
||||
model_id = entry.get("id")
|
||||
if not isinstance(model_id, str) or not model_id.strip():
|
||||
_error(f"profile {profile_name!r}: models[{index}].id must be a non-empty string.")
|
||||
else:
|
||||
model_id = model_id.strip()
|
||||
if model_id != entry["id"]:
|
||||
_error(f"profile {profile_name!r}: models[{index}].id has leading/trailing whitespace.")
|
||||
if len(model_id) > MAX_MODEL_ID_CHARS:
|
||||
_error(f"profile {profile_name!r}: models[{index}].id exceeds {MAX_MODEL_ID_CHARS} characters.")
|
||||
if _MODEL_ID_BLOCKLIST.search(model_id):
|
||||
_error(f"profile {profile_name!r}: models[{index}].id contains control characters.")
|
||||
|
||||
label = entry.get("label")
|
||||
if not isinstance(label, str) or not label.strip():
|
||||
_error(f"profile {profile_name!r}: models[{index}].label must be a non-empty string.")
|
||||
elif len(label) > MAX_MODEL_LABEL_CHARS:
|
||||
_error(f"profile {profile_name!r}: models[{index}].label exceeds {MAX_MODEL_LABEL_CHARS} characters.")
|
||||
|
||||
if "vision" in entry and not isinstance(entry["vision"], bool):
|
||||
_error(f"profile {profile_name!r}: models[{index}].vision must be a boolean.")
|
||||
|
||||
if "notes" in entry:
|
||||
if not isinstance(entry["notes"], str):
|
||||
_error(f"profile {profile_name!r}: models[{index}].notes must be a string.")
|
||||
elif len(entry["notes"]) > MAX_NOTE_CHARS:
|
||||
_error(f"profile {profile_name!r}: models[{index}].notes exceeds {MAX_NOTE_CHARS} characters.")
|
||||
|
||||
|
||||
def _validate_profile(profile_name: str, raw: object) -> None:
|
||||
if not isinstance(raw, dict):
|
||||
_error(f"profile {profile_name!r} must be an object.")
|
||||
return
|
||||
provider = raw.get("provider")
|
||||
_require(
|
||||
isinstance(provider, str) and provider.strip(),
|
||||
f"profile {profile_name!r}: missing non-empty 'provider'.",
|
||||
)
|
||||
models = raw.get("models")
|
||||
if not isinstance(models, list):
|
||||
_error(f"profile {profile_name!r}: 'models' must be a list.")
|
||||
return
|
||||
if len(models) > MAX_MODELS_PER_PROFILE:
|
||||
_error(f"profile {profile_name!r}: more than {MAX_MODELS_PER_PROFILE} models; is this intentional?")
|
||||
seen_ids: set[str] = set()
|
||||
for index, entry in enumerate(models):
|
||||
_validate_model_entry(profile_name, index, entry)
|
||||
if isinstance(entry, dict) and isinstance(entry.get("id"), str):
|
||||
model_id = entry["id"].strip()
|
||||
if model_id in seen_ids:
|
||||
_error(f"profile {profile_name!r}: duplicate model id {model_id!r}.")
|
||||
seen_ids.add(model_id)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
with CATALOG_PATH.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"ERROR: cannot read {CATALOG_PATH}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
print(f"ERROR: {CATALOG_PATH} must contain a JSON object.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
version = payload.get("version")
|
||||
if version != 1:
|
||||
_error(f"catalog 'version' must be 1, got {version!r}.")
|
||||
|
||||
updated_at = payload.get("updated_at")
|
||||
if not isinstance(updated_at, str) or not updated_at.strip():
|
||||
_error("catalog is missing 'updated_at'.")
|
||||
|
||||
profiles = payload.get("profiles")
|
||||
if not isinstance(profiles, dict):
|
||||
_error("catalog is missing 'profiles' object.")
|
||||
profiles = {}
|
||||
|
||||
for required_key, expected_provider in REQUIRED_PROFILE_KEYS.items():
|
||||
if required_key not in profiles:
|
||||
_error(f"missing required profile {required_key!r} for provider {expected_provider!r}.")
|
||||
continue
|
||||
raw = profiles[required_key]
|
||||
_validate_profile(required_key, raw)
|
||||
if isinstance(raw, dict):
|
||||
actual_provider = (raw.get("provider") or "").strip()
|
||||
if actual_provider != expected_provider:
|
||||
_error(
|
||||
f"profile {required_key!r}: provider {actual_provider!r} "
|
||||
f"does not match expected {expected_provider!r}.",
|
||||
)
|
||||
|
||||
for profile_name, raw in profiles.items():
|
||||
if profile_name not in REQUIRED_PROFILE_KEYS:
|
||||
_validate_profile(profile_name, raw)
|
||||
|
||||
if _ERRORS:
|
||||
print(f"ERROR: {CATALOG_PATH} failed validation:", file=sys.stderr)
|
||||
for message in _ERRORS:
|
||||
print(f" - {message}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
model_count = sum(
|
||||
len(p.get("models") or []) for p in profiles.values() if isinstance(p, dict)
|
||||
)
|
||||
print(f"OK: {CATALOG_PATH} valid ({len(profiles)} profiles, {model_count} models).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Fetch live provider model lists and regenerate web/api_model_catalogs.json.
|
||||
|
||||
For providers that expose a public (key-less) OpenAI-compatible /models
|
||||
endpoint we fetch the full list and refresh the catalog automatically. For
|
||||
providers that require an API key (OpenAI, QwenCloud, Groq, Together AI,
|
||||
Fireworks) the curated entries in web/api_model_catalogs.json are kept as-is.
|
||||
|
||||
Usage:
|
||||
python tools/update_api_model_catalogs.py # fetch public providers
|
||||
python tools/update_api_model_catalogs.py --offline # keep current data
|
||||
python tools/update_api_model_catalogs.py --dry-run # print what would change
|
||||
|
||||
After running, run the validator:
|
||||
python tools/check_api_model_catalogs.py
|
||||
|
||||
Run from the repository root. This is intended to run in CI at release time
|
||||
so the shipped lists stay current.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CATALOG_PATH = REPO_ROOT / "web" / "api_model_catalogs.json"
|
||||
|
||||
# Providers with a public, key-less /models endpoint.
|
||||
PUBLIC_PROVIDERS: dict[str, str] = {
|
||||
"OpenRouter": "https://openrouter.ai/api/v1/models",
|
||||
"OrcaRouter": "https://api.orcarouter.ai/v1/models",
|
||||
"DeepInfra": "https://api.deepinfra.com/v1/openai/models",
|
||||
"Featherless": "https://api.featherless.ai/v1/models",
|
||||
}
|
||||
|
||||
# Providers that require a key and are therefore hand-curated.
|
||||
CURATED_PROVIDERS = ["OpenAI", "QwenCloud (Singapore)", "Together AI", "Fireworks AI", "Groq"]
|
||||
|
||||
# Maximum number of entries kept per provider (to keep the node dropdown usable).
|
||||
MAX_MODELS_PER_PROVIDER = 500
|
||||
|
||||
|
||||
def _fetch_json(url: str, timeout: int = 30) -> dict | list:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "curl/8"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.load(resp)
|
||||
|
||||
|
||||
def _extract_models_openrouter(payload: dict) -> list[str]:
|
||||
items = payload.get("data") or []
|
||||
# Sort by context_length descending so the most capable models come first.
|
||||
items.sort(key=lambda m: m.get("context_length") or 0, reverse=True)
|
||||
return [m["id"] for m in items if isinstance(m.get("id"), str)]
|
||||
|
||||
|
||||
def _extract_models_openai_style(payload: dict | list) -> list[str]:
|
||||
if isinstance(payload, list):
|
||||
items = payload
|
||||
else:
|
||||
items = payload.get("data") or payload.get("models") or []
|
||||
ids = [m.get("id") for m in items if isinstance(m, dict)]
|
||||
return [i for i in ids if isinstance(i, str)]
|
||||
|
||||
|
||||
def _filter_featherless(ids: list[str]) -> list[str]:
|
||||
"""Featherless exposes thousands of raw HF names; keep the usable chat/vision ones."""
|
||||
keywords = ("instruct", "chat", "vision", "vl", "turbo", "-it", "1.5", "3.5")
|
||||
blocked = ("gensyn-swarm", "-gensyn-", "swarm-")
|
||||
out = [
|
||||
i for i in ids
|
||||
if any(k.lower() in i.lower() for k in keywords)
|
||||
and not any(b.lower() in i.lower() for b in blocked)
|
||||
]
|
||||
# De-dup case-insensitively, keep order.
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for model_id in out:
|
||||
key = model_id.lower()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
result.append(model_id)
|
||||
return result
|
||||
|
||||
|
||||
def _sort_models_for_dropdown(ids: list[str], *, by_context: bool = False,
|
||||
context_map: dict[str, int] | None = None) -> list[str]:
|
||||
"""Sort for the dropdown: real models first, special router models last.
|
||||
|
||||
When by_context is set (OpenRouter), order real models by context length
|
||||
descending so the most capable models appear first.
|
||||
"""
|
||||
def sort_key(model_id: str):
|
||||
lowered = model_id.lower()
|
||||
is_special = lowered.startswith("orcarouter/") or lowered in {"openrouter/auto", "openrouter/auto-beta"}
|
||||
ctx = -(context_map.get(model_id, 0) or 0) if by_context else 0
|
||||
return (1 if is_special else 0, ctx, lowered)
|
||||
return sorted(ids, key=sort_key)
|
||||
|
||||
|
||||
def _fetch_public_catalog() -> dict[str, dict]:
|
||||
catalog: dict[str, dict] = {}
|
||||
for provider, url in PUBLIC_PROVIDERS.items():
|
||||
try:
|
||||
payload = _fetch_json(url)
|
||||
except Exception as exc: # noqa: BLE001 - report and continue
|
||||
print(f"[update] WARNING: could not fetch {provider}: {exc}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
if provider == "OpenRouter":
|
||||
ids = _extract_models_openrouter(payload)
|
||||
context_map = {
|
||||
m["id"]: (m.get("context_length") or 0)
|
||||
for m in (payload.get("data") or []) if isinstance(m, dict) and isinstance(m.get("id"), str)
|
||||
}
|
||||
elif provider == "Featherless":
|
||||
ids = _filter_featherless(_extract_models_openai_style(payload))
|
||||
context_map = {}
|
||||
else:
|
||||
ids = _extract_models_openai_style(payload)
|
||||
context_map = {}
|
||||
|
||||
if not ids:
|
||||
print(f"[update] WARNING: {provider} returned no models", file=sys.stderr)
|
||||
continue
|
||||
|
||||
ids = _sort_models_for_dropdown(ids, by_context=(provider == "OpenRouter"), context_map=context_map)
|
||||
models = [
|
||||
{"id": model_id, "label": model_id, "vision": False}
|
||||
for model_id in ids[:MAX_MODELS_PER_PROVIDER]
|
||||
]
|
||||
catalog[provider] = {"provider": provider, "models": models}
|
||||
print(f"[update] {provider}: {len(ids)} models (kept {len(models)}).")
|
||||
return catalog
|
||||
|
||||
|
||||
def _merge_catalogs(previous: dict, fresh: dict) -> dict:
|
||||
"""Replace public providers with fresh data, keep curated providers as-is."""
|
||||
profiles = dict(previous.get("profiles", {}))
|
||||
for provider, entry in fresh.items():
|
||||
profiles[provider] = entry
|
||||
return profiles
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Update the API model catalogs.")
|
||||
parser.add_argument("--offline", action="store_true", help="Do not fetch; just rewrite metadata.")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print changes without writing.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not CATALOG_PATH.exists():
|
||||
print(f"ERROR: {CATALOG_PATH} not found.", file=sys.stderr)
|
||||
return 1
|
||||
with CATALOG_PATH.open("r", encoding="utf-8") as handle:
|
||||
previous = json.load(handle)
|
||||
|
||||
fresh = {} if args.offline else _fetch_public_catalog()
|
||||
profiles = _merge_catalogs(previous, fresh)
|
||||
now = date.today().isoformat()
|
||||
|
||||
result = {
|
||||
"version": 1,
|
||||
"description": previous.get(
|
||||
"description",
|
||||
"Curated convenience model lists for the ThinkingLLM API node. "
|
||||
"These are suggestions only and never override a server-side profile allowed_models allowlist.",
|
||||
),
|
||||
"updated_at": now,
|
||||
"profiles": profiles,
|
||||
}
|
||||
|
||||
if args.dry_run:
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
with CATALOG_PATH.open("w", encoding="utf-8") as handle:
|
||||
json.dump(result, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
print(f"[update] Wrote {CATALOG_PATH} (updated_at={now}).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,5 +1,252 @@
|
||||
# ComfyUI-QwenVL Update Log
|
||||
|
||||
## Version 2.5.8 (2026/09/27) - ROCm SageAttention 1 Support
|
||||
|
||||
### 2.5.8 Changes
|
||||
|
||||
- Added a SageAttention 1.x Triton path for Hugging Face Qwen nodes on ROCm, including SageAttention 1.0.6. Select `attention_mode=sage`, or use `auto` to try Sage before Flash Attention 2 and SDPA.
|
||||
- Retained NVIDIA CUDA kernel selection. Unsupported attention masks, head sizes, and model layers use the original SDPA forward.
|
||||
- Protected the KV cache from SageAttention 1's in-place key smoothing and documented that the GGUF `flash_attn` setting is separate from Hugging Face attention selection.
|
||||
|
||||
### 2.5.8 Validation
|
||||
|
||||
- Seven focused SageAttention tests and all 153 feature and compatibility tests pass on the local Windows CPU test path.
|
||||
- End-to-end inference on ROCm hardware remains unverified.
|
||||
|
||||
## Version 2.5.7 (2026/08/26) - Safe Gemma 4 GGUF Audio
|
||||
|
||||
### 2.5.7 Changes
|
||||
|
||||
- Fixed the dedicated Gemma 4 Audio GGUF node so supported audio no longer hits the obsolete unconditional safety block reported in issue #18.
|
||||
- Kept the native crash protection for older or unknown llama.cpp backends, and now require `llama-cpp-python` 0.3.36 or newer.
|
||||
- Added a positive runtime capability check: the selected `Gemma4ChatHandler` must initialize its multimodal context and the mmproj must report audio support before inference begins.
|
||||
- Reorganized the README into a concise project entry point and moved detailed feature, workflow, model, backend, and troubleshooting explanations into dedicated documentation guides.
|
||||
|
||||
### 2.5.7 Validation
|
||||
|
||||
- Added regression coverage for legacy backends, modern audio-capable backends, the OpenAI-compatible `input_audio` payload, and mmproj files without audio support.
|
||||
- All 153 feature and compatibility tests pass in the bundled ComfyUI Python 3.12 runtime.
|
||||
|
||||
## Version 2.4.0 (2026/08/12) - Duration-Aware Video Prompting and QwenVL Core
|
||||
|
||||
### 2.4.0 Changes
|
||||
|
||||
- Added duration-aware LTX 2.3 and MiniMax H3 prompting across the Hugging Face, ComfyUI-native, and GGUF video workflows.
|
||||
- Split MiniMax H3 prompting into dedicated T2VA, I2VA, FL2VA, L2VA, and full-reference contracts with frame-grid normalization and safer best-effort output cleanup.
|
||||
- Refactored the QwenVL implementation into focused shared modules for model access, runtime configuration, prompt state, prompt contracts, and media handling while preserving compatibility exports.
|
||||
- Improved streaming so reasoning stays separate from final content, unfinished thinking traces trigger a direct finalization retry, and terminal-only punctuation remains safe on legacy Windows code pages.
|
||||
- Expanded preset recommendations, tooltips, system prompts, and documentation for video token budgets, thinking behavior, reference roles, dialogue fidelity, and duration handling.
|
||||
- Fixed dynamic duration-widget hiding so switching from LTX or MiniMax back to `Custom Only` cannot serialize the read-only recommendation text as `duration_seconds`; existing corrupted values are repaired to the last valid duration or `5.0` seconds.
|
||||
|
||||
### 2.4.0 Validation
|
||||
|
||||
- Expanded regression coverage for compatibility, downloads, caching, duration-aware prompting, MiniMax structures, streaming, and node registration.
|
||||
- All 136 isolated regression tests pass in the bundled ComfyUI Python runtime, including duration-widget serialization coverage.
|
||||
|
||||
## Version 2.3.24 (2026/08/08) - System Prompt Preset Utility
|
||||
|
||||
### 2.3.24 Changes
|
||||
|
||||
- Added a standalone `ThinkingLLM System Prompt Preset` utility node that outputs reusable system prompts as a standard `STRING`.
|
||||
- Exposed only presets backed by prompt definitions and made obsolete or unknown saved values return an empty string instead of a display label.
|
||||
- Registered the utility in both Community and commercial releases.
|
||||
- Added the existing ThinkingLLM tool styling and preset tooltip previews without enabling inference-only recommendation or token behavior.
|
||||
|
||||
### 2.3.24 Validation
|
||||
|
||||
- Added regression coverage for prompt lookup, undefined presets, loader registration, and frontend integration.
|
||||
- All 116 isolated regression tests pass on the merged `main` release candidate.
|
||||
|
||||
## Version 2.3.23 (2026/08/08) - MiniMax H3 Reference Prompt Guard
|
||||
|
||||
### 2.3.23 Changes
|
||||
|
||||
- Required an explicit target-video prompt and reference-role assignment for MiniMax H3 Reference-to-Video generation.
|
||||
- Applied the same validation to Hugging Face, ComfyUI-native, and GGUF vision nodes.
|
||||
- Rejected empty ComfyUI-native model generations before they can be cached or saved as blank output.
|
||||
- Documented the correct Text-to-Video versus Reference-to-Video selection and recommended direct-generation settings.
|
||||
|
||||
### 2.3.23 Validation
|
||||
|
||||
- Added regression coverage for missing Reference-to-Video target prompts and empty native model output.
|
||||
- All 114 isolated regression tests pass.
|
||||
|
||||
## Version 2.3.22 (2026/08/06) - Community Model Catalog Fix
|
||||
|
||||
### 2.3.22 Changes
|
||||
|
||||
- Restored plain model names and the original catalog order in Community mode.
|
||||
- Restored automatic downloads for Community models while retaining Hugging Face token requirements for gated repositories.
|
||||
- Kept rights labels, status grouping, and fail-closed model restrictions exclusive to commercial releases.
|
||||
- Preserved compatibility with workflows saved while status-prefixed model names were exposed.
|
||||
- Clarified the Community and commercial model policies in the README and third-party notices.
|
||||
|
||||
### 2.3.22 Validation
|
||||
|
||||
- Added regression coverage for Community dropdowns, Community downloads, commercial restrictions, and legacy status-prefixed workflows.
|
||||
- All 113 isolated regression tests pass on the merged `main` release candidate.
|
||||
|
||||
## Version 2.3.21 (2026/08/06) - Commercial Model Catalog
|
||||
|
||||
### 2.3.21 Changes
|
||||
|
||||
- Grouped HF and GGUF model dropdowns by reviewed commercial status while preserving old workflow model names.
|
||||
- Added fail-closed commercial mode with only the two pinned Qwen models and no runtime model downloads.
|
||||
- Added Hugging Face token handling for external gated models without logging or persisting tokens.
|
||||
- Split MiniMax H3 into text/keyframe and full-reference presets and log the exact LLM input sent by each node.
|
||||
|
||||
### 2.3.21 Validation
|
||||
|
||||
- Added regression coverage for catalog status, legacy workflow resolution, gated access, token redaction, and commercial-mode restrictions.
|
||||
- All 113 isolated regression tests pass after merging the newer MiniMax H3 preset split from `main`.
|
||||
|
||||
## Version 2.3.20 (2026/08/06) - Shared ComfyUI Text Encoders
|
||||
|
||||
### 2.3.20 Changes
|
||||
|
||||
- Added automatic discovery of compatible checkpoints in `ComfyUI/models/text_encoders`.
|
||||
- Added native ComfyUI loading for Qwen3-VL 4B/8B and Gemma 3 12B without duplicated model files.
|
||||
- Added clear errors for unsupported beam and Gemma video inputs.
|
||||
- Kept incomplete MiniMax H3/Qwen3-VL 32B conditioning checkpoints out of the text-generation selector.
|
||||
|
||||
### 2.3.20 Validation
|
||||
|
||||
- Added regression coverage for discovery, native loading, generation, deterministic seeds, and MiniMax H3 exclusion.
|
||||
- All 99 isolated regression tests pass.
|
||||
|
||||
## Version 2.3.19 (2026/08/06) - MiniMax H3 Prompt Preset
|
||||
|
||||
### 2.3.19 Changes
|
||||
|
||||
- Added `🎬 MiniMax H3 Multimodal Video` to vision and text-only prompt nodes.
|
||||
- Added the official T2VA, I2VA, FL2VA, L2VA, and full-reference output structures, including native audio fields and reference-retention analysis.
|
||||
- Documented the preset and linked the official MiniMax H3 prompt guides.
|
||||
|
||||
### 2.3.19 Validation
|
||||
|
||||
- Added regression coverage for preset availability, required H3 fields, and node dropdown exposure.
|
||||
- Kept browser tooltip previews synchronized with the preset source.
|
||||
|
||||
## Version 2.3.18 (2026/07/21) - Advanced Mask Preview
|
||||
|
||||
### 2.3.18 Changes
|
||||
|
||||
- Added a `MASK_PREVIEW` image output to the Hugging Face and GGUF advanced vision nodes.
|
||||
- The preview shows selected pixels at normal brightness and surrounding context dimmed to 20%.
|
||||
- Simple nodes keep their existing two-output workflow contract.
|
||||
- Documented how to connect `MASK_PREVIEW` to a ComfyUI Preview Image node.
|
||||
|
||||
### 2.3.18 Validation
|
||||
|
||||
- All 98 regression tests pass.
|
||||
|
||||
## Version 2.3.17 (2026/07/21) - Mask-Focused Image Editing
|
||||
|
||||
### 2.3.17 Changes
|
||||
|
||||
- Added optional `MASK` input support to all four ThinkingLLM vision nodes.
|
||||
- Kept selected pixels at normal brightness while dimming surrounding context for focused visual analysis.
|
||||
- Added shared Hugging Face and GGUF mask preprocessing with mask-aware cache invalidation.
|
||||
- Added the `👁️ Remove (Mask Area)` preset for positive background-continuation and replacement prompts.
|
||||
- Documented the complete mask-focused object-removal workflow in the README.
|
||||
|
||||
### 2.3.17 Validation
|
||||
|
||||
- Verified mask resizing, soft-mask blending, alpha preservation, input validation, cache invalidation, and node compatibility.
|
||||
- Full local regression suite passes with 97 tests.
|
||||
|
||||
## Version 2.3.16 (2026/07/13) — Gemma 4 E2B Catalog Repair
|
||||
|
||||
### 2.3.16 Changes
|
||||
|
||||
- Replaced stale Gemma 4 E2B GGUF download entries with currently hosted Bartowski Q4_K_S, Q8_0, and BF16 files.
|
||||
- Preserved reuse of existing ggml-org Q8_0 and BF16 downloads through local filename aliases.
|
||||
|
||||
### 2.3.16 Validation
|
||||
|
||||
- Verified the replacement filenames against the live Hugging Face repository API.
|
||||
- Added regression coverage for both Gemma 4 E2B text-catalog entries.
|
||||
- Full local regression suite passes.
|
||||
|
||||
## Version 2.3.15 (2026/07/12) — Installed GGUF Visibility
|
||||
|
||||
### 2.3.15 Changes
|
||||
|
||||
- Marked catalogued GGUF model files found on disk with `[installed]` in both ThinkingLLM GGUF selectors.
|
||||
- Kept `[local]` for uncatalogued GGUF files and preserved compatibility with model names stored by older workflows.
|
||||
|
||||
### 2.3.15 Validation
|
||||
|
||||
- Added regression coverage for installed-model labelling, duplicate suppression, and legacy model-name resolution.
|
||||
- Full local regression suite passes.
|
||||
|
||||
## Version 2.3.14 (2026/06/20) — Gemma4 GGUF Stability Release
|
||||
|
||||
### 2.3.14 Changes
|
||||
|
||||
- Stabilized `google_gemma-4-E4B-it-GGUF` multimodal runs by applying Gemma4-compatible GGUF image settings before llama.cpp execution.
|
||||
- Pinned llama.cpp `main_gpu` to ComfyUI's selected CUDA device so GGUF loads do not drift to the wrong card after CUDA state changes.
|
||||
- Blocked unsupported Gemma4 GGUF audio before native llama.cpp can abort the ComfyUI process.
|
||||
- Reused existing mapped GGUF model files across ComfyUI `LLM` model paths to avoid redundant downloads.
|
||||
- Removed duplicate legacy ThinkingLLM search entries from the ComfyUI node list.
|
||||
|
||||
### 2.3.14 Validation
|
||||
|
||||
- Full local regression suite passes.
|
||||
- Live NVIDIA portable ComfyUI test completed successfully with `google_gemma-4-E4B-it-Q8_0.gguf` and an image prompt.
|
||||
|
||||
## Version 2.3.13 (2026/06/20) — Recommendation Note Cleanup
|
||||
|
||||
### 2.3.13 Changes
|
||||
|
||||
- Removed duplicate compact recommendation-note rows from ThinkingLLM nodes, including Advanced GGUF.
|
||||
- Kept the real multiline recommendation note visible while pruning stale duplicate `recommended_settings` widgets.
|
||||
- Prevented recommendation notes from being serialized back into workflows as duplicate UI-only widgets.
|
||||
|
||||
### 2.3.13 Validation
|
||||
|
||||
- Added regression coverage for recommendation widget deduplication.
|
||||
- Full local regression suite passes.
|
||||
|
||||
## Version 2.3.12 (2026/06/20) — Rollback Recovery Release
|
||||
|
||||
### 2.3.12 Changes
|
||||
|
||||
- Rebased the public release line onto the rollback-safe GGUF single-pass state from 2.3.11.
|
||||
- Preserved the ComfyUI execution highlight behavior by no longer overwriting node colors once ComfyUI has set them.
|
||||
- Restored the saved-workflow compatible `Custom prompt + image (no preset)` option.
|
||||
|
||||
### 2.3.12 Validation
|
||||
|
||||
- Local regression coverage verifies the execution color guard and the restored no-preset alias.
|
||||
- Full local regression suite passes.
|
||||
|
||||
## Version 2.3.11 (2026/06/19) — GGUF Single-Pass Execution Fix
|
||||
|
||||
### 2.3.11 Changes
|
||||
|
||||
- Disabled automatic GGUF finalization retries by default so one node execution performs one LLM generation pass unless explicitly configured otherwise.
|
||||
- Added `auto_finalization_retry` to `ThinkingLLM (GGUF)`, `ThinkingLLM Advanced (GGUF)`, `ThinkingLLM Gemma 4 Audio (GGUF)`, and `ThinkingLLM Prompt Enhancer (GGUF)` for users who still want the old empty/reasoning-only retry behavior.
|
||||
- Fixed catalogued GGUF model reuse across ComfyUI `extra_model_paths.yaml` `LLM` mappings, preventing unnecessary re-downloads when models already exist in a shared `models/LLM/GGUF` directory.
|
||||
|
||||
### 2.3.11 Validation
|
||||
|
||||
- Added regression coverage for default single-pass GGUF execution, explicit opt-in finalization retry, GGUF node UI defaults, and shared `LLM/GGUF` model reuse.
|
||||
- Targeted GGUF tests pass.
|
||||
|
||||
## Version 2.3.10 (2026/06/19) — ComfyUI 2.0 Advanced GGUF Layout Fix
|
||||
|
||||
### 2.3.10 Changes
|
||||
|
||||
- Fixed the large blank area in `ThinkingLLM Advanced (GGUF)` under ComfyUI Nodes 2.0 by no longer hiding real advanced tuning widgets behind a layout hack.
|
||||
- Kept legacy workflow compatibility shims hidden while exposing `n_ubatch`, thread, flash-attention, KQV offload, and context-checkpoint controls.
|
||||
- Added regression coverage for the ComfyUI 2.0 layout compatibility rule.
|
||||
|
||||
### 2.3.10 Validation
|
||||
|
||||
- Targeted Advanced GGUF compatibility tests pass.
|
||||
- Full local regression test file passes.
|
||||
|
||||
## Version 2.3.6 (2026/06/14) — GGUF Context Reset Hardening
|
||||
|
||||
### 2.3.6 Changes
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+418
-18
@@ -6,6 +6,7 @@ const COLOR_THEMES = {
|
||||
QwenVLGGUF: { nodeColor: "#474539", nodeBgColor: "#2c4045", width: 340 },
|
||||
Tools: { nodeColor: "#28403f", nodeBgColor: "#233238", width: 300 },
|
||||
Enhancer: { nodeColor: "#374445", nodeBgColor: "#474539", width: 340 },
|
||||
Api: { nodeColor: "#3a2f4a", nodeBgColor: "#2b2740", width: 360 },
|
||||
};
|
||||
|
||||
const NODE_COLORS = {
|
||||
@@ -23,9 +24,11 @@ const NODE_COLORS = {
|
||||
"ThinkingLLM_Gemma4_Audio_GGUF": "QwenVLGGUF",
|
||||
"ThinkingLLM_Whisper_ASR": "QwenVLGGUF",
|
||||
"ThinkingLLM_QwenVL_GGUF_PromptEnhancer": "Enhancer",
|
||||
"ThinkingLLM_SystemPromptPreset": "Tools",
|
||||
"AILab_QwenVL_PromptLibrary": "Tools",
|
||||
"VRAMCleanup": "Tools",
|
||||
"StorySplitNode": "Tools",
|
||||
"ThinkingLLM_OpenAICompatibleAPI": "Api",
|
||||
};
|
||||
|
||||
const THINKINGLLM_NODE_NAMES = new Set([
|
||||
@@ -45,6 +48,26 @@ const THINKINGLLM_NODE_NAMES = new Set([
|
||||
"ThinkingLLM_QwenVL_GGUF_PromptEnhancer",
|
||||
]);
|
||||
|
||||
const PRESET_TOOLTIP_NODE_NAMES = new Set([
|
||||
...THINKINGLLM_NODE_NAMES,
|
||||
"ThinkingLLM_SystemPromptPreset",
|
||||
]);
|
||||
|
||||
const DURATION_INPUT_NODE_NAMES = new Set([
|
||||
"AILab_QwenVL",
|
||||
"AILab_QwenVL_Advanced",
|
||||
"AILab_QwenVL_PromptEnhancer",
|
||||
"AILab_QwenVL_GGUF",
|
||||
"AILab_QwenVL_GGUF_Advanced",
|
||||
"AILab_QwenVL_GGUF_PromptEnhancer",
|
||||
"ThinkingLLM_QwenVL",
|
||||
"ThinkingLLM_QwenVL_Advanced",
|
||||
"ThinkingLLM_QwenVL_PromptEnhancer",
|
||||
"ThinkingLLM_QwenVL_GGUF",
|
||||
"ThinkingLLM_QwenVL_GGUF_Advanced",
|
||||
"ThinkingLLM_QwenVL_GGUF_PromptEnhancer",
|
||||
]);
|
||||
|
||||
const AUDIO_CAPABLE_NODE_NAMES = new Set([
|
||||
"ThinkingLLM_Gemma4_Audio_GGUF",
|
||||
"ThinkingLLM_Whisper_ASR",
|
||||
@@ -57,21 +80,25 @@ const AUDIO_CAPABLE_NODE_NAMES = new Set([
|
||||
const GGUF_ADVANCED_INTERNAL_WIDGETS = new Set([
|
||||
"legacy_seed_mode",
|
||||
"legacy_unload_after_run",
|
||||
"n_ubatch",
|
||||
"n_threads",
|
||||
"n_threads_batch",
|
||||
"flash_attn",
|
||||
"offload_kqv",
|
||||
"ctx_checkpoints",
|
||||
]);
|
||||
|
||||
const RECOMMENDATIONS_URL = new URL("../model_recommendations.json", import.meta.url);
|
||||
const PRESET_TOOLTIPS_URL = new URL("../preset_tooltips.json", import.meta.url);
|
||||
const API_MODEL_CATALOGS_URL = new URL("../api_model_catalogs.json", import.meta.url);
|
||||
const API_NODE_CLASS = "ThinkingLLM_OpenAICompatibleAPI";
|
||||
const API_PROFILE_WIDGET = "api_profile";
|
||||
const API_MODEL_WIDGET = "model_name";
|
||||
const API_HELP_WIDGET_NAME = "setup_help";
|
||||
const RECOMMENDATION_WIDGET_NAME = "recommended_settings";
|
||||
const RECOMMENDATION_PLACEHOLDER = "Select a model to see provider/community recommended settings. This note never changes your saved widget values.";
|
||||
const DEFAULT_DURATION_SECONDS = 5.0;
|
||||
const RECOMMENDATION_FIELDS = ["temperature", "top_p", "top_k", "max_tokens", "ctx_min", "n_batch", "image_max_tokens"];
|
||||
const AUDIO_UNSUPPORTED_MESSAGE = "No curated audio support for the selected model. Use Gemma 4 Audio for audio understanding or Whisper ASR for transcription.";
|
||||
const PRESET_WIDGET_NAMES = ["preset_prompt", "enhancement_style", "preset_system_prompt"];
|
||||
|
||||
let recommendationsPromise = null;
|
||||
let presetTooltipsPromise = null;
|
||||
let apiModelCatalogsPromise = null;
|
||||
|
||||
function loadRecommendations() {
|
||||
if (!recommendationsPromise) {
|
||||
@@ -91,12 +118,47 @@ function loadRecommendations() {
|
||||
return recommendationsPromise;
|
||||
}
|
||||
|
||||
function loadApiModelCatalogs() {
|
||||
if (!apiModelCatalogsPromise) {
|
||||
apiModelCatalogsPromise = fetch(API_MODEL_CATALOGS_URL, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((payload) => (payload?.profiles && typeof payload.profiles === "object" ? payload.profiles : {}))
|
||||
.catch((error) => {
|
||||
console.warn("[ThinkingLLM] Failed to load API model catalogs", error);
|
||||
return {};
|
||||
});
|
||||
}
|
||||
return apiModelCatalogsPromise;
|
||||
}
|
||||
|
||||
function loadPresetTooltips() {
|
||||
if (!presetTooltipsPromise) {
|
||||
presetTooltipsPromise = fetch(PRESET_TOOLTIPS_URL, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[ThinkingLLM] Failed to load preset tooltips", error);
|
||||
return {};
|
||||
});
|
||||
}
|
||||
return presetTooltipsPromise;
|
||||
}
|
||||
|
||||
function setNodeColors(node, theme) {
|
||||
if (!theme) { return; }
|
||||
if (theme.nodeColor) {
|
||||
if (!node.color && theme.nodeColor) {
|
||||
node.color = theme.nodeColor;
|
||||
}
|
||||
if (theme.nodeBgColor) {
|
||||
if (!node.bgcolor && theme.nodeBgColor) {
|
||||
node.bgcolor = theme.nodeBgColor;
|
||||
}
|
||||
if (theme.width) {
|
||||
@@ -116,7 +178,121 @@ function hideStableWidget(node, widget) {
|
||||
widget._thinkingllmOriginalComputeSize = widget.computeSize;
|
||||
widget.hidden = true;
|
||||
widget.type = `thinkingllm_hidden_${widget.type || "widget"}`;
|
||||
widget.computeSize = () => [0, -4];
|
||||
widget.computeSize = () => [0, 0];
|
||||
widget.draw = () => {};
|
||||
}
|
||||
|
||||
function setWidgetVisible(node, widget, visible) {
|
||||
if (!widget) { return; }
|
||||
if (!widget._thinkingllmVisibilityState) {
|
||||
widget._thinkingllmVisibilityState = {
|
||||
computeSize: widget.computeSize,
|
||||
hidden: widget.hidden,
|
||||
};
|
||||
}
|
||||
const original = widget._thinkingllmVisibilityState;
|
||||
if (visible) {
|
||||
widget.computeSize = original.computeSize;
|
||||
widget.hidden = original.hidden;
|
||||
delete widget.computedHeight;
|
||||
} else {
|
||||
widget.hidden = true;
|
||||
widget.computeSize = () => [0, -3.3];
|
||||
widget.computedHeight = 0;
|
||||
}
|
||||
resizeNode(node);
|
||||
}
|
||||
|
||||
function repairDurationWidgetValue(widget) {
|
||||
if (!widget) { return; }
|
||||
const numericValue = typeof widget.value === "number"
|
||||
? widget.value
|
||||
: Number(String(widget.value ?? "").trim());
|
||||
const minimum = Number(widget.options?.min ?? 0.2);
|
||||
const maximum = Number(widget.options?.max ?? 150.0);
|
||||
if (Number.isFinite(numericValue) && numericValue >= minimum && numericValue <= maximum) {
|
||||
widget.value = numericValue;
|
||||
widget._thinkingllmLastValidDuration = numericValue;
|
||||
return;
|
||||
}
|
||||
const previousValue = Number(widget._thinkingllmLastValidDuration);
|
||||
widget.value = Number.isFinite(previousValue) ? previousValue : DEFAULT_DURATION_SECONDS;
|
||||
}
|
||||
|
||||
function readComboValue(widget) {
|
||||
const value = widget?.value;
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const key of ["value", "content", "label", "name"]) {
|
||||
if (typeof value[key] === "string") {
|
||||
return value[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function normalizePresetName(value) {
|
||||
return String(value || "").normalize("NFC").replaceAll("\uFE0F", "").trim();
|
||||
}
|
||||
|
||||
function findVideoPresetMetadata(payload, selectedPreset) {
|
||||
const presets = payload?.video_presets;
|
||||
if (!presets || typeof presets !== "object" || Array.isArray(presets)) {
|
||||
return { metadataAvailable: false, metadata: null };
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(presets, selectedPreset)) {
|
||||
return { metadataAvailable: true, metadata: presets[selectedPreset] };
|
||||
}
|
||||
const normalizedSelection = normalizePresetName(selectedPreset);
|
||||
const matchingEntry = Object.entries(presets)
|
||||
.find(([name]) => normalizePresetName(name) === normalizedSelection);
|
||||
return {
|
||||
metadataAvailable: true,
|
||||
metadata: matchingEntry?.[1] || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedPresetName(node) {
|
||||
return PRESET_WIDGET_NAMES
|
||||
.map((name) => readComboValue(findWidget(node, name)))
|
||||
.find((value) => value) || "";
|
||||
}
|
||||
|
||||
function updateDurationVisibility(node, payload) {
|
||||
if (!DURATION_INPUT_NODE_NAMES.has(node.comfyClass)) {
|
||||
return;
|
||||
}
|
||||
const selectedPreset = selectedPresetName(node);
|
||||
const durationWidget = findWidget(node, "duration_seconds");
|
||||
repairDurationWidgetValue(durationWidget);
|
||||
const { metadataAvailable, metadata } = findVideoPresetMetadata(payload, selectedPreset);
|
||||
if (!metadataAvailable) {
|
||||
// Keep the backend-provided control usable if the auxiliary payload is stale or unavailable.
|
||||
setWidgetVisible(node, durationWidget, true);
|
||||
return;
|
||||
}
|
||||
setWidgetVisible(node, durationWidget, Boolean(metadata?.duration_required));
|
||||
}
|
||||
|
||||
function findWidgets(node, name) {
|
||||
return node.widgets?.filter((widget) => widget?.name === name) || [];
|
||||
}
|
||||
|
||||
function removeDuplicateRecommendationWidgets(node, keepWidget) {
|
||||
if (!node.widgets?.length || !keepWidget) {
|
||||
return;
|
||||
}
|
||||
for (let index = node.widgets.length - 1; index >= 0; index -= 1) {
|
||||
const widget = node.widgets[index];
|
||||
if (widget?.name !== RECOMMENDATION_WIDGET_NAME || widget === keepWidget) {
|
||||
continue;
|
||||
}
|
||||
hideStableWidget(node, widget);
|
||||
node.widgets.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function hideGgufAdvancedInternals(node) {
|
||||
@@ -289,25 +465,63 @@ function buildRecommendationText(node, rules) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildVideoPresetRecommendationText(node, payload) {
|
||||
const presetName = selectedPresetName(node);
|
||||
if (!presetName) {
|
||||
return "";
|
||||
}
|
||||
const { metadata } = findVideoPresetMetadata(payload, presetName);
|
||||
const settings = metadata?.recommended_settings;
|
||||
if (!settings || typeof settings !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const suggested = [];
|
||||
if (hasWidget(node, "enable_thinking") && typeof settings.enable_thinking === "boolean") {
|
||||
suggested.push(`thinking ${settings.enable_thinking ? "ON" : "OFF"}`);
|
||||
}
|
||||
if (hasWidget(node, "max_tokens") && Number.isFinite(settings.max_tokens)) {
|
||||
suggested.push(`max_tokens=${settings.max_tokens}`);
|
||||
}
|
||||
if (!suggested.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`Video preset: ${presetName}`,
|
||||
`Suggested: ${suggested.join(" | ")}`,
|
||||
];
|
||||
if (metadata.recommendation_note) {
|
||||
lines.push(`Reason: ${metadata.recommendation_note}`);
|
||||
}
|
||||
lines.push("Sampler: follow the model/input-mode recommendation above.");
|
||||
lines.push("Info only: your saved widget values are not changed.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function ensureRecommendationWidget(node) {
|
||||
if (!THINKINGLLM_NODE_NAMES.has(node.comfyClass)) {
|
||||
return null;
|
||||
}
|
||||
let widget = findWidget(node, RECOMMENDATION_WIDGET_NAME);
|
||||
if (widget) {
|
||||
return widget;
|
||||
let widget = findWidgets(node, RECOMMENDATION_WIDGET_NAME)
|
||||
.find((candidate) => candidate?.inputEl)
|
||||
|| findWidget(node, RECOMMENDATION_WIDGET_NAME);
|
||||
if (!widget) {
|
||||
widget = ComfyWidgets.STRING(node, RECOMMENDATION_WIDGET_NAME, ["STRING", { multiline: true }], app).widget;
|
||||
}
|
||||
widget = ComfyWidgets.STRING(node, RECOMMENDATION_WIDGET_NAME, ["STRING", { multiline: true }], app).widget;
|
||||
removeDuplicateRecommendationWidgets(node, widget);
|
||||
if (widget?.inputEl) {
|
||||
widget.inputEl.readOnly = true;
|
||||
widget.inputEl.style.opacity = 0.78;
|
||||
widget.inputEl.style.fontSize = "12px";
|
||||
widget.inputEl.style.lineHeight = "1.35";
|
||||
widget.inputEl.rows = 6;
|
||||
widget.inputEl.rows = 10;
|
||||
}
|
||||
widget.options = { ...(widget.options || {}), serialize: false };
|
||||
widget.serializeValue = async () => undefined;
|
||||
widget.value = RECOMMENDATION_PLACEHOLDER;
|
||||
if (!widget.value) {
|
||||
widget.value = RECOMMENDATION_PLACEHOLDER;
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
|
||||
@@ -330,8 +544,11 @@ function updateRecommendationWidget(node) {
|
||||
if (!widget) {
|
||||
return;
|
||||
}
|
||||
loadRecommendations().then((rules) => {
|
||||
widget.value = buildRecommendationText(node, rules);
|
||||
Promise.all([loadRecommendations(), loadPresetTooltips()]).then(([rules, payload]) => {
|
||||
widget.value = [
|
||||
buildRecommendationText(node, rules),
|
||||
buildVideoPresetRecommendationText(node, payload),
|
||||
].filter(Boolean).join("\n\n");
|
||||
resizeNode(node);
|
||||
});
|
||||
}
|
||||
@@ -347,6 +564,186 @@ function queueRecommendationRefresh(node) {
|
||||
});
|
||||
}
|
||||
|
||||
function catalogModelsForProfile(catalogs, profileName) {
|
||||
const entry = catalogs?.[profileName];
|
||||
const models = Array.isArray(entry?.models) ? entry.models : [];
|
||||
return models
|
||||
.map((model) => (model && typeof model.id === "string" ? model.id : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isApiNode(node) {
|
||||
return node?.comfyClass === API_NODE_CLASS;
|
||||
}
|
||||
|
||||
function applyApiModelCombo(node, catalogs) {
|
||||
if (!isApiNode(node)) {
|
||||
return;
|
||||
}
|
||||
const profileWidget = findWidget(node, API_PROFILE_WIDGET);
|
||||
const modelWidget = findWidget(node, API_MODEL_WIDGET);
|
||||
if (!profileWidget || !modelWidget) {
|
||||
return;
|
||||
}
|
||||
const profileName = readComboValue(profileWidget);
|
||||
const models = catalogModelsForProfile(catalogs, profileName);
|
||||
const options = models.length ? [...new Set(models)] : null;
|
||||
|
||||
if (options) {
|
||||
// Always keep a native combo when a curated list exists for this profile.
|
||||
if (modelWidget.type !== "combo") {
|
||||
modelWidget.type = "combo";
|
||||
modelWidget.serialize = true;
|
||||
}
|
||||
if (Array.isArray(modelWidget.options?.values)) {
|
||||
const equal = options.length === modelWidget.options.values.length
|
||||
&& options.every((option, index) => option === modelWidget.options.values[index]);
|
||||
if (!equal) {
|
||||
modelWidget.options.values = options;
|
||||
}
|
||||
}
|
||||
// Keep the current value if it is still a valid option; otherwise fall back to the first.
|
||||
const currentValue = readComboValue(modelWidget);
|
||||
if (!options.includes(currentValue)) {
|
||||
modelWidget.value = options[0];
|
||||
modelWidget.options.value = options[0];
|
||||
}
|
||||
}
|
||||
resizeNode(node);
|
||||
}
|
||||
|
||||
function hookApiModelDropdown(node) {
|
||||
if (!isApiNode(node) || node._thinkingllmApiModelHooked) {
|
||||
return;
|
||||
}
|
||||
node._thinkingllmApiModelHooked = true;
|
||||
|
||||
const profileWidget = findWidget(node, API_PROFILE_WIDGET);
|
||||
const modelWidget = findWidget(node, API_MODEL_WIDGET);
|
||||
|
||||
loadApiModelCatalogs().then((catalogs) => {
|
||||
const refresh = () => applyApiModelCombo(node, catalogs);
|
||||
if (profileWidget) {
|
||||
const originalCallback = profileWidget.callback;
|
||||
profileWidget.callback = function (...args) {
|
||||
const result = originalCallback?.apply(this, args);
|
||||
refresh();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function buildApiHelpText() {
|
||||
return [
|
||||
"SETUP (one-time):",
|
||||
"",
|
||||
"Windows - set the API key as a user environment variable (PowerShell):",
|
||||
' [System.Environment]::SetEnvironmentVariable("OPENROUTER_API_KEY", "sk-...", "User")',
|
||||
" (Variable name depends on provider: OPENROUTER_API_KEY, OPENAI_API_KEY,",
|
||||
" DASHSCOPE_API_KEY, GROQ_API_KEY, ORCAROUTER_API_KEY, ...)",
|
||||
"",
|
||||
"Linux/macOS - export it in the terminal before starting ComfyUI:",
|
||||
' export OPENROUTER_API_KEY="sk-..."',
|
||||
"",
|
||||
"Then RESTART ComfyUI. The key lives only on the server/machine -",
|
||||
"never in the workflow, profile JSON, or Git.",
|
||||
"",
|
||||
"Docs: docs/OPENAI_COMPATIBLE_API.md",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function ensureApiHelpWidget(node) {
|
||||
if (!isApiNode(node)) {
|
||||
return null;
|
||||
}
|
||||
let widget = findWidget(node, API_HELP_WIDGET_NAME);
|
||||
if (!widget) {
|
||||
widget = ComfyWidgets.STRING(node, API_HELP_WIDGET_NAME, ["STRING", { multiline: true }], app).widget;
|
||||
}
|
||||
if (widget?.inputEl) {
|
||||
widget.inputEl.readOnly = true;
|
||||
widget.inputEl.style.opacity = 0.75;
|
||||
widget.inputEl.style.fontSize = "12px";
|
||||
widget.inputEl.style.lineHeight = "1.35";
|
||||
widget.inputEl.rows = 12;
|
||||
}
|
||||
widget.options = { ...(widget.options || {}), serialize: false };
|
||||
widget.serializeValue = async () => undefined;
|
||||
widget.value = buildApiHelpText();
|
||||
return widget;
|
||||
}
|
||||
|
||||
function hookApiHelp(node) {
|
||||
if (!isApiNode(node) || node._thinkingllmApiHelpHooked) {
|
||||
return;
|
||||
}
|
||||
node._thinkingllmApiHelpHooked = true;
|
||||
ensureApiHelpWidget(node);
|
||||
resizeNode(node);
|
||||
}
|
||||
|
||||
function summarizePresetTooltip(prompt) {
|
||||
const text = String(prompt || "").trim();
|
||||
if (!text) {
|
||||
return "No preset prompt is active. Use the custom prompt field, or combine multiple text nodes for longer prompt recipes.";
|
||||
}
|
||||
return text.length > 1800 ? `${text.slice(0, 1800).trim()}...` : text;
|
||||
}
|
||||
|
||||
function updatePresetTooltip(widget, promptMap) {
|
||||
if (!widget) {
|
||||
return;
|
||||
}
|
||||
const prompt = promptMap?.[widget.name]?.[widget.value];
|
||||
const tooltip = summarizePresetTooltip(prompt);
|
||||
widget.options = { ...(widget.options || {}), tooltip };
|
||||
widget.tooltip = tooltip;
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.title = tooltip;
|
||||
}
|
||||
}
|
||||
|
||||
function hookPresetTooltipPreviews(node) {
|
||||
if (!PRESET_TOOLTIP_NODE_NAMES.has(node.comfyClass) || node._thinkingllmPresetTooltipsHooked) {
|
||||
return;
|
||||
}
|
||||
node._thinkingllmPresetTooltipsHooked = true;
|
||||
|
||||
loadPresetTooltips().then((promptMap) => {
|
||||
const refresh = () => {
|
||||
for (const widgetName of PRESET_WIDGET_NAMES) {
|
||||
updatePresetTooltip(findWidget(node, widgetName), promptMap);
|
||||
}
|
||||
updateDurationVisibility(node, promptMap);
|
||||
};
|
||||
|
||||
for (const widgetName of PRESET_WIDGET_NAMES) {
|
||||
const target = findWidget(node, widgetName);
|
||||
if (!target || target._thinkingllmPresetTooltipCallbackHooked) {
|
||||
continue;
|
||||
}
|
||||
target._thinkingllmPresetTooltipCallbackHooked = true;
|
||||
const originalCallback = target.callback;
|
||||
target.callback = function (...args) {
|
||||
const result = originalCallback?.apply(this, args);
|
||||
refresh();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
const originalConfigure = node.onConfigure;
|
||||
node.onConfigure = function (...args) {
|
||||
const result = originalConfigure?.apply(this, args);
|
||||
refresh();
|
||||
return result;
|
||||
};
|
||||
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function hookRecommendationUpdates(node) {
|
||||
if (!THINKINGLLM_NODE_NAMES.has(node.comfyClass) || node._thinkingllmRecommendationHooked) {
|
||||
return;
|
||||
@@ -354,7 +751,7 @@ function hookRecommendationUpdates(node) {
|
||||
node._thinkingllmRecommendationHooked = true;
|
||||
ensureRecommendationWidget(node);
|
||||
|
||||
for (const widgetName of ["model_name", "enable_thinking"]) {
|
||||
for (const widgetName of ["model_name", "enable_thinking", ...PRESET_WIDGET_NAMES]) {
|
||||
const target = findWidget(node, widgetName);
|
||||
if (!target || target._thinkingllmRecommendationCallbackHooked) {
|
||||
continue;
|
||||
@@ -390,7 +787,10 @@ const ext = {
|
||||
}
|
||||
hideGgufAdvancedInternals(node);
|
||||
hookRecommendationUpdates(node);
|
||||
hookPresetTooltipPreviews(node);
|
||||
clearHfTokenAfterExecution(node);
|
||||
hookApiModelDropdown(node);
|
||||
hookApiHelp(node);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"rules": [
|
||||
{
|
||||
"id": "qwen38_blackfrost_27b",
|
||||
"priority": 110,
|
||||
"match_any": [
|
||||
"Qwen3.8-27B-ABLITERATED"
|
||||
],
|
||||
"family_label": "Qwen3.8 27B Blackfrost abliterated",
|
||||
"sources": [
|
||||
"Blackfrost-AI Qwen3.8 27B Abliterated GGUF model card",
|
||||
"Qwen3.8 27B official model card"
|
||||
],
|
||||
"support_notes": {
|
||||
"hf": "This catalog entry is GGUF-only. Use a GGUF node for this repository.",
|
||||
"gguf": "Hard toggle via enable_thinking in the Qwen3.8 chat template. Vision requires the matching mmproj file and a current llama.cpp build.",
|
||||
"soft_switch": "Qwen3.8 uses chat-template thinking controls; no /think or /no_think soft switch is documented."
|
||||
},
|
||||
"thinking_on": {
|
||||
"temperature": 1.0,
|
||||
"top_p": 0.95,
|
||||
"top_k": 20,
|
||||
"max_tokens": 32768,
|
||||
"ctx_min": 262144
|
||||
},
|
||||
"thinking_off": {
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.8,
|
||||
"top_k": 20,
|
||||
"max_tokens": 32768,
|
||||
"ctx_min": 262144
|
||||
},
|
||||
"notes": [
|
||||
"Thinking is enabled by default; disable it for direct prompt rewrites and keep it enabled for deeper scene planning.",
|
||||
"Q4_K_M is the model author's balanced default; use the compact Q8 projector for image or video input.",
|
||||
"The model supports 262K context architecturally, but large llama.cpp contexts consume substantial KV-cache memory; lower ctx for ordinary prompt work."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "qwen36_hauhau_multimodal",
|
||||
"priority": 100,
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
{
|
||||
"preset_prompt": {
|
||||
"🚫 No preset (image-only)": "",
|
||||
"💬 Custom prompt + image (no preset)": "",
|
||||
"👁️ Remove (Mask Area)": "Create a positive diffusion/inpainting prompt describing what should naturally appear inside the selected normal-brightness region after it is repainted.\n\nDo not describe or preserve the region's current subject. Use the dimmed surrounding scene and the selection boundaries to infer the hidden background or the most natural replacement. Continue nearby surfaces and structures seamlessly, matching geometry, perspective, scale, materials, texture, patterns, color, lighting, shadows, reflections, depth of field, image style, and detail level.\n\nIf the user specifies replacement content, describe it positively and integrate it naturally into the surrounding scene. Otherwise, infer the most plausible continuation of the background. If the selected region crosses multiple surfaces, describe how each surface continues and connects.\n\nOutput only one concise, fluent diffusion prompt describing the desired final pixels. Do not mention removal, erasing, masks, selections, inpainting, editing, absence, or what was previously there. Do not describe unrelated parts of the image. No preface, labels, reasoning, instructions, or negative prompt.",
|
||||
"🎦 LTX 2.3 NSFW I2V Scene": "You are an expert cinematic prompt director for LTX 2.3 image-to-video generation. Convert the user input and visual input into one fluent English cinematic prompt.\n\nRead the image first, then integrate the user text. The image first rule is mandatory: visual facts from the image have priority over contradictory user text. Describe the visible subject, setting, action, pose, camera angle, lens feel, lighting, color, texture, and environment before adding requested motion or story intent.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Write a single flowing paragraph of 120-200 words. Start directly with the main action. Include specific movements, gestures, object or character appearances, background details, camera movement, lighting behavior, and any sudden changes. Keep the prompt literal, precise, and usable for LTX 2.3. If the source contains NSFW adult content, describe it according to the user intent and visual evidence without adding unrelated content.\n\nOutput the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning. Do not exceed 200 words.",
|
||||
"🎞️ LTX 2.3 First/Last-to-Video": "You are an expert cinematic prompt director for LTX 2.3 first-and-last-frame video generation. Treat Picture 1 as the exact opening frame and Picture 2 as the exact final frame. Convert the user request and both visual anchors into one fluent English cinematic prompt that preserves visible identity, composition, wardrobe, props, environment, lighting, color, geometry, and style. Infer a plausible continuous transformation from the opening state to the final state without inventing cuts unless the user explicitly requests them.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep every action inside that total duration. Write one flowing paragraph of 120-200 words in chronological playback order. Begin with the opening action, describe subject and object motion, camera movement, environmental changes, timing and causal transitions, then converge precisely on Picture 2 at the end. Keep the language literal, visual, and optimized for LTX 2.3 motion. Adult content may be described only when present in the references or requested by the user.\n\nOutput only the paragraph. No headings, labels, timestamps, bullets, markdown, notes, or reasoning. Do not exceed 200 words.",
|
||||
"🖼️ LTX 2.3 Reference-to-Video": "You are an expert cinematic prompt director for LTX 2.3 reference-to-video generation. Analyze every connected image or video and use the user's text to assign each reference a clear role such as subject identity, wardrobe, prop, environment, visual style, action, pose, or camera language. References are reusable visual anchors, not automatically first or last frames. Preserve the requested identities and stable visual attributes while composing one coherent new target scene.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Write one fluent English paragraph of 120-200 words in playback order, covering the opening composition, subject appearance and positions, actions and state changes, camera movement, environment, lighting, texture, and final visual state. Keep reference roles consistent and avoid unrelated additions. Adult content may be described only when present in the references or requested by the user.\n\nOutput only the paragraph. No headings, reference analysis, labels, bullets, markdown, notes, or reasoning. Do not exceed 200 words.",
|
||||
"🎬 MiniMax H3 Text-to-Video": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. Do not infer image roles and do not output analysis, markdown, notes, or alternatives.\n\nTARGET_DURATION_SECONDS is authoritative internal context. Use it to keep actions feasible and every cut timestamp strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the entire storyline into a rapid montage. You may omit scenes and dialogue outside the selected moment, but must not invent replacements.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut is sequential and uses exactly [Shot N] At MM:SS.mmm, followed by an explicit cut or transition that adds new information. Camera movement must use official natural phrasing: motion type, plus small or large amplitude and slow or fast speed only when meaningful; omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue syntax uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>; all spoken words stay inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, or plot beats beyond explicit user facts and direct physical consequences of requested actions. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If the user did not explicitly request audience-only background music, output non_diegetic_music: N/A. Return only the three-field prompt.",
|
||||
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)": "You are a strict MiniMax H3 I2VA prompt formatter following the official Base guide. Treat the connected image as <Picture 1>, the exact first frame of [Shot 1], and develop forward from it without inventing unrelated content. TARGET_DURATION_SECONDS is internal context only; never print a target-duration declaration.\n\nThe final prompt begins with exactly: For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced. Leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and preserves the picture's identity, clothing, colors, objects, composition, geometry, lighting, and spatial relationships before describing the next visible action.\n\nLater cuts use [Shot N] At MM:SS.mmm, with strictly increasing times inside TARGET_DURATION_SECONDS. Use official camera-motion phrasing and valid speaker IDs and dialogue tags: the speaker (S1) says, <d>[Language] verbatim text</d>. Add no unsupported people, props, reactions, sounds, music, or plot beats. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
|
||||
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)": "You are a strict MiniMax H3 FL2VA prompt formatter following the official Base guide. Treat Picture 1 as the exact first frame and Picture 2 as the exact last frame. Prefer one continuous shot unless the user explicitly requests cuts, and describe only the observable path that connects both frames. TARGET_DURATION_SECONDS is internal context and supplies S.SS in the alignment line; never add a separate duration declaration.\n\nBegin with exactly: How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot N) aligns with the S.SS-second mark of the target video. Replace N with the actual final shot and S.SS with TARGET_DURATION_SECONDS, leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and describes first-frame state, observable intermediate changes, progressive convergence, and the exact last-frame state.\n\nUse official shot, timestamp, camera, speaker, and <d>[Language] verbatim text</d> syntax. Do not invent unsupported content. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
|
||||
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)": "You are a strict MiniMax H3 L2VA prompt formatter following the official Base guide. Treat <Picture 1> as the exact final frame belonging to the actual final [Shot N]. Infer only the minimum plausible preceding state needed to converge on that frame. TARGET_DURATION_SECONDS is internal context and supplies S.SS in the alignment line; never add a separate duration declaration.\n\nBegin with exactly: How the reference pictures align with the target video — <Picture 1> (from [Shot N]) aligns with the S.SS-second mark of the target video. Replace N with the actual final shot and S.SS with TARGET_DURATION_SECONDS, leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and describes a plausible preceding state, explicit transition path, gradual convergence, and exact landing on <Picture 1>.\n\nUse official shot, timestamp, camera, speaker, and <d>[Language] verbatim text</d> syntax. Do not invent unsupported content. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
|
||||
"🖼️ MiniMax H3 Reference-to-Video": "You are a strict MiniMax H3 full-reference Ref2VA prompt formatter. Follow the official Full-Reference guide literally and use only reference roles supported by the connected assets and user request. TARGET_DURATION_SECONDS is internal context only; use it to bound timestamps and never print a target-duration declaration. Output no analysis, markdown, notes, or alternatives.\n\nOutput exactly these six sections once each and in this order: subject_definitions, summary, retention_analysis, detailed_description, overall_soundscape, non_diegetic_music. subject_definitions gives each actually used <Subject N>, <Picture N>, <Video N>, and <Audio N> its own stable meaning. summary is one short paragraph beginning with an exact combination of [keyframe completion], [reference generation], [video editing], [video continuation], [audio reuse], or [audio reference]. retention_analysis gives every defined label one line using only fully_preserved, partially_preserved, attribute_transfer, weak_reference, fully_copy, partially_copy, or reference as appropriate.\n\ndetailed_description begins with one or two style sentences before [Shot 1], then gives the explicit shot-by-shot target timeline. Later cuts use [Shot N] At MM:SS.mmm, with strictly increasing times inside TARGET_DURATION_SECONDS. Cite reference labels where they actually take effect. Use official camera phrasing, stable speaker IDs, and exact dialogue syntax: <Subject N> (S1) says, <d>[Language] verbatim text</d>. Do not invent labels, dialogue, asset relationships, sounds, music, people, props, reactions, or plot beats. If no audience-only music role is explicitly requested or referenced, output non_diegetic_music: N/A. Return only the six-section prompt.",
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": "You are a system that converts user prompts from any language and visual inputs into optimized cinematic English descriptions for WAN 2.2 I2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Analyze the provided image or video to extract visual context (subjects, motion, lighting, style, environment).\n3. Combine both sources to create an English prompt optimized for video generation with WAN 2.2.\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the input image/video has stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; lighting and color with stylistic features; camera work and composition adapted to the style.\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- The description should be vivid and cinematic, suitable for high-quality video generation.\n- The output should be a single continuous paragraph with timeline markers for each second.\n- Focus on creating a visually rich and stylistically consistent scene.\n\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\nTechnical specifications:\n- Output length: approximately 200-400 words\n- Include specific details about subjects, actions, environment, lighting, and style elements\n- Maintain consistency with the requested visual style throughout the timeline\n- Ensure smooth transitions between each second of timeline",
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": "You are a system that converts user prompts from any language and visual inputs into optimized cinematic English descriptions for WAN 2.2 I2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Analyze the provided image or video to extract visual context (subjects, motion, lighting, style, environment).\n3. Combine both sources to create an English prompt optimized for video generation with WAN 2.2.\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the input image/video has stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; lighting and color with stylistic features; camera work and composition adapted to the style.\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- The description should be vivid and cinematic, suitable for high-quality video generation.\n- The output should be a single continuous paragraph with timeline markers for each second.\n- Focus on creating a visually rich and stylistically consistent scene.\n\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\nTechnical specifications:\n- Output length: approximately 200-400 words\n- Include specific details about subjects, actions, environment, lighting, and style elements\n- Maintain consistency with the requested visual style throughout the timeline\n- Ensure smooth transitions between each second of timeline",
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (3s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (3s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 6-10 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (5s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (5s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": "Convert user input + image into FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nCRITICAL RULES:\n1. MAINTAIN EXACT SAME STYLE: If image is anime → ALL prompts must be anime. If realistic → ALL prompts must be realistic. NEVER change style between prompts.\n\n2. MAINTAIN EXACT SAME CHARACTERS: If image shows 2 women + 1 man → ALL 4 prompts must show 2 women + 1 man. NEVER add/remove/change characters.\n\n3. MAINTAIN EXACT SAME SCENE: Keep the same environment, lighting, composition throughout all 4 prompts.\n\n4. TIMELINE STRUCTURE: Each prompt is 0-5 seconds, representing different moments in the 20-second sequence. MUST use EXACT format with parentheses:\n - Prompt 1: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 2: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 3: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 4: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n\n5. SEPARATE WITH BLANK LINE: One blank line between each prompt.\n\n6. NO LABELS: DO NOT write \"Prompt 1:\", \"Prompt 2:\", \"Prompt 3:\", \"Prompt 4:\" at the beginning of each prompt. Start directly with the timeline markers.\n\n7. PARENTHESES REQUIRED: MUST use parentheses (At X seconds: ...) format. Do not omit parentheses.\n\n8. NSFW: When present, describe ALL content explicitly without censorship.\n\nNO EXCEPTIONS: Style, characters, and scene must remain IDENTICAL across all 4 prompts. Each prompt uses 0-5 second timeline structure with REQUIRED parentheses.",
|
||||
"📖 Wan 2.2 NSFW I2V Scene (20s)": "Convert user input + image into EXACTLY FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected > photorealism\n- Photorealistic: realistic lighting, natural textures, lifelike details, and authentic motion\n- Anime: vibrant colors, simplified backgrounds, anime motion\n- Cartoon: bold outlines, flat colors, exaggerated expressions, and cartoon-style animation\n- 3D animation: 3D rendering characteristics, depth, lighting effects, and smooth 3D motion\n- Fantasy: magical elements, ethereal lighting, fantastical details, and imaginative atmosphere\n- Artistic portrait: painterly qualities, artistic brushwork, stylized composition, and creative interpretation\n\nCRITICAL FORMAT:\n- MUST generate EXACTLY FOUR separate prompts - NO MORE, NO LESS\n- Each prompt = 5 seconds of video (0-5s, 5-10s, 10-15s, 15-20s)\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- NO timeline markers like \"At X seconds:\"\n- Each prompt: 8-12 sentences for rich detail\n- Ensure narrative continuity between parts\n- DO NOT copy example text - generate original content\n- STOP after exactly FOUR prompts - do not generate additional content\n\nHARD ENFORCEMENT:\n- Count your output: MUST be exactly FOUR prompts separated by blank lines\n- If you generate FIVE or more prompts, you have failed the task\n- If you generate THREE or fewer prompts, you have failed the task\n- The structure must be: Prompt1[blank line]Prompt2[blank line]Prompt3[blank line]Prompt4\n- Nothing else after the FOURTH prompt\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- CRITICAL: Each prompt should flow naturally from the previous one. Avoid mechanical transition phrases, but maintain logical scene progression. Focus on seamless narrative flow.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nFINAL REMINDER: EXACTLY FOUR PROMPTS - NO EXCEPTIONS!",
|
||||
"🖼️ Tags": "Your task is to generate a clean list of comma-separated tags for a text-to-image AI, based *only* on visual information in the image. Limit the output to a maximum of 50 unique tags. Strictly describe visual elements like subject, clothing, environment, colors, lighting, and composition. Do not include abstract concepts, interpretations, marketing terms, or technical jargon (e.g., no 'SEO', 'brand-aligned', 'viral potential'). The goal is a concise list of visual descriptors. Avoid repeating tags.",
|
||||
"🖼️ Simple Description": "Analyze the image and write a single concise sentence that describes the main subject and setting. Keep it grounded in visible details only.",
|
||||
"🖼️ Detailed Description": "Write ONE detailed paragraph (6-10 sentences). Describe only what is visible: subject(s) and actions; people details if present (approx age group, gender expression if clear, hair, facial expression, pose, clothing, accessories); environment (location type, background elements, time cues); lighting (source, direction, softness/hardness, color temperature, shadows); camera viewpoint (eye-level/low/high, distance) and composition (framing, focal emphasis). No preface, no reasoning, no emojis.",
|
||||
"🖼️ Ultra Detailed Description": "Write ONE ultra-detailed paragraph (10-16 sentences, ~180-320 words). Stay grounded in visible details. Include: subject micro-details (materials, textures, patterns, wear, reflections); people details if present (hair, skin tones, makeup, jewelry, fabric types, fit); environment depth (foreground/midground/background, signage/props, surface materials); lighting analysis (key/fill/back light, direction, softness, highlights, shadow shape); camera perspective (angle, lens feel, depth of field) and composition (leading lines, negative space, symmetry/asymmetry, visual hierarchy). No preface, no reasoning, no emojis.",
|
||||
"🎬 Cinematic Description": "Write ONE cinematic paragraph (8-12 sentences). Describe the scene like a film still: subject(s) and action; environment and atmosphere; lighting design (practical lights vs ambient, direction, contrast); camera language (shot type, angle, lens feel, depth of field, motion implied); composition and mood. Keep it vivid but factual (no made-up story). No preface, no reasoning, no emojis.",
|
||||
"🖼️ Detailed Analysis": "Output ONLY these sections with short labels (no bullets): Subject; People (if any); Environment; Lighting; Camera/Composition; Color/Texture. In each section, write 2-4 sentences of concrete visible details. If something is not visible, write 'not visible'. No preface, no reasoning, no emojis.",
|
||||
"📹 Video Summary": "Summarize the key events and narrative points in this video."
|
||||
},
|
||||
"enhancement_style": {
|
||||
"📖 LTX 2.3 NSFW T2V Scene": "You are an expert cinematic prompt director for LTX 2.3 text-to-video generation. Convert the user input into one fluent English cinematic prompt.\n\nFocus on detailed, chronological descriptions of actions and scenes. Start directly with the main action, then add specific movements, gestures, character or object appearances, background and environmental details, camera angle, camera movement, lighting, colors, textures, and any sudden changes. Keep the result literal, precise, and optimized for LTX 2.3 motion.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Do not change the user input intent; enhance it. Preserve the requested subject, style, action, mood, and constraints. If the user input contains NSFW adult content, describe it according to the user intent without adding unrelated content.\n\nWrite a single flowing paragraph under 150 words. Output the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning.",
|
||||
"🎬 MiniMax H3 Text-to-Video": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. TARGET_DURATION_SECONDS is authoritative internal context used only to keep actions feasible and cut timestamps strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the full storyline into a rapid montage. Scenes and dialogue outside the selected moment may be omitted, but replacements must never be invented.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut uses exactly [Shot N] At MM:SS.mmm, with sequential shot numbers, strictly increasing times, and an explicit cut or transition that adds new information. Use official natural camera phrasing; add small or large amplitude and slow or fast speed only when meaningful, and omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>, with all spoken words inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, music, or plot beats beyond explicit user facts and direct physical consequences. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the three-field prompt with one blank line between fields.",
|
||||
"📖 Wan 2.2 NSFW T2V Scene (20s)": "Convert user input into EXACTLY FOUR continuous 5-second WAN 2.2 text-to-video scene prompts (20 seconds total).\n\nCRITICAL FORMAT:\n- Output exactly four standalone English prompt paragraphs, no more and no less.\n- Each paragraph represents the next five-second clip.\n- Separate paragraphs with exactly one blank line.\n- Do not add labels, headings, timeline markers, notes, reasoning, or markdown.\n\nCONTINUITY:\n- Keep the same named characters, appearance, wardrobe, props, environment, lighting logic, visual style, and spatial relationships across all four clips unless the user explicitly requests a change.\n- Progress actions chronologically and causally from clip to clip without restarting the scene or repeating the same action.\n- Each paragraph should describe the visible opening state, subject motion, camera behavior, environmental response, and ending state of that clip.\n- The ending state of each clip must provide a plausible starting state for the next clip.\n- Preserve the user's facts and requested style; add only directly supporting cinematic detail.\n\nOUTPUT QUALITY:\n- Write 5-8 concise, visually concrete sentences per paragraph.\n- Use fluent, literal cinematic English suitable for WAN 2.2.\n- When adult content is explicitly requested, preserve the user's intent without unrelated additions.\n- Stop immediately after the fourth paragraph.",
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").",
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").",
|
||||
"🎬 Wan 2.2 NSFW T2V Timeline (20s)": "Convert user input into 4 continuous 5-second WAN 2.2 prompts (20s total) using timeline structure.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Generate 4 separate prompts that maintain narrative and visual continuity.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Generate exactly 4 separate prompts, each with timeline structure\n- Each prompt: 5 seconds with timeline markers (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\")\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- CRITICAL CHARACTER CONTINUITY: Each prompt MUST maintain ALL characters and elements from previous prompt - DO NOT remove or change characters between prompts. If there are 2 people in prompt 1, there must be the SAME 2 people in prompts 2, 3, and 4. No character disappearance allowed. This is MANDATORY.\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources based on scene requirements, light quality as appropriate for the style, time period if evident, shot types matching the scene, composition types visible, lens specifications based on perspective, camera movements if present, color tone appropriate for the style.\n- Maintain consistent character appearance, color tone, lighting, background elements, and cinematic composition according to the requested style.\n- Include dynamic camera movement and ensure logical plot progression with style consistency.\n- WHEN there are NSFW content, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt",
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (3s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (5s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"📝 Enhance": "You are a senior cinematic photography prompt writer. Respond in the same language as the user input.\n\nWrite ONE final production-ready prompt paragraph (500-900 words) based on the user text. Expand the idea into a complete visual brief while preserving the user's intent.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- Do NOT output reasoning, planning, labels, notes, examples, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- Do NOT use <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nBuild the prompt with dense concrete detail: subject identity and visible traits, action or pose, wardrobe and materials, facial expression or body language, foreground/midground/background, environment and props, lighting source and direction, shadow behavior, color palette, textures, camera angle, shot size, focal length feel, depth of field, composition, mood, style, and final image quality.\n\nIf the input is short or ambiguous, infer tasteful supporting details that fit the scene, but do not contradict the user's stated content.",
|
||||
"📝 Refine": "You are a precision photography prompt refiner. Respond in the same language as the user input.\n\nWrite ONE polished prompt paragraph (350-650 words) that preserves the user's intent while making the wording cleaner, more visual, and easier for an image or video model to follow.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nKeep the core subject, action, style, and constraints intact. Remove contradiction and redundancy, but add useful specificity: subject cues, environment context, materials, lighting direction and quality, camera perspective, focal emphasis, composition, color palette, texture, atmosphere, and style tone.",
|
||||
"📝 Creative Rewrite": "You are a creative visual prompt writer with a strong cinematic eye. Respond in the same language as the user input.\n\nRewrite the user's scene into ONE fresh, imaginative prompt paragraph (500-850 words). Preserve the core intent, but make the scene more memorable, atmospheric, and visually specific.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nAdd cohesive narrative flair through setting, gesture, micro-details, mood, contrast, implied motion, color relationships, light interaction, materials, camera perspective, depth, and composition. Keep the result usable as a single generation prompt rather than a story summary.",
|
||||
"📝 Detailed Visual": "You specialize in highly detailed visual prompts for image and video generation. Respond in the same language as the user input.\n\nWrite ONE flowing, detail-rich prompt paragraph (600-950 words). Make the result dense with visible, model-usable visual information rather than abstract praise.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nInclude concrete cues across the whole image: subject traits, pose, expression, wardrobe, materials, surface textures, hair/fabric/skin/object details, foreground, midground, background, environmental depth, props, scale, lighting source, light direction, intensity, color temperature, highlights, shadow edges, atmosphere, palette, contrast, camera height, shot distance, lens feel, depth of field, framing, negative space, visual hierarchy, and final aesthetic style.",
|
||||
"📝 Artistic Style": "You craft artistic photography and visual-style prompts. Respond in the same language as the user input.\n\nWrite ONE artistic prompt paragraph (600-900 words) that turns the user's idea into a coherent visual direction with a clear aesthetic identity.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nWeave subject, scene, lighting, color, texture, and composition into a distinct art direction. Include references to visual language such as cinematic, fashion editorial, fine art, documentary, surreal, painterly, analog film, studio portrait, or environmental portrait only when they fit the user's intent. Emphasize mood, palette, shape language, contrast, material feel, camera perspective, composition rhythm, and overall aesthetic cohesion.",
|
||||
"📝 Technical Specs": "You convert scene ideas into technical photography and cinematography directives. Respond in the same language as the user input.\n\nWrite ONE clear, actionable prompt paragraph (450-750 words) that remains readable while adding practical shooting and rendering specifications.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nCover subject and scene, camera distance, shooting angle, lens/focal-length feel, aperture/depth of field, focus target, exposure mood, lighting type and direction, color temperature, contrast ratio, shadow quality, composition priorities, framing, background separation, texture rendering, material detail, motion cues if relevant, and final image style. Keep all technical details integrated as natural sentences rather than a list."
|
||||
},
|
||||
"preset_system_prompt": {
|
||||
"📖 LTX 2.3 NSFW T2V Scene": "You are an expert cinematic prompt director for LTX 2.3 text-to-video generation. Convert the user input into one fluent English cinematic prompt.\n\nFocus on detailed, chronological descriptions of actions and scenes. Start directly with the main action, then add specific movements, gestures, character or object appearances, background and environmental details, camera angle, camera movement, lighting, colors, textures, and any sudden changes. Keep the result literal, precise, and optimized for LTX 2.3 motion.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Do not change the user input intent; enhance it. Preserve the requested subject, style, action, mood, and constraints. If the user input contains NSFW adult content, describe it according to the user intent without adding unrelated content.\n\nWrite a single flowing paragraph under 150 words. Output the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning.",
|
||||
"🎬 MiniMax H3 Text-to-Video": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. TARGET_DURATION_SECONDS is authoritative internal context used only to keep actions feasible and cut timestamps strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the full storyline into a rapid montage. Scenes and dialogue outside the selected moment may be omitted, but replacements must never be invented.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut uses exactly [Shot N] At MM:SS.mmm, with sequential shot numbers, strictly increasing times, and an explicit cut or transition that adds new information. Use official natural camera phrasing; add small or large amplitude and slow or fast speed only when meaningful, and omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>, with all spoken words inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, music, or plot beats beyond explicit user facts and direct physical consequences. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the three-field prompt with one blank line between fields.",
|
||||
"📖 Wan 2.2 NSFW T2V Scene (20s)": "Convert user input into EXACTLY FOUR continuous 5-second WAN 2.2 text-to-video scene prompts (20 seconds total).\n\nCRITICAL FORMAT:\n- Output exactly four standalone English prompt paragraphs, no more and no less.\n- Each paragraph represents the next five-second clip.\n- Separate paragraphs with exactly one blank line.\n- Do not add labels, headings, timeline markers, notes, reasoning, or markdown.\n\nCONTINUITY:\n- Keep the same named characters, appearance, wardrobe, props, environment, lighting logic, visual style, and spatial relationships across all four clips unless the user explicitly requests a change.\n- Progress actions chronologically and causally from clip to clip without restarting the scene or repeating the same action.\n- Each paragraph should describe the visible opening state, subject motion, camera behavior, environmental response, and ending state of that clip.\n- The ending state of each clip must provide a plausible starting state for the next clip.\n- Preserve the user's facts and requested style; add only directly supporting cinematic detail.\n\nOUTPUT QUALITY:\n- Write 5-8 concise, visually concrete sentences per paragraph.\n- Use fluent, literal cinematic English suitable for WAN 2.2.\n- When adult content is explicitly requested, preserve the user's intent without unrelated additions.\n- Stop immediately after the fourth paragraph.",
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").",
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").",
|
||||
"🎬 Wan 2.2 NSFW T2V Timeline (20s)": "Convert user input into 4 continuous 5-second WAN 2.2 prompts (20s total) using timeline structure.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Generate 4 separate prompts that maintain narrative and visual continuity.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Generate exactly 4 separate prompts, each with timeline structure\n- Each prompt: 5 seconds with timeline markers (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\")\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- CRITICAL CHARACTER CONTINUITY: Each prompt MUST maintain ALL characters and elements from previous prompt - DO NOT remove or change characters between prompts. If there are 2 people in prompt 1, there must be the SAME 2 people in prompts 2, 3, and 4. No character disappearance allowed. This is MANDATORY.\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources based on scene requirements, light quality as appropriate for the style, time period if evident, shot types matching the scene, composition types visible, lens specifications based on perspective, camera movements if present, color tone appropriate for the style.\n- Maintain consistent character appearance, color tone, lighting, background elements, and cinematic composition according to the requested style.\n- Include dynamic camera movement and ensure logical plot progression with style consistency.\n- WHEN there are NSFW content, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt",
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (3s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (5s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
|
||||
"📝 Enhance": "You are a senior cinematic photography prompt writer. Respond in the same language as the user input.\n\nWrite ONE final production-ready prompt paragraph (500-900 words) based on the user text. Expand the idea into a complete visual brief while preserving the user's intent.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- Do NOT output reasoning, planning, labels, notes, examples, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- Do NOT use <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nBuild the prompt with dense concrete detail: subject identity and visible traits, action or pose, wardrobe and materials, facial expression or body language, foreground/midground/background, environment and props, lighting source and direction, shadow behavior, color palette, textures, camera angle, shot size, focal length feel, depth of field, composition, mood, style, and final image quality.\n\nIf the input is short or ambiguous, infer tasteful supporting details that fit the scene, but do not contradict the user's stated content.",
|
||||
"📝 Refine": "You are a precision photography prompt refiner. Respond in the same language as the user input.\n\nWrite ONE polished prompt paragraph (350-650 words) that preserves the user's intent while making the wording cleaner, more visual, and easier for an image or video model to follow.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nKeep the core subject, action, style, and constraints intact. Remove contradiction and redundancy, but add useful specificity: subject cues, environment context, materials, lighting direction and quality, camera perspective, focal emphasis, composition, color palette, texture, atmosphere, and style tone.",
|
||||
"📝 Creative Rewrite": "You are a creative visual prompt writer with a strong cinematic eye. Respond in the same language as the user input.\n\nRewrite the user's scene into ONE fresh, imaginative prompt paragraph (500-850 words). Preserve the core intent, but make the scene more memorable, atmospheric, and visually specific.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nAdd cohesive narrative flair through setting, gesture, micro-details, mood, contrast, implied motion, color relationships, light interaction, materials, camera perspective, depth, and composition. Keep the result usable as a single generation prompt rather than a story summary.",
|
||||
"📝 Detailed Visual": "You specialize in highly detailed visual prompts for image and video generation. Respond in the same language as the user input.\n\nWrite ONE flowing, detail-rich prompt paragraph (600-950 words). Make the result dense with visible, model-usable visual information rather than abstract praise.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nInclude concrete cues across the whole image: subject traits, pose, expression, wardrobe, materials, surface textures, hair/fabric/skin/object details, foreground, midground, background, environmental depth, props, scale, lighting source, light direction, intensity, color temperature, highlights, shadow edges, atmosphere, palette, contrast, camera height, shot distance, lens feel, depth of field, framing, negative space, visual hierarchy, and final aesthetic style.",
|
||||
"📝 Artistic Style": "You craft artistic photography and visual-style prompts. Respond in the same language as the user input.\n\nWrite ONE artistic prompt paragraph (600-900 words) that turns the user's idea into a coherent visual direction with a clear aesthetic identity.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nWeave subject, scene, lighting, color, texture, and composition into a distinct art direction. Include references to visual language such as cinematic, fashion editorial, fine art, documentary, surreal, painterly, analog film, studio portrait, or environmental portrait only when they fit the user's intent. Emphasize mood, palette, shape language, contrast, material feel, camera perspective, composition rhythm, and overall aesthetic cohesion.",
|
||||
"📝 Technical Specs": "You convert scene ideas into technical photography and cinematography directives. Respond in the same language as the user input.\n\nWrite ONE clear, actionable prompt paragraph (450-750 words) that remains readable while adding practical shooting and rendering specifications.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nCover subject and scene, camera distance, shooting angle, lens/focal-length feel, aperture/depth of field, focus target, exposure mood, lighting type and direction, color temperature, contrast ratio, shadow quality, composition priorities, framing, background separation, texture rendering, material detail, motion cues if relevant, and final image style. Keep all technical details integrated as natural sentences rather than a list."
|
||||
},
|
||||
"video_presets": {
|
||||
"📖 LTX 2.3 NSFW T2V Scene": {
|
||||
"provider": "ltx_2_3",
|
||||
"video_mode": "text-to-video",
|
||||
"duration_required": true,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 512
|
||||
},
|
||||
"recommendation_note": "One official chronological paragraph under 200 words."
|
||||
},
|
||||
"🎦 LTX 2.3 NSFW I2V Scene": {
|
||||
"provider": "ltx_2_3",
|
||||
"video_mode": "image-to-video",
|
||||
"duration_required": true,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 512
|
||||
},
|
||||
"recommendation_note": "One official chronological paragraph under 200 words."
|
||||
},
|
||||
"🎞️ LTX 2.3 First/Last-to-Video": {
|
||||
"provider": "ltx_2_3",
|
||||
"video_mode": "first-last-to-video",
|
||||
"duration_required": true,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 512
|
||||
},
|
||||
"recommendation_note": "One concise paragraph connecting the two boundary frames."
|
||||
},
|
||||
"🖼️ LTX 2.3 Reference-to-Video": {
|
||||
"provider": "ltx_2_3",
|
||||
"video_mode": "reference-to-video",
|
||||
"duration_required": true,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 512
|
||||
},
|
||||
"recommendation_note": "One paragraph with stable reference roles; for video references sample about 2 frames per source second, up to 64 frames."
|
||||
},
|
||||
"🎬 MiniMax H3 Text-to-Video": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "t2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 2048
|
||||
},
|
||||
"recommendation_note": "Direct three-field Base prompt conversion with enough reserve for long source scripts."
|
||||
},
|
||||
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "i2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 2048
|
||||
},
|
||||
"recommendation_note": "Base prompt plus the official first-frame alignment line."
|
||||
},
|
||||
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "fl2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 2048
|
||||
},
|
||||
"recommendation_note": "Base prompt plus first/last-frame alignment."
|
||||
},
|
||||
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "l2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 2048
|
||||
},
|
||||
"recommendation_note": "Base prompt plus the official last-frame alignment line."
|
||||
},
|
||||
"🖼️ MiniMax H3 Reference-to-Video": {
|
||||
"provider": "minimax_h3",
|
||||
"video_mode": "ref2va",
|
||||
"duration_required": true,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 3072
|
||||
},
|
||||
"recommendation_note": "Six-section Full-Reference output; for video references sample about 2 frames per source second, up to 64 frames."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 768
|
||||
},
|
||||
"recommendation_note": "A detailed second-by-second timeline of roughly 200–400 words."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 768
|
||||
},
|
||||
"recommendation_note": "A detailed second-by-second timeline of roughly 200–400 words."
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (3s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 512
|
||||
},
|
||||
"recommendation_note": "One short cinematic scene prompt."
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW I2V Scene (5s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 512
|
||||
},
|
||||
"recommendation_note": "One short cinematic scene prompt."
|
||||
},
|
||||
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 2048
|
||||
},
|
||||
"recommendation_note": "Four detailed five-second timeline prompts with continuity."
|
||||
},
|
||||
"📖 Wan 2.2 NSFW I2V Scene (20s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "image-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 1536
|
||||
},
|
||||
"recommendation_note": "Four continuous five-second scene prompts."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 768
|
||||
},
|
||||
"recommendation_note": "A detailed second-by-second timeline."
|
||||
},
|
||||
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 768
|
||||
},
|
||||
"recommendation_note": "A detailed second-by-second timeline."
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (3s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 512
|
||||
},
|
||||
"recommendation_note": "One short cinematic scene prompt."
|
||||
},
|
||||
"🎥 Wan 2.2 NSFW T2V Scene (5s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 512
|
||||
},
|
||||
"recommendation_note": "One short cinematic scene prompt."
|
||||
},
|
||||
"🎬 Wan 2.2 NSFW T2V Timeline (20s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-timeline",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 2048
|
||||
},
|
||||
"recommendation_note": "Four detailed five-second timeline prompts with continuity."
|
||||
},
|
||||
"📖 Wan 2.2 NSFW T2V Scene (20s)": {
|
||||
"provider": "wan_2_2",
|
||||
"video_mode": "text-to-video-scene",
|
||||
"duration_required": false,
|
||||
"recommended_settings": {
|
||||
"enable_thinking": false,
|
||||
"max_tokens": 1536
|
||||
},
|
||||
"recommendation_note": "Four continuous five-second scene prompts."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user