Files
goodguy1963-ComfyUI-Thinkin…/thinkingllm_core/decision_api.py
T
Marcel Hofer b87d17e34d feat: add native Decision API backends (v3.0.2)
Add native OpenAI Decisions and OpenRouter Decisions API routing to the image and text Decision Router nodes while keeping local in-process llama.cpp inference as the default.

Discover OpenRouter decision models by input modality and decision output capability, then enforce server profile image policy and optional model allowlists both in the UI model catalog and before inference. Keep credentials and endpoints in server-side profiles; apply profile input, image, and timeout limits; never fall back to chat-generated JSON.

Preserve saved workflow widget order, release any retained local model when switching to API mode, and normalize typed route choices, probabilities, and confidence into decision_json. Add API setup documentation and adapter/router/security regression coverage.

Validation: 240 tests passed across six test modules run individually in bundled ComfyUI Python 3.12; Python and JavaScript syntax checks, API catalog validation, version metadata checks, and git diff --check passed. Provider inference is covered with mocked responses; no authenticated live inference was run.
2026-10-29 17:08:23 +01:00

194 lines
9.4 KiB
Python

"""Native OpenAI Decisions and OpenRouter Decisions API adapters."""
from __future__ import annotations
import json
import math
import time
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Mapping
OPENAI_DECISION_MODEL = "gpt-6-luna"
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
MODEL_CACHE_TTL_SECONDS = 3600
_MODEL_CACHE: dict[str, tuple[float, list[dict]]] = {}
def _request_json(url: str, *, headers: dict[str, str], payload=None, timeout: int = 60, opener=None):
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
request_headers = {"Accept": "application/json", **headers}
if data is not None:
request_headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=request_headers, method="GET" if data is None else "POST")
try:
response = (opener or urllib.request.build_opener()).open(req, timeout=timeout)
with response:
raw = response.read()
except urllib.error.HTTPError as exc:
# Do not include provider response text: it can echo user content or media.
raise RuntimeError(f"Decision API returned HTTP {exc.code}.") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Could not reach the Decision API ({type(exc.reason).__name__}).") from exc
try:
result = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("Decision API returned invalid JSON.") from exc
if not isinstance(result, dict):
raise RuntimeError("Decision API returned an unexpected response shape.")
if result.get("error"):
raise RuntimeError("Decision API reported an inference error.")
return result
def _route_criteria(routes):
return {str(route["name"]): str(route.get("description") or "") or None for route in routes}
def build_decision_request(protocol: str, model: str, input_kind: str, item, image_data_url: str | None, system_prompt: str, routes):
criteria = _route_criteria(routes)
instruction = str(system_prompt or "").strip() or "Choose the single route that best matches the input."
if protocol == "openai_decisions":
content = [{"type": "input_text", "text": str(item) if input_kind == "text" else "Classify the supplied image."}]
if image_data_url:
content.append({"type": "input_image", "image_url": image_data_url})
return {
"model": model,
"input": [{"role": "user", "content": content}],
"questions": [{
"type": "choice",
"name": "route",
"instructions": instruction,
"choices": [{"value": name, "description": description or ""} for name, description in criteria.items()],
}],
}
if protocol == "openrouter_decisions":
state = str(item) if input_kind == "text" else []
if input_kind == "image":
state = [{"type": "image_url", "image_url": {"url": image_data_url}}]
return {
"model": model,
"state": state,
"questions": {
"route": {"type": "choice", "instructions": instruction, "criteria": criteria},
},
}
raise ValueError(f"Unsupported Decision API protocol: {protocol!r}.")
def _probability_map(raw, route_names):
if isinstance(raw, Mapping):
values = {str(key): value for key, value in raw.items()}
elif isinstance(raw, list):
values = {}
for item in raw:
if not isinstance(item, dict) or "value" not in item:
raise RuntimeError("Decision API returned malformed route probabilities.")
value = str(item["value"])
if value in values:
raise RuntimeError("Decision API returned duplicate route probabilities.")
values[value] = item.get("probability")
else:
raise RuntimeError("Decision API returned no route probabilities.")
if set(values) != set(route_names):
raise RuntimeError("Decision API probabilities do not match the configured route names.")
normalized = {}
for name in route_names:
value = values[name]
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or not 0 <= float(value) <= 1:
raise RuntimeError("Decision API returned an invalid route probability.")
normalized[name] = float(value)
return normalized
def normalize_decision_response(protocol: str, response: dict, route_names: list[str]):
if protocol == "openai_decisions":
answers = response.get("answers")
answer = next((item for item in answers if isinstance(item, dict) and item.get("name") == "route"), None) if isinstance(answers, list) else None
if not isinstance(answer, dict) or answer.get("type") != "choice":
raise RuntimeError("OpenAI Decisions API did not return a route choice.")
choice = answer.get("choice")
probability_map = _probability_map(answer.get("probabilities"), route_names)
confidence = answer.get("confidence")
elif protocol == "openrouter_decisions":
answers = response.get("answers")
answer = answers.get("route") if isinstance(answers, dict) else None
if not isinstance(answer, dict) or answer.get("type") != "choice":
raise RuntimeError("OpenRouter Decisions API did not return a route choice.")
choice = answer.get("choice")
probability_map = _probability_map(answer.get("probabilities"), route_names)
confidence = answer.get("confidence")
else:
raise ValueError(f"Unsupported Decision API protocol: {protocol!r}.")
if not isinstance(choice, str) or choice not in route_names:
raise RuntimeError("Decision API selected an unknown route.")
if confidence is not None and (isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not math.isfinite(float(confidence)) or not 0 <= float(confidence) <= 1):
raise RuntimeError("Decision API returned an invalid confidence value.")
return choice, probability_map, None if confidence is None else float(confidence)
def request_decision(profile: dict, model: str, input_kind: str, item, image_data_url: str | None, system_prompt: str, routes, *, api_key: str, opener=None):
protocol = profile.get("decision_protocol")
payload = build_decision_request(protocol, model, input_kind, item, image_data_url, system_prompt, routes)
response = _request_json(
profile["decision_endpoint"],
headers={"Authorization": f"Bearer {api_key}"} if api_key else {},
payload=payload,
timeout=int(profile.get("max_timeout_seconds", 300)),
opener=opener,
)
return normalize_decision_response(protocol, response, [str(route["name"]) for route in routes])
def openrouter_decision_models(input_kind: str, *, timeout: int = 20, now: float | None = None, opener=None):
"""Return OpenRouter model ids with decision output and requested input capability."""
modality = "image" if input_kind == "image" else "text"
now = time.monotonic() if now is None else now
cached = _MODEL_CACHE.get(modality)
if cached and now - cached[0] < MODEL_CACHE_TTL_SECONDS:
return [dict(model) for model in cached[1]]
query = urllib.parse.urlencode({"output_modalities": "decisions", "input_modalities": modality})
try:
data = _request_json(f"{OPENROUTER_MODELS_URL}?{query}", headers={}, timeout=timeout, opener=opener)
items = data.get("data")
if not isinstance(items, list):
raise RuntimeError("OpenRouter model catalog returned an unexpected response shape.")
models = []
for item in items:
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
continue
architecture = item.get("architecture")
modalities = architecture.get("input_modalities") if isinstance(architecture, dict) else None
output_modalities = architecture.get("output_modalities") if isinstance(architecture, dict) else None
if not isinstance(modalities, list) or modality not in modalities:
continue
if not isinstance(output_modalities, list) or "decisions" not in output_modalities:
continue
models.append({"id": item["id"], "label": item.get("name") or item["id"], "vision": "image" in modalities})
if not models:
raise RuntimeError("OpenRouter returned no decision models for the requested input type.")
_MODEL_CACHE[modality] = (now, models)
return [dict(model) for model in models]
except RuntimeError:
if cached:
return [dict(model) for model in cached[1]]
raise
def decision_models_for_profile(profile: dict, input_kind: str):
if input_kind == "image" and not profile.get("allow_images", False):
return []
protocol = profile.get("decision_protocol")
if protocol == "openai_decisions":
models = [{"id": OPENAI_DECISION_MODEL, "label": "GPT-6 Luna Decisions", "vision": True}]
elif protocol == "openrouter_decisions":
models = openrouter_decision_models(input_kind)
else:
return []
allowed = profile.get("allowed_models")
if allowed is not None:
models = [model for model in models if model["id"] in allowed]
return models