From 431e3be47d965fc2d798a0f1bbc1c4e3ded5223f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 14 Jun 2026 03:34:41 -0700 Subject: [PATCH] feat(openrouter): expose Fusion as a model slug --- agent/auxiliary_client.py | 13 + agent/chat_completion_helpers.py | 29 ++- agent/model_metadata.py | 2 + hermes_cli/config.py | 9 + hermes_cli/fusion_presets.py | 227 ++++++++++++++++++ hermes_cli/model_normalize.py | 7 + hermes_cli/model_setup_flows.py | 5 + hermes_cli/model_switch.py | 12 + hermes_cli/models.py | 51 +++- hermes_cli/web_server.py | 63 +++++ .../agent/transports/test_chat_completions.py | 25 ++ tests/hermes_cli/test_fusion_presets.py | 79 ++++++ tests/hermes_cli/test_moa_config.py | 16 ++ tests/hermes_cli/test_models.py | 72 ++++++ tests/hermes_cli/test_web_server.py | 28 +++ web/src/lib/api.ts | 24 ++ web/src/pages/ModelsPage.tsx | 119 +++++++++ .../user-guide/features/mixture-of-agents.md | 31 +++ website/static/api/model-catalog.json | 4 + 19 files changed, 810 insertions(+), 6 deletions(-) create mode 100644 hermes_cli/fusion_presets.py create mode 100644 tests/hermes_cli/test_fusion_presets.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 01ea45d7be24..b66adb5f2da6 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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": diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 1ee1702b45e8..dc4eff0f134c 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -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 = {} diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 3a71e974fdb7..b6344a9a8ccb 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -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, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5f4156c0b2d2..475ac4751e4a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1209,6 +1209,15 @@ DEFAULT_CONFIG = { "min_coding_score": 0.65, }, + # User-defined OpenRouter Fusion presets. Each preset becomes a selectable + # model slug (`fusion/`) 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": { diff --git a/hermes_cli/fusion_presets.py b/hermes_cli/fusion_presets.py new file mode 100644 index 000000000000..2f5b016917c3 --- /dev/null +++ b/hermes_cli/fusion_presets.py @@ -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 diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 2c4988cc76ea..57f5fafa0230 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -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/``). 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 --- diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 83e60fc20a23..17d517b3d7d8 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -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 diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 9e90cef9eadd..75413b6ddd7b 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -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] diff --git a/hermes_cli/models.py b/hermes_cli/models.py index afab5bac32de..ebaeb07b7378 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8adc368b3ca4..e49dcc07f398 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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. diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index da642e2ae17c..334a1738ee1a 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -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 diff --git a/tests/hermes_cli/test_fusion_presets.py b/tests/hermes_cli/test_fusion_presets.py new file mode 100644 index 000000000000..58c646543807 --- /dev/null +++ b/tests/hermes_cli/test_fusion_presets.py @@ -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" diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index 9893a7c77cdb..f5b41dcd98ac 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -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") diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index 21f1557d7358..60a5f6499d82 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -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): diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 276004dba88c..11731920deb3 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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): diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1e900c3f328c..41501a63264e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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("/api/model/fusion"), + saveFusionPresets: (body: FusionPresetsResponse) => + fetchJSON("/api/model/fusion", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), setModelAssignment: (body: ModelAssignmentRequest) => fetchJSON("/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 | null; + temperature?: number | null; +} + +export interface FusionPresetsResponse { + presets: FusionPreset[]; +} + export interface ModelAssignmentRequest { confirm_expensive_model?: boolean; scope: "main" | "auxiliary"; diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 0d009d76d636..4c88d34a9a98 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -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(config); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const updatePreset = (index: number, patch: Partial) => { + 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 ( +
+ + + Configure Fusion Presets + + +

+ Each preset creates a selectable model slug under OpenRouter or Nous, such as fusion/research. Hermes sends it as openrouter/fusion with your selected analysis panel. +

+ {draft.presets.map((preset, index) => ( +
+
+ + + +
+