model updates

This commit is contained in:
Dag Thomas Olsen
2026-08-16 21:38:22 +02:00
parent 802683d30a
commit 5ec1899c14
4 changed files with 304 additions and 43 deletions
+17 -2
View File
@@ -534,7 +534,17 @@ stage 2: 1728x960 (1.58 MP)
Both nodes take a **short idea, an image, or both** and expand it into a complete, spec-compliant MiniMax-H3 video prompt. The official MiniMax writing guides ship verbatim in `data/h3/` and are used as the system prompt, so the model follows the real spec rather than a paraphrase — edit those files to tune behaviour globally.
Any provider works: `auto-detect` picks the first of Claude → GPT → Gemini → Grok → Groq that has an API key set. When an image is connected it is sent as vision input, so the model describes the frame itself instead of you writing the description.
Any provider works — cloud or local. `auto-detect` picks the first of Claude → GPT → Gemini → Grok → Groq that has an API key set, and falls back to a running local server when none do. When an image is connected it is sent as vision input, so the model describes the frame itself instead of you writing the description.
**Local models.** Any OpenAI-compatible server works: Ollama, LM Studio, vLLM, llama.cpp server, LocalAI, TabbyAPI, text-generation-webui. Whatever is running when the ComfyUI page loads is listed at the bottom of the `model` dropdown as `ollama:…`, `lmstudio:…` or `local:…` — start a server or pull a new model, refresh the page, and it appears (no ComfyUI restart). Use a vision-capable model, e.g. `ollama:qwen3-vl:8b`, if you want to connect images.
| Prefix | Default URL | Env override |
|--------|-------------|--------------|
| `ollama:` | `http://localhost:11434/v1` | `OLLAMA_BASE_URL`, `OLLAMA_HOST` |
| `lmstudio:` | `http://localhost:1234/v1` | `LMSTUDIO_BASE_URL` |
| `local:` | `http://localhost:8000/v1` | `LOCAL_LLM_BASE_URL` |
The optional `local_base_url` input points a single node somewhere else (a LAN box, a different port) — `192.168.1.10:11434` is enough, the scheme and `/v1` are filled in. The optional `model_override` input takes an exact `provider:model` string and wins over the dropdown, which is how you reach a model the dropdown has not discovered, e.g. `ollama:qwen3:8b`. Set `APNEXT_LOCAL_LLM_DISCOVERY=0` to skip the local probe entirely.
#### APNext H3 Prompt Writer
**Display Name:** `APNext H3 Prompt Writer`
@@ -554,9 +564,11 @@ Writes the base format — `integrated_multimodal_description`, `overall_soundsc
| `dialogue_language` | Language tag written inside `<d>[...]</d>` |
| `include_on_screen_text` | Whether readable signs/banners/subtitles appear |
| `include_soundscape` / `include_non_diegetic_music` | Off writes `N/A` into that field |
| `model`, `temperature`, `seed` | Provider selection and sampling |
| `model`, `temperature`, `seed` | Provider selection and sampling — cloud models plus any local server that answered |
| `image` *(optional)* | Reference frame(s), sent as vision input |
| `extra_instructions` *(optional)* | Free-form extra direction |
| `model_override` *(optional)* | Exact `provider:model` string, beats the dropdown — e.g. `ollama:qwen3:8b` |
| `local_base_url` *(optional)* | Where the local server lives, e.g. `192.168.1.10:11434`. Empty = the default for the prefix |
**Returns:** `(h3_prompt, integrated_multimodal_description, overall_soundscape, non_diegetic_music, model_used)` — the full prompt plus each field split out for separate wiring.
@@ -952,9 +964,12 @@ Two constraints worth knowing about:
| Groq | ✅ | ✅ | ❌ | ❌ |
| QwenVL | ✅ | ✅ | ✅ | ✅ |
| Ollama | ✅ | ✅ | ❌ | ✅ |
| LM Studio / vLLM / llama.cpp | ✅ | ✅ | ❌ | ✅ |
| MiniCPM | ✅ | ✅ | ✅ | ✅ |
| Phi-3.5 | ✅ | ✅ | ❌ | ✅ |
Ollama and the other OpenAI-compatible servers are selectable directly in the H3 prompt writers via the `ollama:` / `lmstudio:` / `local:` prefixes — see [MiniMax-H3 Prompt Nodes](#-minimax-h3-prompt-nodes). Vision depends on the loaded model being multimodal.
---
## 📝 License
+26 -2
View File
@@ -100,7 +100,12 @@ class H3BasePromptWriter:
}),
"model": (list_all_models(), {
"default": AUTO_DETECT,
"tooltip": "Which LLM writes the prompt. auto-detect picks the first provider with an API key set.",
"tooltip": (
"Which LLM writes the prompt. auto-detect picks the first provider with an "
"API key set, falling back to a running local server. Models found on local "
"servers (ollama:, lmstudio:, local:) are listed at the bottom - start the "
"server and refresh the ComfyUI page to pick up new ones."
),
}),
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05}),
"seed": ("INT", {"default": -1, "min": -1, "max": 0xffffffffffffffff}),
@@ -114,6 +119,22 @@ class H3BasePromptWriter:
"default": "",
"tooltip": "Free-form extra direction appended to the request.",
}),
"model_override": ("STRING", {
"default": "",
"tooltip": (
"Exact provider:model string, used instead of the dropdown when filled in. "
"Handy for a local model the dropdown has not discovered, e.g. "
"'ollama:qwen3:8b', 'lmstudio:qwen/qwen3-8b' or 'local:my-model'."
),
}),
"local_base_url": ("STRING", {
"default": "",
"tooltip": (
"Where to reach the local server, e.g. 'http://192.168.1.10:11434'. Empty "
"uses the default for the chosen prefix: ollama 11434, lmstudio 1234, "
"local 8000. Ignored by the cloud providers."
),
}),
},
}
@@ -287,6 +308,8 @@ class H3BasePromptWriter:
seed,
image=None,
extra_instructions="",
model_override="",
local_base_url="",
):
try:
if not idea.strip() and image is None:
@@ -325,13 +348,14 @@ class H3BasePromptWriter:
)
text, resolved_model = call_llm(
model,
model_override.strip() or model,
user_prompt,
system_prompt=self._build_system_prompt(),
images=images,
temperature=temperature,
seed=current_seed,
max_tokens=4000,
base_url=local_base_url.strip() or None,
)
prompt = strip_code_fence(text)
+29 -2
View File
@@ -104,7 +104,15 @@ class H3RefPromptWriter:
"include_on_screen_text": ("BOOLEAN", {"default": False}),
"include_soundscape": ("BOOLEAN", {"default": True}),
"include_non_diegetic_music": ("BOOLEAN", {"default": True}),
"model": (list_all_models(), {"default": AUTO_DETECT}),
"model": (list_all_models(), {
"default": AUTO_DETECT,
"tooltip": (
"Which LLM writes the rewrite. auto-detect picks the first provider with an "
"API key set, falling back to a running local server. Models found on local "
"servers (ollama:, lmstudio:, local:) are listed at the bottom - start the "
"server and refresh the ComfyUI page to pick up new ones."
),
}),
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05}),
"seed": ("INT", {"default": -1, "min": -1, "max": 0xffffffffffffffff}),
},
@@ -119,6 +127,22 @@ class H3RefPromptWriter:
"tooltip": "Optional per-reference notes, one per line, e.g. 'Image 1: the woman, keep her cardigan'. Also use this to describe video or audio references you cannot attach.",
}),
"extra_instructions": ("STRING", {"multiline": True, "default": ""}),
"model_override": ("STRING", {
"default": "",
"tooltip": (
"Exact provider:model string, used instead of the dropdown when filled in. "
"Handy for a local model the dropdown has not discovered, e.g. "
"'ollama:qwen3:8b', 'lmstudio:qwen/qwen3-8b' or 'local:my-model'."
),
}),
"local_base_url": ("STRING", {
"default": "",
"tooltip": (
"Where to reach the local server, e.g. 'http://192.168.1.10:11434'. Empty "
"uses the default for the chosen prefix: ollama 11434, lmstudio 1234, "
"local 8000. Ignored by the cloud providers."
),
}),
},
}
@@ -328,6 +352,8 @@ class H3RefPromptWriter:
image_4=None,
reference_notes="",
extra_instructions="",
model_override="",
local_base_url="",
):
try:
images = []
@@ -371,13 +397,14 @@ class H3RefPromptWriter:
)
text, resolved_model = call_llm(
model,
model_override.strip() or model,
user_prompt,
system_prompt=self._build_system_prompt(),
images=images or None,
temperature=temperature,
seed=current_seed,
max_tokens=6000,
base_url=local_base_url.strip() or None,
)
prompt = strip_code_fence(text)
+232 -37
View File
@@ -1,12 +1,18 @@
# Shared multi-provider LLM router
#
# One entry point (`call_llm`) that talks to GPT, Gemini, Claude, Grok and Groq
# with an optional system prompt and optional images. Providers are selected by
# a "provider:model" string, the same convention the universal nodes already use.
# One entry point (`call_llm`) that talks to GPT, Gemini, Claude, Grok and Groq,
# plus any local OpenAI-compatible server (Ollama, LM Studio, vLLM, llama.cpp,
# LocalAI, ...), with an optional system prompt and optional images. Providers
# are selected by a "provider:model" string, the same convention the universal
# nodes already use.
import base64
import io
import os
import socket
import time
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urlsplit
from .constants import (
gpt_models,
@@ -47,7 +53,19 @@ _OPENAI_COMPATIBLE = {
"groq": (("GROQ_API_KEY",), "https://api.groq.com/openai/v1"),
}
# Local OpenAI-compatible servers: provider prefix -> (env var names, default base url).
# No API key is needed; the URL comes from the node, then the environment, then
# the default port each tool ships with. "local" is the catch-all for anything
# else that speaks the OpenAI API - vLLM, llama.cpp server, LocalAI, TabbyAPI,
# text-generation-webui, or a remote box on the LAN.
_LOCAL_PROVIDERS = {
"ollama": (("OLLAMA_BASE_URL", "OLLAMA_HOST"), "http://localhost:11434/v1"),
"lmstudio": (("LMSTUDIO_BASE_URL", "LM_STUDIO_BASE_URL"), "http://localhost:1234/v1"),
"local": (("LOCAL_LLM_BASE_URL", "LOCAL_BASE_URL"), "http://localhost:8000/v1"),
}
# Preference order used by auto-detect, with the fallback model for each.
# A running local server is the last resort, after every cloud key.
_AUTO_DETECT_ORDER = (
(("ANTHROPIC_API_KEY", "CLAUDE_API_KEY"), "claude:claude-sonnet-5"),
(("OPENAI_API_KEY",), "gpt:gpt-5.6"),
@@ -62,18 +80,6 @@ AUTO_DETECT = "auto-detect"
_client_cache = {}
def list_all_models():
"""Every selectable model string, auto-detect first."""
return (
[AUTO_DETECT]
+ [f"claude:{m}" for m in claude_models]
+ [f"gpt:{m}" for m in gpt_models]
+ [f"gemini:{m}" for m in gemini_models]
+ [f"grok:{m}" for m in grok_models]
+ [f"groq:{m}" for m in groq_models]
)
def _first_env(names):
for name in names:
value = os.environ.get(name)
@@ -82,15 +88,163 @@ def _first_env(names):
return None
# ----------------------------------------------------------------------
# Local servers
# ----------------------------------------------------------------------
# A dead port does not always refuse the connection quickly - on Windows it is
# routinely swallowed and left to time out - so discovery TCP-probes first with a
# short budget and only speaks HTTP to a port that is actually open.
_LOCAL_PROBE_TIMEOUT = float(os.environ.get("APNEXT_LOCAL_LLM_PROBE_TIMEOUT", "0.5"))
_LOCAL_TIMEOUT = float(os.environ.get("APNEXT_LOCAL_LLM_TIMEOUT", "3.0"))
# Set APNEXT_LOCAL_LLM_DISCOVERY=0 to stop probing entirely. Local models can
# still be used, they just have to be typed in rather than picked from the list.
_LOCAL_DISCOVERY_ENABLED = os.environ.get("APNEXT_LOCAL_LLM_DISCOVERY", "1").strip().lower() not in (
"0",
"false",
"no",
"off",
)
# ComfyUI rebuilds every dropdown each time the browser asks for /object_info,
# so the discovered list is cached briefly rather than re-probed per call.
_LOCAL_CACHE_TTL = 60.0
_local_models_cache = {"stamp": None, "models": []}
def _normalise_base_url(url):
"""
Accept the shapes people actually paste: `localhost:11434`, a bare host with
no path, or a full `.../v1` endpoint. A URL that already has a path is left
alone, since some servers mount the OpenAI API somewhere other than /v1.
"""
url = (url or "").strip().rstrip("/")
if not url:
return url
if "://" not in url:
url = f"http://{url}"
if urlsplit(url).path in ("", "/"):
url = f"{url}/v1"
return url
def resolve_base_url(provider, override=None):
"""Base URL for a local provider: node override, then env var, then default."""
env_names, default_url = _LOCAL_PROVIDERS[provider]
return _normalise_base_url(override or _first_env(env_names) or default_url)
def _get_json(url, timeout):
"""GET a small JSON document, or None if anything at all goes wrong."""
try:
if httpx is not None:
response = httpx.get(url, timeout=timeout)
else:
import requests
response = requests.get(url, timeout=timeout)
if response.status_code != 200:
return None
return response.json()
except Exception:
return None
def _port_is_open(url, timeout=_LOCAL_PROBE_TIMEOUT):
"""Is anything listening at all? Keeps a dead server to one short timeout."""
parts = urlsplit(url)
try:
# .port raises on a malformed authority, so it stays inside the guard.
host, port = parts.hostname, parts.port or (443 if parts.scheme == "https" else 80)
if not host:
return False
with socket.create_connection((host, port), timeout):
return True
except (OSError, ValueError):
return False
def discover_local_models(provider, base_url=None, timeout=_LOCAL_TIMEOUT):
"""Model ids one local server is serving right now. Empty when it is not up."""
url = resolve_base_url(provider, base_url)
if not _port_is_open(url):
return []
payload = _get_json(f"{url}/models", timeout)
if not isinstance(payload, dict):
return []
ids = [
entry.get("id")
for entry in payload.get("data", [])
if isinstance(entry, dict) and entry.get("id")
]
return sorted(ids)
def list_local_models(refresh=False):
"""
Every `provider:model` string for local servers that answer right now.
Start a server (or pull a new model), refresh the ComfyUI page, and the
models show up in the dropdown - no ComfyUI restart needed.
"""
if not _LOCAL_DISCOVERY_ENABLED:
return []
stamp = _local_models_cache["stamp"]
now = time.monotonic()
if not refresh and stamp is not None and now - stamp < _LOCAL_CACHE_TTL:
return list(_local_models_cache["models"])
# Probed concurrently, so three sleeping servers cost one timeout, not three.
providers = list(_LOCAL_PROVIDERS)
with ThreadPoolExecutor(max_workers=len(providers)) as pool:
per_provider = list(pool.map(discover_local_models, providers))
found = [
f"{provider}:{model}"
for provider, models in zip(providers, per_provider)
for model in models
]
_local_models_cache["models"] = found
_local_models_cache["stamp"] = now
return list(found)
def list_all_models():
"""Every selectable model string, auto-detect first and local servers last."""
return (
[AUTO_DETECT]
+ [f"claude:{m}" for m in claude_models]
+ [f"gpt:{m}" for m in gpt_models]
+ [f"gemini:{m}" for m in gemini_models]
+ [f"grok:{m}" for m in grok_models]
+ [f"groq:{m}" for m in groq_models]
+ list_local_models()
)
def auto_detect_model():
"""Pick the best model whose API key is actually present."""
"""Pick the best model whose API key is present, else whatever runs locally."""
for env_names, model in _AUTO_DETECT_ORDER:
if _first_env(env_names):
return model
local = list_local_models()
if local:
return local[0]
raise ValueError(
"No API keys found. Set one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, "
"GEMINI_API_KEY, XAI_API_KEY, GROQ_API_KEY"
"No API keys found and no local server responded. Set one of: "
"ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, XAI_API_KEY, GROQ_API_KEY - "
"or start a local OpenAI-compatible server (Ollama, LM Studio, vLLM, "
"llama.cpp) and pick an `ollama:` / `lmstudio:` / `local:` model."
)
@@ -118,18 +272,26 @@ def _http_client():
return httpx.Client()
def _get_openai_compatible_client(provider):
cached = _client_cache.get(provider)
if cached is not None:
return cached
def _get_openai_compatible_client(provider, base_url_override=None):
if not OPENAI_AVAILABLE:
raise ImportError("openai is not installed. Install it with: pip install 'openai<3'")
env_names, base_url = _OPENAI_COMPATIBLE[provider]
api_key = _first_env(env_names)
if not api_key:
raise ValueError(f"{' or '.join(env_names)} environment variable not set")
if provider in _LOCAL_PROVIDERS:
base_url = resolve_base_url(provider, base_url_override)
# Local servers ignore the key, but the OpenAI client refuses to start without one.
api_key = _first_env(("LOCAL_LLM_API_KEY",)) or "local"
else:
env_names, base_url = _OPENAI_COMPATIBLE[provider]
api_key = _first_env(env_names)
if not api_key:
raise ValueError(f"{' or '.join(env_names)} environment variable not set")
# The URL is part of the key so two nodes pointing at different local servers
# do not share a client.
cache_key = f"{provider}|{base_url or ''}"
cached = _client_cache.get(cache_key)
if cached is not None:
return cached
kwargs = {"api_key": api_key}
http_client = _http_client()
@@ -139,7 +301,7 @@ def _get_openai_compatible_client(provider):
kwargs["base_url"] = base_url
client = OpenAI(**kwargs)
_client_cache[provider] = client
_client_cache[cache_key] = client
return client
@@ -177,8 +339,10 @@ def _encode_png(image):
return buffer.getvalue()
def _call_openai_compatible(provider, model, user_prompt, system_prompt, images, temperature, seed, max_tokens):
client = _get_openai_compatible_client(provider)
def _call_openai_compatible(
provider, model, user_prompt, system_prompt, images, temperature, seed, max_tokens, base_url=None
):
client = _get_openai_compatible_client(provider, base_url)
if images:
content = [{"type": "text", "text": user_prompt}]
@@ -204,11 +368,27 @@ def _call_openai_compatible(provider, model, user_prompt, system_prompt, images,
"max_tokens": max_tokens,
"temperature": temperature,
}
# Groq rejects the seed parameter on several models, so only GPT and Grok get it.
if seed is not None and seed != -1 and provider in ("gpt", "grok"):
# Groq rejects the seed parameter on several models; everything else here
# (GPT, Grok, Ollama, LM Studio, vLLM, llama.cpp) accepts it.
if seed is not None and seed != -1 and provider != "groq":
kwargs["seed"] = seed
response = client.chat.completions.create(**kwargs)
try:
response = client.chat.completions.create(**kwargs)
except Exception as exc:
if provider in _LOCAL_PROVIDERS:
vision_hint = (
" The model also has to be vision-capable to accept the attached image(s)."
if images
else ""
)
raise RuntimeError(
f"Local LLM call to {resolve_base_url(provider, base_url)} failed: {exc}. "
f"Check the server is running and that '{model}' is available on it."
f"{vision_hint}"
) from exc
raise
return (response.choices[0].message.content or "").strip()
@@ -275,19 +455,30 @@ def call_llm(
temperature=1.0,
seed=-1,
max_tokens=4000,
base_url=None,
):
"""
Send a prompt to whichever provider `model_name` selects.
`images` is a list of PIL images; providers that support vision receive them
inline. Raises on failure so callers can decide how to surface the error.
inline. `base_url` overrides where a local provider (`ollama:`, `lmstudio:`,
`local:`) is reached and is ignored for the cloud providers. Raises on
failure so callers can decide how to surface the error.
"""
resolved = resolve_model(model_name)
provider, model = split_model(resolved)
if provider in _OPENAI_COMPATIBLE:
if provider in _OPENAI_COMPATIBLE or provider in _LOCAL_PROVIDERS:
text = _call_openai_compatible(
provider, model, user_prompt, system_prompt, images, temperature, seed, max_tokens
provider,
model,
user_prompt,
system_prompt,
images,
temperature,
seed,
max_tokens,
base_url=base_url,
)
elif provider == "claude":
text = _call_claude(model, user_prompt, system_prompt, images, temperature, max_tokens)
@@ -296,6 +487,10 @@ def call_llm(
raise ImportError("google-genai is not installed. Install it with: pip install google-genai")
text = _call_gemini(model, user_prompt, system_prompt, images, temperature, max_tokens)
else:
raise ValueError(f"Unknown provider '{provider}' in model '{model_name}'")
known = ["claude", "gemini"] + list(_OPENAI_COMPATIBLE) + list(_LOCAL_PROVIDERS)
raise ValueError(
f"Unknown provider '{provider}' in model '{model_name}'. Prefix the model "
f"with one of: {', '.join(known)} - e.g. 'ollama:qwen3:8b'."
)
return text, resolved