feat(openrouter): expose Fusion as a model slug

This commit is contained in:
Teknium 2026-06-14 03:34:41 -07:00
parent 4c367df56f
commit 431e3be47d
No known key found for this signature in database
19 changed files with 810 additions and 6 deletions

View file

@ -4937,6 +4937,16 @@ def _build_call_kwargs(
base_url: Optional[str] = None,
) -> dict:
"""Build kwargs for .chat.completions.create() with model/provider adjustments."""
fusion_overrides = None
try:
from hermes_cli.fusion_presets import fusion_request_overrides_for_model
fusion_overrides = fusion_request_overrides_for_model(provider, model)
except Exception:
fusion_overrides = None
if fusion_overrides:
model = str(fusion_overrides.get("model") or model)
tools = fusion_overrides.get("tools") or tools
kwargs: Dict[str, Any] = {
"model": model,
"messages": messages,
@ -5003,6 +5013,9 @@ def _build_call_kwargs(
_deduped.append(_t)
kwargs["tools"] = _deduped
if fusion_overrides and fusion_overrides.get("tool_choice"):
kwargs["tool_choice"] = fusion_overrides["tool_choice"]
# Provider-specific extra_body
merged_extra = dict(extra_body or {})
if provider == "nous":

View file

@ -736,6 +736,15 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
if _ephemeral_out is not None:
agent._ephemeral_max_output_tokens = None
_request_overrides = agent.request_overrides
try:
from hermes_cli.fusion_presets import fusion_request_overrides_for_model
_fusion_overrides = fusion_request_overrides_for_model(agent.provider, agent.model)
except Exception:
_fusion_overrides = None
if _fusion_overrides:
_request_overrides = {**(agent.request_overrides or {}), **_fusion_overrides}
# Strip image parts for non-vision models that have provider profiles
# (e.g. DeepSeek, Kimi). The legacy path below already does this, but
# registered providers with profiles were bypassing the strip.
@ -751,7 +760,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
ephemeral_max_output_tokens=_ephemeral_out,
max_tokens_param_fn=agent._max_tokens_param,
reasoning_config=agent.reasoning_config,
request_overrides=agent.request_overrides,
request_overrides=_request_overrides,
session_id=getattr(agent, "session_id", None),
provider_profile=_profile,
ollama_num_ctx=agent._ollama_num_ctx,
@ -770,6 +779,15 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
if _ephemeral_out is not None:
agent._ephemeral_max_output_tokens = None
_request_overrides = agent.request_overrides
try:
from hermes_cli.fusion_presets import fusion_request_overrides_for_model
_fusion_overrides = fusion_request_overrides_for_model(agent.provider, agent.model)
except Exception:
_fusion_overrides = None
if _fusion_overrides:
_request_overrides = {**(agent.request_overrides or {}), **_fusion_overrides}
# Strip image parts for non-vision models (no-op when vision-capable).
_msgs_for_chat = agent._prepare_messages_for_non_vision_model(api_messages)
@ -783,7 +801,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
ephemeral_max_output_tokens=_ephemeral_out,
max_tokens_param_fn=agent._max_tokens_param,
reasoning_config=agent.reasoning_config,
request_overrides=agent.request_overrides,
request_overrides=_request_overrides,
session_id=getattr(agent, "session_id", None),
model_lower=(agent.model or "").lower(),
is_openrouter=_is_or,
@ -1416,6 +1434,13 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
summary_kwargs.update(agent._max_tokens_param(agent.max_tokens))
if _lm_reasoning_effort is not None:
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
try:
from hermes_cli.fusion_presets import fusion_request_overrides_for_model
_fusion_summary = fusion_request_overrides_for_model(agent.provider, agent.model)
except Exception:
_fusion_summary = None
if _fusion_summary:
summary_kwargs.update(_fusion_summary)
# Include provider routing preferences
provider_preferences = {}

View file

@ -238,6 +238,8 @@ DEFAULT_CONTEXT_LENGTHS = {
# Arcee
"trinity": 262144,
# OpenRouter
"openrouter/fusion": 128000,
"fusion/": 128000,
"elephant": 262144,
# Hugging Face Inference Providers — model IDs use org/name format
"Qwen/Qwen3.5-397B-A17B": 131072,

View file

@ -1209,6 +1209,15 @@ DEFAULT_CONFIG = {
"min_coding_score": 0.65,
},
# User-defined OpenRouter Fusion presets. Each preset becomes a selectable
# model slug (`fusion/<slug>`) under its configured provider (`openrouter`
# or `nous`). At request time Hermes sends model `openrouter/fusion` with
# an explicit `openrouter:fusion` server-tool config containing the chosen
# analysis panel and optional judge model.
"fusion": {
"presets": [],
},
# AWS Bedrock provider configuration.
# Only used when model.provider is "bedrock".
"bedrock": {

View file

@ -0,0 +1,227 @@
"""OpenRouter/Nous Fusion preset configuration helpers."""
from __future__ import annotations
import re
from typing import Any
FUSION_MODEL_SLUG = "openrouter/fusion"
_FUSION_PREFIX = "fusion/"
_SUPPORTED_PROVIDERS = {"openrouter", "nous"}
_SLUG_RE = re.compile(r"[^a-z0-9._-]+")
def _slugify(value: Any) -> str:
raw = str(value or "").strip().lower()
if raw.startswith(_FUSION_PREFIX):
raw = raw[len(_FUSION_PREFIX):]
raw = _SLUG_RE.sub("-", raw).strip("-._")
return raw or "custom"
def fusion_model_slug(slug: Any) -> str:
"""Return the user-facing model slug for a Fusion preset."""
return f"{_FUSION_PREFIX}{_slugify(slug)}"
def _clean_model(value: Any) -> str:
return str(value or "").strip()
def _clean_models(values: Any) -> list[str]:
if isinstance(values, str):
raw = [part.strip() for part in values.split(",")]
elif isinstance(values, (list, tuple)):
raw = [_clean_model(item) for item in values]
else:
raw = []
seen: set[str] = set()
out: list[str] = []
for item in raw:
if not item or item in seen:
continue
seen.add(item)
out.append(item)
if len(out) >= 8:
break
return out
def normalize_fusion_preset(raw: Any) -> dict[str, Any] | None:
"""Normalize one Fusion preset from config/dashboard input.
Config shape:
provider: openrouter | nous
slug: research
analysis_models: [anthropic/..., openai/...]
judge_model: openai/... # optional
max_tool_calls: 8 # optional, 1..16
max_completion_tokens: 4096 # optional
reasoning: {...} # optional
temperature: 0.4 # optional, 0..2
"""
if not isinstance(raw, dict):
return None
provider = str(raw.get("provider") or "openrouter").strip().lower()
if provider not in _SUPPORTED_PROVIDERS:
return None
slug = _slugify(raw.get("slug") or raw.get("name") or "custom")
analysis_models = _clean_models(raw.get("analysis_models") or raw.get("models"))
if not analysis_models:
return None
preset: dict[str, Any] = {
"provider": provider,
"slug": slug,
"model_slug": fusion_model_slug(slug),
"analysis_models": analysis_models,
}
judge_model = _clean_model(raw.get("judge_model") or raw.get("model"))
if judge_model:
preset["judge_model"] = judge_model
max_tool_calls = raw.get("max_tool_calls")
try:
mtc = int(max_tool_calls) if max_tool_calls is not None else None
except (TypeError, ValueError):
mtc = None
if mtc is not None and 1 <= mtc <= 16:
preset["max_tool_calls"] = mtc
max_completion_tokens = raw.get("max_completion_tokens")
try:
mct = int(max_completion_tokens) if max_completion_tokens is not None else None
except (TypeError, ValueError):
mct = None
if mct is not None and mct > 0:
preset["max_completion_tokens"] = mct
reasoning = raw.get("reasoning")
if isinstance(reasoning, dict) and reasoning:
preset["reasoning"] = dict(reasoning)
temperature = raw.get("temperature")
try:
temp = float(temperature) if temperature is not None else None
except (TypeError, ValueError):
temp = None
if temp is not None and 0.0 <= temp <= 2.0:
preset["temperature"] = temp
return preset
def normalize_fusion_presets(raw: Any) -> list[dict[str, Any]]:
if isinstance(raw, dict):
raw = raw.get("presets") or []
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for item in raw:
preset = normalize_fusion_preset(item)
if not preset:
continue
key = (preset["provider"], preset["slug"])
if key in seen:
continue
seen.add(key)
out.append(preset)
return out
def get_fusion_presets(config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
config = {}
raw = (config or {}).get("fusion")
return normalize_fusion_presets(raw)
def fusion_model_slugs_for_provider(provider: str, config: dict[str, Any] | None = None) -> list[str]:
provider_norm = str(provider or "").strip().lower()
return [
preset["model_slug"]
for preset in get_fusion_presets(config)
if preset.get("provider") == provider_norm
]
def append_fusion_model_ids(
provider: str,
models: list[str],
config: dict[str, Any] | None = None,
) -> list[str]:
seen = {str(model).lower() for model in models}
out = list(models)
for slug in fusion_model_slugs_for_provider(provider, config):
if slug.lower() not in seen:
out.append(slug)
seen.add(slug.lower())
return out
def append_fusion_model_tuples(
provider: str,
models: list[tuple[str, str]],
config: dict[str, Any] | None = None,
) -> list[tuple[str, str]]:
seen = {str(model).lower() for model, _ in models}
out = list(models)
for slug in fusion_model_slugs_for_provider(provider, config):
if slug.lower() not in seen:
out.append((slug, "custom Fusion preset"))
seen.add(slug.lower())
return out
def resolve_fusion_preset(
provider: str,
model: str,
config: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
provider_norm = str(provider or "").strip().lower()
model_norm = str(model or "").strip().lower()
if provider_norm not in _SUPPORTED_PROVIDERS or not model_norm.startswith(_FUSION_PREFIX):
return None
for preset in get_fusion_presets(config):
if preset.get("provider") == provider_norm and preset.get("model_slug", "").lower() == model_norm:
return dict(preset)
return None
def fusion_tool_from_preset(preset: dict[str, Any]) -> dict[str, Any]:
params: dict[str, Any] = {
"analysis_models": list(preset.get("analysis_models") or []),
}
if preset.get("judge_model"):
params["model"] = preset["judge_model"]
for key in ("max_tool_calls", "max_completion_tokens", "reasoning", "temperature"):
if key in preset:
params[key] = preset[key]
return {"type": "openrouter:fusion", "parameters": params}
def fusion_request_overrides_for_model(
provider: str,
model: str,
config: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Return top-level Chat Completions kwargs for a custom Fusion slug."""
preset = resolve_fusion_preset(provider, model, config)
if not preset:
return None
return {
"model": FUSION_MODEL_SLUG,
"tools": [fusion_tool_from_preset(preset)],
"tool_choice": "required",
}
def request_model_for_fusion_preset(model: str, preset: dict[str, Any] | None) -> str:
"""Map a custom Fusion preset slug to the provider-served router model."""
return FUSION_MODEL_SLUG if preset else model

View file

@ -387,6 +387,13 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
if not name:
return name
# User-defined Fusion presets intentionally live in a Hermes-local
# namespace (``fusion/<slug>``). Aggregators must receive that slug
# unchanged so the runtime can map it to ``openrouter/fusion`` plus the
# configured server-tool payload; don't try to vendor-prefix it.
if name.lower().startswith("fusion/"):
return name
provider = _normalize_provider_alias(target_provider)
# --- Aggregators: need vendor/model format ---

View file

@ -194,6 +194,11 @@ def _model_flow_nous(config, current_model="", args=None):
)
model_ids = get_curated_nous_model_ids()
try:
from hermes_cli.fusion_presets import append_fusion_model_ids
model_ids = append_fusion_model_ids("nous", model_ids)
except Exception:
pass
if not model_ids:
print("No curated models available for Nous Portal.")
return

View file

@ -1327,6 +1327,12 @@ def list_authenticated_providers(
# requiring a Hermes release. Falls back to the in-repo
# _PROVIDER_MODELS["nous"] snapshot when the manifest is unreachable.
curated["nous"] = get_curated_nous_model_ids()
try:
from hermes_cli.fusion_presets import append_fusion_model_ids
curated["openrouter"] = append_fusion_model_ids("openrouter", curated.get("openrouter", []))
curated["nous"] = append_fusion_model_ids("nous", curated.get("nous", []))
except Exception:
pass
# Ollama Cloud uses dynamic discovery (no static curated list)
if "ollama-cloud" not in curated:
from hermes_cli.models import fetch_ollama_cloud_models
@ -1588,6 +1594,12 @@ def list_authenticated_providers(
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
if hermes_slug in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
if hermes_slug in {"openrouter", "nous"}:
try:
from hermes_cli.fusion_presets import append_fusion_model_ids
model_ids = append_fusion_model_ids(hermes_slug, list(model_ids))
except Exception:
pass
total = len(model_ids)
top = model_ids[:max_models]

View file

@ -71,6 +71,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
# NVIDIA
("nvidia/nemotron-3-super-120b-a12b", ""),
# OpenRouter routers
("openrouter/fusion", "multi-model deliberation router"),
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
# Free tier
("openrouter/elephant-alpha", "free"),
@ -1287,6 +1288,23 @@ def _openrouter_model_is_free(pricing: Any) -> bool:
return False
_OPENROUTER_AGENTIC_ROUTER_MODELS = frozenset({
"openrouter/fusion",
"openrouter/pareto-code",
})
def _ensure_openrouter_router_models(models: list[tuple[str, str]]) -> list[tuple[str, str]]:
"""Keep first-class OpenRouter router slugs visible if the remote manifest lags."""
seen = {mid for mid, _ in models}
out = list(models)
for mid, desc in OPENROUTER_MODELS:
if mid in _OPENROUTER_AGENTIC_ROUTER_MODELS and mid not in seen:
out.append((mid, desc))
seen.add(mid)
return out
def _openrouter_model_supports_tools(item: Any) -> bool:
"""Return True when the model's ``supported_parameters`` advertise tool calling.
@ -1305,6 +1323,14 @@ def _openrouter_model_supports_tools(item: Any) -> bool:
"""
if not isinstance(item, dict):
return True
mid = str(item.get("id") or "").strip().lower()
if mid in _OPENROUTER_AGENTIC_ROUTER_MODELS:
# OpenRouter routers expose synthetic model slugs whose catalog rows
# currently report supported_parameters=[] even though the chat
# completions endpoint handles them as first-class agentic routes.
# Keep those curated router slugs selectable while still dropping
# arbitrary non-tool models that explicitly omit `tools`.
return True
params = item.get("supported_parameters")
if not isinstance(params, list):
# Field absent / malformed / None — be permissive.
@ -1321,7 +1347,11 @@ def fetch_openrouter_models(
global _openrouter_catalog_cache
if _openrouter_catalog_cache is not None and not force_refresh:
return list(_openrouter_catalog_cache)
try:
from hermes_cli.fusion_presets import append_fusion_model_tuples
return append_fusion_model_tuples("openrouter", list(_openrouter_catalog_cache))
except Exception:
return list(_openrouter_catalog_cache)
# Prefer the remotely-hosted catalog manifest; fall back to the in-repo
# snapshot when the manifest is unreachable. Both are curated lists that
@ -1332,7 +1362,7 @@ def fetch_openrouter_models(
remote = get_curated_openrouter_models()
except Exception:
remote = None
fallback = list(remote) if remote else list(OPENROUTER_MODELS)
fallback = _ensure_openrouter_router_models(list(remote) if remote else list(OPENROUTER_MODELS))
preferred_ids = [mid for mid, _ in fallback]
try:
@ -1376,6 +1406,11 @@ def fetch_openrouter_models(
first_id, _ = curated[0]
curated[0] = (first_id, "recommended")
try:
from hermes_cli.fusion_presets import append_fusion_model_tuples
curated = append_fusion_model_tuples("openrouter", curated)
except Exception:
pass
_openrouter_catalog_cache = curated
return list(curated)
@ -2217,7 +2252,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if creds:
live = fetch_nous_models(api_key=creds.get("api_key", ""), inference_base_url=creds.get("base_url", ""))
if live:
return live
try:
from hermes_cli.fusion_presets import append_fusion_model_ids
return append_fusion_model_ids("nous", live)
except Exception:
return live
except Exception:
pass
# Live failed (or no creds). Fall back to the docs-hosted manifest
@ -2225,7 +2264,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
# added Portal models still surface without a Hermes release.
manifest_ids = get_curated_nous_model_ids()
if manifest_ids:
return manifest_ids
try:
from hermes_cli.fusion_presets import append_fusion_model_ids
return append_fusion_model_ids("nous", manifest_ids)
except Exception:
return manifest_ids
if normalized == "stepfun":
try:
from hermes_cli.auth import resolve_api_key_provider_credentials

View file

@ -535,6 +535,7 @@ _CATEGORY_MERGE: Dict[str, str] = {
"code_execution": "agent",
"prompt_caching": "agent",
"goals": "agent",
"fusion": "agent",
"updates": "general",
# `onboarding.profile_build` is the only schema-surfaced onboarding field
# (`onboarding.seen` is an internal latch dict, not a user setting), so fold
@ -753,6 +754,22 @@ class MoaConfigPayload(BaseModel):
profile: Optional[str] = None
class FusionPresetPayload(BaseModel):
provider: str = "openrouter"
slug: str
analysis_models: list[str]
judge_model: str = ""
max_tool_calls: Optional[int] = None
max_completion_tokens: Optional[int] = None
reasoning: Optional[dict] = None
temperature: Optional[float] = None
class FusionPresetsPayload(BaseModel):
presets: list[FusionPresetPayload]
profile: Optional[str] = None
def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, str]:
"""Normalize a main-slot (provider, model) pair before persisting.
@ -3298,6 +3315,52 @@ def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None):
raise HTTPException(status_code=500, detail="Failed to save MoA config")
@app.get("/api/model/fusion")
def get_fusion_presets_api(profile: Optional[str] = None):
"""Return configured OpenRouter/Nous Fusion presets."""
try:
from hermes_cli.fusion_presets import normalize_fusion_presets
with _profile_scope(profile):
cfg = load_config()
presets = normalize_fusion_presets(cfg.get("fusion") if isinstance(cfg, dict) else {})
return {"presets": presets}
except HTTPException:
raise
except Exception:
_log.exception("GET /api/model/fusion failed")
raise HTTPException(status_code=500, detail="Failed to read Fusion presets")
@app.put("/api/model/fusion")
def set_fusion_presets_api(body: FusionPresetsPayload, profile: Optional[str] = None):
"""Persist OpenRouter/Nous Fusion presets."""
try:
from hermes_cli.fusion_presets import normalize_fusion_presets
raw = {"presets": [preset.dict() for preset in body.presets]}
presets = normalize_fusion_presets(raw)
with _profile_scope(body.profile or profile):
cfg = load_config()
cfg["fusion"] = {"presets": presets}
save_config(cfg)
try:
from hermes_cli.models import clear_provider_models_cache
import hermes_cli.models as _models
_models._openrouter_catalog_cache = None
clear_provider_models_cache("openrouter")
clear_provider_models_cache("nous")
except Exception:
pass
return {"ok": True, "presets": presets}
except HTTPException:
raise
except Exception:
_log.exception("PUT /api/model/fusion failed")
raise HTTPException(status_code=500, detail="Failed to save Fusion presets")
@app.post("/api/model/set")
async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = None):
"""Assign a model to the main slot or an auxiliary task slot.

View file

@ -236,6 +236,31 @@ class TestChatCompletionsBuildKwargs:
{"id": "pareto-router", "min_coding_score": 0.8}
]
def test_fusion_request_overrides_replace_model_and_tools(self, transport):
from providers import get_provider_profile
profile = get_provider_profile("openrouter")
msgs = [{"role": "user", "content": "Hi"}]
fusion_tool = {
"type": "openrouter:fusion",
"parameters": {"analysis_models": ["openai/gpt-5.5"]},
}
kw = transport.build_kwargs(
model="fusion/research",
messages=msgs,
tools=[{"type": "function", "function": {"name": "local_tool", "parameters": {}}}],
provider_profile=profile,
request_overrides={
"model": "openrouter/fusion",
"tools": [fusion_tool],
"tool_choice": "required",
},
)
assert kw["model"] == "openrouter/fusion"
assert kw["tools"] == [fusion_tool]
assert kw["tool_choice"] == "required"
def test_nous_tags(self, transport):
from agent.portal_tags import nous_portal_tags
from providers import get_provider_profile

View file

@ -0,0 +1,79 @@
from hermes_cli.fusion_presets import (
append_fusion_model_ids,
append_fusion_model_tuples,
fusion_model_slug,
fusion_request_overrides_for_model,
normalize_fusion_presets,
)
def _cfg():
return {
"fusion": {
"presets": [
{
"provider": "openrouter",
"slug": "Research Panel",
"analysis_models": [
"anthropic/claude-opus-4.8",
"openai/gpt-5.5",
],
"judge_model": "google/gemini-3.5-flash",
"max_tool_calls": 6,
"max_completion_tokens": 4096,
"temperature": 0.3,
},
{
"provider": "nous",
"slug": "portal-panel",
"analysis_models": ["anthropic/claude-sonnet-4.6"],
},
]
}
}
def test_normalize_fusion_presets_creates_model_slugs():
presets = normalize_fusion_presets(_cfg()["fusion"])
assert presets[0]["slug"] == "research-panel"
assert presets[0]["model_slug"] == "fusion/research-panel"
assert presets[1]["provider"] == "nous"
assert presets[1]["model_slug"] == "fusion/portal-panel"
def test_append_fusion_models_is_provider_scoped():
assert append_fusion_model_ids("openrouter", ["openrouter/fusion"], _cfg()) == [
"openrouter/fusion",
"fusion/research-panel",
]
assert append_fusion_model_ids("nous", ["anthropic/claude-opus-4.8"], _cfg()) == [
"anthropic/claude-opus-4.8",
"fusion/portal-panel",
]
assert append_fusion_model_tuples("openrouter", [], _cfg()) == [
("fusion/research-panel", "custom Fusion preset")
]
def test_fusion_request_overrides_maps_custom_slug_to_router_tool():
overrides = fusion_request_overrides_for_model("openrouter", "fusion/research-panel", _cfg())
assert overrides["model"] == "openrouter/fusion"
assert overrides["tool_choice"] == "required"
assert overrides["tools"] == [
{
"type": "openrouter:fusion",
"parameters": {
"analysis_models": ["anthropic/claude-opus-4.8", "openai/gpt-5.5"],
"model": "google/gemini-3.5-flash",
"max_tool_calls": 6,
"max_completion_tokens": 4096,
"temperature": 0.3,
},
}
]
def test_fusion_model_slug_sanitizes_names():
assert fusion_model_slug("Research Panel!") == "fusion/research-panel"

View file

@ -32,6 +32,22 @@ def test_normalize_moa_config_drops_incomplete_slots_and_keeps_valid_ones():
assert cfg["aggregator"] == {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}
def test_normalize_moa_config_accepts_openrouter_fusion_model_slug():
cfg = normalize_moa_config(
{
"reference_models": [
{"provider": "openrouter", "model": "openrouter/fusion"},
],
"aggregator": {"provider": "openrouter", "model": "openrouter/fusion"},
}
)
assert cfg["reference_models"] == [
{"provider": "openrouter", "model": "openrouter/fusion"}
]
assert cfg["aggregator"] == {"provider": "openrouter", "model": "openrouter/fusion"}
def test_build_moa_turn_prompt_keeps_user_prompt_and_marks_mode():
prompt = build_moa_turn_prompt("write a file then inspect it")

View file

@ -135,6 +135,71 @@ class TestFetchOpenRouterModels:
# Image-only model advertised supported_parameters WITHOUT tools → must be dropped.
assert "google/gemini-3-pro-image-preview" not in ids
def test_keeps_openrouter_router_slugs_despite_empty_supported_parameters(self, monkeypatch):
"""OpenRouter router slugs may report [] but are valid chat models."""
class _Resp:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return (
b'{"data":['
b'{"id":"openrouter/fusion","pricing":{"prompt":"-1","completion":"-1"},'
b'"supported_parameters":[]},'
b'{"id":"openrouter/pareto-code","pricing":{"prompt":"-1","completion":"-1"},'
b'"supported_parameters":[]}'
b']}'
)
monkeypatch.setattr(
_models_mod,
"OPENROUTER_MODELS",
[
("openrouter/fusion", "multi-model deliberation router"),
("openrouter/pareto-code", "auto-routes to cheapest coder"),
],
)
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
models = fetch_openrouter_models(force_refresh=True)
ids = [mid for mid, _ in models]
assert "openrouter/fusion" in ids
assert "openrouter/pareto-code" in ids
def test_remote_manifest_lag_does_not_hide_openrouter_fusion(self, monkeypatch):
"""Fusion must stay selectable even before the hosted manifest refreshes."""
class _Resp:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return (
b'{"data":['
b'{"id":"openrouter/fusion","pricing":{"prompt":"-1","completion":"-1"},'
b'"supported_parameters":[]},'
b'{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"},'
b'"supported_parameters":["tools"]}'
b']}'
)
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
monkeypatch.setattr(
"hermes_cli.model_catalog.get_curated_openrouter_models",
lambda: [("anthropic/claude-opus-4.8", "")],
)
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
models = fetch_openrouter_models(force_refresh=True)
ids = [mid for mid, _ in models]
assert "openrouter/fusion" in ids
def test_permissive_when_supported_parameters_missing(self, monkeypatch):
"""Models missing the supported_parameters field keep appearing in the picker.
@ -211,6 +276,13 @@ class TestOpenRouterToolSupportHelper:
{"id": "x", "supported_parameters": []}
) is False
def test_agentic_router_empty_supported_parameters_are_allowed(self):
from hermes_cli.models import _openrouter_model_supports_tools
assert _openrouter_model_supports_tools(
{"id": "openrouter/fusion", "supported_parameters": []}
) is True
class TestFindOpenrouterSlug:
def test_exact_match(self):

View file

@ -274,6 +274,34 @@ class TestWebServerEndpoints:
assert cfg["moa"]["reference_models"] == payload["reference_models"]
assert cfg["moa"]["aggregator"] == payload["aggregator"]
def test_put_fusion_presets_persists_custom_model_slugs(self):
from hermes_cli.config import load_config
payload = {
"presets": [
{
"provider": "openrouter",
"slug": "research panel",
"analysis_models": ["anthropic/claude-opus-4.8", "openai/gpt-5.5"],
"judge_model": "google/gemini-3.5-flash",
"max_tool_calls": 6,
},
{
"provider": "nous",
"slug": "portal",
"analysis_models": ["anthropic/claude-sonnet-4.6"],
},
]
}
resp = self.client.put("/api/model/fusion", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["presets"][0]["model_slug"] == "fusion/research-panel"
cfg = load_config()
assert cfg["fusion"]["presets"][1]["model_slug"] == "fusion/portal"
# ── GET /api/media (remote image display) ───────────────────────────
def test_get_media_serves_image_in_root(self):

View file

@ -75,6 +75,7 @@ const PROFILE_SCOPED_PREFIXES = [
"/api/model/set",
"/api/model/auxiliary",
"/api/model/moa",
"/api/model/fusion",
"/api/model/options",
];
@ -457,6 +458,13 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
getFusionPresets: () => fetchJSON<FusionPresetsResponse>("/api/model/fusion"),
saveFusionPresets: (body: FusionPresetsResponse) =>
fetchJSON<FusionPresetsResponse & { ok: boolean }>("/api/model/fusion", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
setModelAssignment: (body: ModelAssignmentRequest) =>
fetchJSON<ModelAssignmentResponse>("/api/model/set", {
method: "POST",
@ -2039,6 +2047,22 @@ export interface MoaConfigResponse {
enabled: boolean;
}
export interface FusionPreset {
provider: "openrouter" | "nous" | string;
slug: string;
model_slug?: string;
analysis_models: string[];
judge_model?: string;
max_tool_calls?: number | null;
max_completion_tokens?: number | null;
reasoning?: Record<string, unknown> | null;
temperature?: number | null;
}
export interface FusionPresetsResponse {
presets: FusionPreset[];
}
export interface ModelAssignmentRequest {
confirm_expensive_model?: boolean;
scope: "main" | "auxiliary";

View file

@ -16,6 +16,8 @@ import { api } from "@/lib/api";
import type {
AuxiliaryModelsResponse,
AuxiliaryTaskAssignment,
FusionPreset,
FusionPresetsResponse,
MoaConfigResponse,
MoaModelSlot,
ModelsAnalyticsModelEntry,
@ -778,6 +780,92 @@ function MoaModelsModal({
);
}
function FusionPresetsModal({
config,
onClose,
onSaved,
}: {
config: FusionPresetsResponse;
onClose(): void;
onSaved(next: FusionPresetsResponse): void;
}) {
const [draft, setDraft] = useState<FusionPresetsResponse>(config);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const updatePreset = (index: number, patch: Partial<FusionPreset>) => {
setDraft((prev) => ({
presets: prev.presets.map((preset, i) =>
i === index ? { ...preset, ...patch } : preset,
),
}));
};
const save = async () => {
setBusy(true);
setError(null);
try {
const saved = await api.saveFusionPresets(draft);
onSaved(saved);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm">
<Card className="max-h-[85vh] w-full max-w-3xl overflow-auto">
<CardHeader>
<CardTitle className="text-sm">Configure Fusion Presets</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-xs text-text-secondary">
Each preset creates a selectable model slug under OpenRouter or Nous, such as <code>fusion/research</code>. Hermes sends it as <code>openrouter/fusion</code> with your selected analysis panel.
</p>
{draft.presets.map((preset, index) => (
<div key={`${preset.provider}-${preset.slug}-${index}`} className="space-y-2 border border-border/50 bg-muted/20 p-3">
<div className="grid gap-2 sm:grid-cols-3">
<label className="text-xs text-text-secondary">
Provider
<select className="mt-1 w-full border border-border bg-background px-2 py-1 font-mono text-xs" value={preset.provider || "openrouter"} onChange={(e) => updatePreset(index, { provider: e.target.value })}>
<option value="openrouter">openrouter</option>
<option value="nous">nous</option>
</select>
</label>
<label className="text-xs text-text-secondary">
Slug
<input className="mt-1 w-full border border-border bg-background px-2 py-1 font-mono text-xs" value={preset.slug || ""} onChange={(e) => updatePreset(index, { slug: e.target.value })} placeholder="research" />
</label>
<label className="text-xs text-text-secondary">
Judge model
<input className="mt-1 w-full border border-border bg-background px-2 py-1 font-mono text-xs" value={preset.judge_model || ""} onChange={(e) => updatePreset(index, { judge_model: e.target.value })} placeholder="openai/gpt-5.5" />
</label>
</div>
<label className="block text-xs text-text-secondary">
Analysis models (comma-separated OpenRouter/Nous catalog slugs, 1–8)
<textarea className="mt-1 min-h-20 w-full border border-border bg-background px-2 py-1 font-mono text-xs" value={(preset.analysis_models || []).join(", ")} onChange={(e) => updatePreset(index, { analysis_models: e.target.value.split(",").map((m) => m.trim()).filter(Boolean) })} placeholder="anthropic/claude-opus-4.8, openai/gpt-5.5, google/gemini-3.5-flash" />
</label>
<div className="grid gap-2 sm:grid-cols-3">
<label className="text-xs text-text-secondary">Max tool calls<input className="mt-1 w-full border border-border bg-background px-2 py-1 font-mono text-xs" value={preset.max_tool_calls ?? ""} onChange={(e) => updatePreset(index, { max_tool_calls: e.target.value ? Number(e.target.value) : null })} /></label>
<label className="text-xs text-text-secondary">Max completion tokens<input className="mt-1 w-full border border-border bg-background px-2 py-1 font-mono text-xs" value={preset.max_completion_tokens ?? ""} onChange={(e) => updatePreset(index, { max_completion_tokens: e.target.value ? Number(e.target.value) : null })} /></label>
<label className="text-xs text-text-secondary">Temperature<input className="mt-1 w-full border border-border bg-background px-2 py-1 font-mono text-xs" value={preset.temperature ?? ""} onChange={(e) => updatePreset(index, { temperature: e.target.value ? Number(e.target.value) : null })} /></label>
</div>
<div className="flex justify-end"><Button size="sm" ghost onClick={() => setDraft((prev) => ({ presets: prev.presets.filter((_, i) => i !== index) }))}>Remove preset</Button></div>
</div>
))}
<Button size="sm" outlined onClick={() => setDraft((prev) => ({ presets: [...prev.presets, { provider: "openrouter", slug: "research", analysis_models: [] }] }))}>Add Fusion preset</Button>
{error && <div className="text-xs text-destructive">{error}</div>}
<div className="flex justify-end gap-2 pt-2"><Button ghost onClick={onClose} disabled={busy}>Cancel</Button><Button onClick={save} disabled={busy}>{busy ? "Saving…" : "Save"}</Button></div>
</CardContent>
</Card>
</div>
);
}
function ModelSettingsPanel({
aux,
refreshKey,
@ -789,7 +877,9 @@ function ModelSettingsPanel({
}) {
const [auxModalOpen, setAuxModalOpen] = useState(false);
const [moaModalOpen, setMoaModalOpen] = useState(false);
const [fusionModalOpen, setFusionModalOpen] = useState(false);
const [moa, setMoa] = useState<MoaConfigResponse | null>(null);
const [fusion, setFusion] = useState<FusionPresetsResponse | null>(null);
const [picker, setPicker] = useState<PickerTarget | null>(null);
const mainProv = aux?.main.provider ?? "";
@ -797,6 +887,7 @@ function ModelSettingsPanel({
useEffect(() => {
api.getMoaModels().then(setMoa).catch(() => setMoa(null));
api.getFusionPresets().then(setFusion).catch(() => setFusion(null));
}, [refreshKey]);
const applyAssignment = async ({
@ -915,6 +1006,23 @@ function ModelSettingsPanel({
</Button>
</div>
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<Zap className="h-3 w-3 text-text-tertiary" />
<span className="text-display text-xs font-medium tracking-wider">
Fusion presets
</span>
</div>
<div className="text-xs font-mono text-text-secondary truncate">
{fusion ? `${fusion.presets.length} preset${fusion.presets.length === 1 ? "" : "s"}` : "not loaded"}
</div>
</div>
<Button size="sm" outlined onClick={() => setFusionModalOpen(true)} disabled={!fusion} className="shrink-0 self-start text-xs uppercase sm:self-center">
Configure
</Button>
</div>
{picker && (
<ModelPickerDialog
key={`picker-${refreshKey}`}
@ -954,6 +1062,17 @@ function ModelSettingsPanel({
onClose={() => setMoaModalOpen(false)}
/>
)}
{fusionModalOpen && fusion && (
<FusionPresetsModal
config={fusion}
onSaved={(next) => {
setFusion(next);
onSaved();
}}
onClose={() => setFusionModalOpen(false)}
/>
)}
</CardContent>
</Card>
);

View file

@ -41,6 +41,37 @@ moa:
max_tokens: 4096
```
OpenRouter Fusion presets are valid model choices here too. Define any number
of custom Fusion slugs under `fusion.presets`; those slugs appear under the
configured provider (`openrouter` or `nous`) in `hermes model`, `/model`, and
the dashboard picker.
```yaml
fusion:
presets:
- provider: openrouter # or nous
slug: research-panel # selectable as fusion/research-panel
analysis_models:
- anthropic/claude-opus-4.8
- openai/gpt-5.5
- google/gemini-3.5-flash
judge_model: openai/gpt-5.5 # optional
max_tool_calls: 8 # optional
max_completion_tokens: 4096 # optional
moa:
reference_models:
- provider: openrouter
model: fusion/research-panel
aggregator:
provider: openrouter
model: fusion/research-panel
```
At request time Hermes sends the preset as `model: openrouter/fusion` plus the
configured `openrouter:fusion` server-tool payload, so the user-facing slug is
stable while the analysis panel stays configurable.
Defaults use one Codex OAuth model and two OpenRouter-hosted models for high-signal testing:
- `openai-codex:gpt-5.5`

View file

@ -108,6 +108,10 @@
"id": "nvidia/nemotron-3-super-120b-a12b",
"description": ""
},
{
"id": "openrouter/fusion",
"description": "multi-model deliberation router"
},
{
"id": "openrouter/pareto-code",
"description": "auto-routes to cheapest coder meeting openrouter.min_coding_score"