Files
EricRollei-Local_LLM_Prompt…/llm_backend.py
T
Eric HissandClaude Fable 5.1 bd2d85da5f V2.0.1: local Qwen3-VL backend - no silent downloads, single model load
- The endpoint / model field is resolved as: an existing model folder, a
  folder name under models/VLM, or an explicit org/name id (local copy if
  present, otherwise - and only then - a download). A URL or any other value
  (a stray "1" from an old workflow asked Hugging Face for the repo "1") is
  ignored with a log line and a local model is auto-detected. With no local
  model the node stops with an error listing what is in models/VLM.
- The model cache is keyed by the resolved folder, so the image pass and the
  text pass share one copy in VRAM instead of loading the same model twice.
  The Advanced video node's image pass uses the same model field as its text
  pass.
- The system prompt is sent as a system turn; <think> blocks are removed
  from text answers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VpCi6nw5UECMdZ7ayYJvN
2026-09-21 13:37:07 -07:00

646 lines
23 KiB
Python

"""
LLM Backend handlers for LM Studio, Ollama, and Qwen3-VL
Handles API communication with local LLM servers and local models
"""
import requests
import json
import base64
from typing import Dict, Optional, List, Any
class LLMBackend:
"""Handles communication with local LLM backends"""
def __init__(self, backend_type: str, endpoint: str, model_name: str, temperature: float = 0.7):
self.backend_type = backend_type.lower()
self.endpoint = endpoint.rstrip('/')
self.temperature = temperature
# Auto-detect model if not provided
if model_name is None:
self.model_name = self._auto_detect_model()
else:
self.model_name = model_name
self._capabilities = self._infer_capabilities()
self._capability_notes: Dict[str, Any] = {}
self._probe_backend_capabilities()
def _auto_detect_model(self) -> Optional[str]:
"""Auto-detect the currently loaded model from LM Studio or Ollama"""
try:
if self.backend_type == "lm_studio":
# Query LM Studio for loaded models
url = f"{self.endpoint}/models"
response = requests.get(url, timeout=5)
response.raise_for_status()
data = response.json()
models = data.get("data", [])
if models and len(models) > 0:
# Get the first model (usually the loaded one)
detected = models[0].get("id") or models[0].get("model")
print(f"[LLM Backend] Auto-detected LM Studio model: {detected}")
return detected
elif self.backend_type == "ollama":
# Query Ollama for loaded models
url = f"{self.endpoint.replace('/v1', '')}/api/tags"
response = requests.get(url, timeout=5)
response.raise_for_status()
data = response.json()
models = data.get("models", [])
if models and len(models) > 0:
# Get the first model
detected = models[0].get("name")
print(f"[LLM Backend] Auto-detected Ollama model: {detected}")
return detected
print(f"[LLM Backend] Could not auto-detect model for {self.backend_type}, will use 'default'")
return "default"
except Exception as e:
print(f"[LLM Backend] Auto-detection failed: {e}, using 'default'")
return "default"
def _infer_capabilities(self) -> Dict[str, bool]:
"""Best-effort capability inference based on backend and model naming."""
capabilities = {
"vision": False
}
name = (self.model_name or "").lower()
if any(tag in name for tag in ["vision", "vl", "mm", "multimodal", "clip", "siglip", "diffusion"]):
capabilities["vision"] = True
# Ollama advertises multimodal support via specific endpoints; currently text-only.
# LM Studio serves OpenAI-compatible models; treat as text unless name hints otherwise.
if self.backend_type == "ollama" and "vision" in name:
capabilities["vision"] = True
return capabilities
def _probe_backend_capabilities(self) -> None:
"""Query backend metadata for more precise capability detection."""
if self.backend_type != "lm_studio":
return
try:
url = f"{self.endpoint}/models"
response = requests.get(url, timeout=6)
response.raise_for_status()
payload = response.json()
except Exception as exc:
self._capability_notes.setdefault("probe_errors", []).append(str(exc))
return
models = payload.get("data")
if not isinstance(models, list):
return
normalized = (self.model_name or "").lower()
candidate: Optional[Dict[str, Any]] = None
fallback: List[Dict[str, Any]] = []
for entry in models:
if not isinstance(entry, dict):
continue
identifier = str(entry.get("id") or entry.get("model") or entry.get("name") or "").lower()
if identifier == normalized:
candidate = entry
break
fallback.append(entry)
if candidate is None and normalized:
for entry in fallback:
identifier = str(entry.get("id") or entry.get("model") or entry.get("name") or "")
if normalized in identifier.lower():
candidate = entry
break
if candidate is None:
return
if self._candidate_supports_vision(candidate):
self._capabilities["vision"] = True
matched_id = candidate.get("id") or candidate.get("model") or candidate.get("name")
if matched_id:
self._capability_notes["vision_probe_match"] = matched_id
self._capability_notes["vision_source"] = "lm_studio_probe"
def _candidate_supports_vision(self, candidate: Dict[str, Any]) -> bool:
"""Inspect LM Studio model metadata for any vision/multimodal signals."""
indicators = {"vision", "image", "multimodal", "multi-modal", "visual", "vl", "mm"}
def check_value(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.lower()
return any(token in lowered for token in indicators)
if isinstance(value, list):
return any(check_value(item) for item in value)
if isinstance(value, dict):
return any(check_value(val) for val in value.values())
return False
direct_flags = [
"vision",
"supportsVision",
"supports_vision",
"supports_images",
"supportsImages",
"has_image_input",
"hasImageInput",
"multimodal"
]
for flag in direct_flags:
if flag in candidate and check_value(candidate.get(flag)):
return True
fields = [
"modalities",
"capabilities",
"abilities",
"tags",
"features",
"metadata",
"io",
"input_modalities",
"output_modalities",
"mode"
]
for field in fields:
if field in candidate and check_value(candidate.get(field)):
return True
return False
def supports_images(self) -> bool:
"""Return True if the backend/model combination is expected to handle image context."""
return bool(self._capabilities.get("vision"))
def caption_image(
self,
image_bytes: bytes,
label: str,
prompt: Optional[str] = None,
max_tokens: int = 320
) -> Dict:
"""Attempt to obtain a detailed caption from the backend for the provided image."""
detail_prompt = prompt or (
"Describe this reference image in exhaustive detail, covering subjects, setting, lighting, colors, mood, and notable elements."
)
log_entry = {
"mode": "vision_caption",
"label": label,
"backend": self.backend_type,
"model": self.model_name,
"prompt": detail_prompt,
"success": False,
"raw_response": None,
"error": None,
"attempted": False
}
if not self.supports_images():
log_entry["error"] = "Model does not support image inputs."
return {
"success": False,
"caption": "",
"error": "Model does not support image inputs.",
"raw_response": "",
"log_entry": log_entry
}
try:
if self.backend_type == "lm_studio":
result = self._caption_lm_studio(image_bytes, detail_prompt, max_tokens)
elif self.backend_type == "ollama":
result = self._caption_ollama(image_bytes, detail_prompt, max_tokens)
else:
raise ValueError(f"Unsupported backend for vision captioning: {self.backend_type}")
log_entry["attempted"] = True
log_entry["raw_response"] = result.get("response")
log_entry["error"] = result.get("error")
log_entry["success"] = result.get("success", False)
if result.get("success") and result.get("response"):
caption_text = result.get("response", "").strip()
return {
"success": True,
"caption": caption_text,
"error": None,
"raw_response": result.get("response"),
"log_entry": log_entry
}
return {
"success": False,
"caption": "",
"error": result.get("error") or "Vision caption failed",
"raw_response": result.get("response", ""),
"log_entry": log_entry
}
except Exception as exc:
log_entry["attempted"] = True
log_entry["error"] = str(exc)
return {
"success": False,
"caption": "",
"error": str(exc),
"raw_response": "",
"log_entry": log_entry
}
def send_prompt(self, system_prompt: str, user_prompt: str, max_tokens: int = 2000) -> Dict:
"""
Send prompt to LLM and get response
Args:
system_prompt: System instructions
user_prompt: User's prompt to expand
max_tokens: Maximum tokens in response
Returns:
Dict with 'success', 'response', and 'error' keys
"""
try:
if self.backend_type == "lm_studio":
return self._call_lm_studio(system_prompt, user_prompt, max_tokens)
elif self.backend_type == "ollama":
return self._call_ollama(system_prompt, user_prompt, max_tokens)
elif self.backend_type == "qwen3_vl":
return self._call_qwen3_vl(system_prompt, user_prompt, max_tokens)
else:
return {
"success": False,
"response": "",
"error": f"Unknown backend type: {self.backend_type}"
}
except Exception as e:
return {
"success": False,
"response": "",
"error": f"LLM Backend Error: {str(e)}"
}
def _call_lm_studio(self, system_prompt: str, user_prompt: str, max_tokens: int) -> Dict:
"""Call LM Studio API (OpenAI-compatible)"""
url = f"{self.endpoint}/chat/completions"
payload = {
"model": self.model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"temperature": self.temperature,
"max_tokens": max_tokens,
"stream": False
}
headers = {
"Content-Type": "application/json"
}
try:
response = requests.post(url, json=payload, headers=headers, timeout=120)
response.raise_for_status()
data = response.json()
# Debug logging
print(f"[LLM Backend] LM Studio response keys: {list(data.keys())}")
# Check if response has expected structure
if 'choices' not in data:
error_msg = data.get('error', {})
if isinstance(error_msg, dict):
error_text = error_msg.get('message', str(error_msg))
else:
error_text = str(error_msg) if error_msg else "Unknown error - response missing 'choices' field"
print(f"[LLM Backend] LM Studio error response: {data}")
return {
"success": False,
"response": "",
"error": f"LM Studio API Error: {error_text}"
}
if not data['choices'] or len(data['choices']) == 0:
return {
"success": False,
"response": "",
"error": "LM Studio returned empty choices array"
}
content = data['choices'][0]['message']['content']
return {
"success": True,
"response": content.strip(),
"error": None
}
except requests.exceptions.Timeout:
return {
"success": False,
"response": "",
"error": "Request timed out. LLM took too long to respond."
}
except requests.exceptions.ConnectionError:
return {
"success": False,
"response": "",
"error": f"Cannot connect to LM Studio at {self.endpoint}. Is it running?"
}
except KeyError as e:
return {
"success": False,
"response": "",
"error": f"LM Studio returned unexpected response structure (missing {str(e)})"
}
except Exception as e:
return {
"success": False,
"response": "",
"error": f"LM Studio Error: {str(e)}"
}
def _caption_lm_studio(self, image_bytes: bytes, prompt: str, max_tokens: int) -> Dict:
"""Call LM Studio for multimodal captioning."""
url = f"{self.endpoint}/chat/completions"
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
payload = {
"model": self.model_name,
"messages": [
{
"role": "system",
"content": [
{
"type": "text",
"text": "You are an expert visual captioner who writes rich, precise descriptions."
}
]
},
{
"role": "user",
"content": [
{"type": "input_text", "text": prompt},
{
"type": "input_image",
"image": {
"b64": image_b64,
"mime_type": "image/png"
}
}
]
}
],
"temperature": self.temperature,
"max_tokens": max_tokens,
"stream": False
}
headers = {
"Content-Type": "application/json"
}
try:
response = requests.post(url, json=payload, headers=headers, timeout=120)
response.raise_for_status()
data = response.json()
# Check if response has expected structure
if 'choices' not in data:
error_msg = data.get('error', {})
if isinstance(error_msg, dict):
error_text = error_msg.get('message', str(error_msg))
else:
error_text = str(error_msg) if error_msg else "Unknown error - response missing 'choices' field"
return {
"success": False,
"response": "",
"error": f"LM Studio vision API error: {error_text}"
}
if not data['choices'] or len(data['choices']) == 0:
return {
"success": False,
"response": "",
"error": "LM Studio returned empty choices array for vision request"
}
content = data['choices'][0]['message']['content']
return {
"success": True,
"response": content.strip(),
"error": None
}
except requests.exceptions.Timeout:
return {
"success": False,
"response": "",
"error": "Vision caption request timed out."
}
except requests.exceptions.ConnectionError:
return {
"success": False,
"response": "",
"error": f"Cannot connect to LM Studio at {self.endpoint}."
}
except KeyError as e:
return {
"success": False,
"response": "",
"error": f"LM Studio vision response missing field: {str(e)}"
}
except Exception as e:
return {
"success": False,
"response": "",
"error": f"LM Studio vision error: {str(e)}"
}
def _call_ollama(self, system_prompt: str, user_prompt: str, max_tokens: int) -> Dict:
"""Call Ollama API"""
url = f"{self.endpoint}/api/generate"
# Combine system and user prompts for Ollama
full_prompt = f"{system_prompt}\n\n{user_prompt}"
payload = {
"model": self.model_name,
"prompt": full_prompt,
"stream": False,
"options": {
"temperature": self.temperature,
"num_predict": max_tokens
}
}
try:
response = requests.post(url, json=payload, timeout=120)
response.raise_for_status()
data = response.json()
content = data.get('response', '')
return {
"success": True,
"response": content.strip(),
"error": None
}
except requests.exceptions.Timeout:
return {
"success": False,
"response": "",
"error": "Request timed out. LLM took too long to respond."
}
except requests.exceptions.ConnectionError:
return {
"success": False,
"response": "",
"error": f"Cannot connect to Ollama at {self.endpoint}. Is it running?"
}
except Exception as e:
return {
"success": False,
"response": "",
"error": f"Ollama Error: {str(e)}"
}
def _caption_ollama(self, image_bytes: bytes, prompt: str, max_tokens: int) -> Dict:
"""Call Ollama for multimodal captioning."""
url = f"{self.endpoint}/api/generate"
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
payload = {
"model": self.model_name,
"prompt": prompt,
"images": [image_b64],
"stream": False,
"options": {
"temperature": self.temperature,
"num_predict": max_tokens
}
}
try:
response = requests.post(url, json=payload, timeout=120)
response.raise_for_status()
data = response.json()
content = data.get('response', '')
return {
"success": True,
"response": content.strip(),
"error": None
}
except requests.exceptions.Timeout:
return {
"success": False,
"response": "",
"error": "Vision caption request timed out."
}
except requests.exceptions.ConnectionError:
return {
"success": False,
"response": "",
"error": f"Cannot connect to Ollama at {self.endpoint}."
}
except Exception as e:
return {
"success": False,
"response": "",
"error": f"Ollama vision error: {str(e)}"
}
def _call_qwen3_vl(self, system_prompt: str, user_prompt: str, max_tokens: int) -> Dict:
"""Call local Qwen3-VL model for text generation (no image)"""
try:
from .qwen3_vl_backend import generate_text_with_qwen3_vl
import folder_paths
from pathlib import Path
# The api_endpoint field doubles as the model field for this backend. The backend resolves it:
# a folder, a folder name under models/VLM, an explicit org/name id; a URL or a stray value
# means auto-detect. Nothing is downloaded unless an org/name id was typed.
model_spec = self.endpoint or None
# Call Qwen3-VL for pure text generation (no image)
result = generate_text_with_qwen3_vl(
prompt=user_prompt,
system_prompt=system_prompt,
model_spec=model_spec,
max_new_tokens=max_tokens,
temperature=self.temperature
)
if result.get("success"):
return {
"success": True,
"response": result.get("response", ""),
"error": None
}
else:
return {
"success": False,
"response": "",
"error": f"Qwen3-VL error: {result.get('error', 'Unknown error')}"
}
except ImportError:
return {
"success": False,
"response": "",
"error": "Qwen3-VL backend not available. Install transformers and torch."
}
except Exception as e:
return {
"success": False,
"response": "",
"error": f"Qwen3-VL Error: {str(e)}"
}
def test_connection(self) -> Dict:
"""Test if LLM backend is accessible"""
try:
if self.backend_type == "lm_studio":
url = f"{self.endpoint}/models"
response = requests.get(url, timeout=5)
response.raise_for_status()
return {"success": True, "message": "LM Studio connected"}
elif self.backend_type == "ollama":
url = f"{self.endpoint}/api/tags"
response = requests.get(url, timeout=5)
response.raise_for_status()
return {"success": True, "message": "Ollama connected"}
elif self.backend_type == "qwen3_vl":
# Test Qwen3-VL by checking if we can import it
try:
from .qwen3_vl_backend import caption_with_qwen3_vl
return {"success": True, "message": "Qwen3-VL backend available (local model)"}
except ImportError as e:
return {"success": False, "message": f"Qwen3-VL dependencies not installed: {str(e)}"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
return {"success": False, "message": f"Unknown backend type: {self.backend_type}"}