mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
feat(openrouter): expose Fusion as a model slug
This commit is contained in:
parent
4c367df56f
commit
431e3be47d
19 changed files with 810 additions and 6 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
227
hermes_cli/fusion_presets.py
Normal file
227
hermes_cli/fusion_presets.py
Normal 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
|
||||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
tests/hermes_cli/test_fusion_presets.py
Normal file
79
tests/hermes_cli/test_fusion_presets.py
Normal 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"
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue