mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(hermes model): add Configure auxiliary models UI to hermes model (#11891)
Previously users had to hand-edit config.yaml to route individual auxiliary
tasks (vision, compression, web_extract, etc.) to a specific provider+model.
Add a first-class picker reachable from the bottom of the existing `hermes
model` provider list.
Flow:
hermes model
→ Configure auxiliary models...
→ <task picker: 9 tasks, shows current setting inline>
→ <provider picker: authenticated providers + auto + custom>
→ <model picker: curated list + live pricing>
The aux picker does NOT re-run credential/OAuth setup; users authenticate
providers through the normal `hermes model` flow, then route aux tasks to
them here. `list_authenticated_providers()` gates the list to providers
the user has configured.
Also:
- 'Cancel' entry relabeled 'Leave unchanged' (sentinel still 'cancel'
internally, so dispatch logic is unchanged)
- 'Reset all to auto' entry to bulk-clear aux overrides; preserves
user-tuned timeout / download_timeout values
- Adds `title_generation` task to DEFAULT_CONFIG.auxiliary — the task
was called from agent/title_generator.py but was missing from defaults,
so config-backed timeout overrides never worked for it
Co-authored-by: teknium1 <teknium@nousresearch.com>
This commit is contained in:
parent
bb85404b16
commit
8444f66890
3 changed files with 629 additions and 1 deletions
|
|
@ -1467,7 +1467,8 @@ def select_provider_and_model(args=None):
|
|||
)
|
||||
if _has_saved_custom_list:
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider"))
|
||||
ordered.append(("cancel", "Cancel"))
|
||||
ordered.append(("aux-config", "Configure auxiliary models..."))
|
||||
ordered.append(("cancel", "Leave unchanged"))
|
||||
|
||||
provider_idx = _prompt_provider_choice(
|
||||
[label for _, label in ordered],
|
||||
|
|
@ -1479,6 +1480,10 @@ def select_provider_and_model(args=None):
|
|||
|
||||
selected_provider = ordered[provider_idx][0]
|
||||
|
||||
if selected_provider == "aux-config":
|
||||
_aux_config_menu()
|
||||
return
|
||||
|
||||
# Step 2: Provider-specific setup + model selection
|
||||
if selected_provider == "openrouter":
|
||||
_model_flow_openrouter(config, current_model)
|
||||
|
|
@ -1579,6 +1584,328 @@ def _clear_stale_openai_base_url():
|
|||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Auxiliary model configuration
|
||||
#
|
||||
# Hermes uses lightweight "auxiliary" models for side tasks (vision analysis,
|
||||
# context compression, web extraction, session search, etc.). Each task has
|
||||
# its own provider+model pair in config.yaml under `auxiliary.<task>`.
|
||||
#
|
||||
# The UI lives behind "Configure auxiliary models..." at the bottom of the
|
||||
# `hermes model` provider picker. It does NOT re-run credential setup — it
|
||||
# only routes already-authenticated providers to specific aux tasks. Users
|
||||
# configure new providers through the normal `hermes model` flow first.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# (task_key, display_name, short_description)
|
||||
_AUX_TASKS: list[tuple[str, str, str]] = [
|
||||
("vision", "Vision", "image/screenshot analysis"),
|
||||
("compression", "Compression", "context summarization"),
|
||||
("web_extract", "Web extract", "web page summarization"),
|
||||
("session_search", "Session search", "past-conversation recall"),
|
||||
("approval", "Approval", "smart command approval"),
|
||||
("mcp", "MCP", "MCP tool reasoning"),
|
||||
("flush_memories", "Flush memories", "memory consolidation"),
|
||||
("title_generation", "Title generation", "session titles"),
|
||||
("skills_hub", "Skills hub", "skills search/install"),
|
||||
]
|
||||
|
||||
|
||||
def _format_aux_current(task_cfg: dict) -> str:
|
||||
"""Render the current aux config for display in the task menu."""
|
||||
if not isinstance(task_cfg, dict):
|
||||
return "auto"
|
||||
base_url = str(task_cfg.get("base_url") or "").strip()
|
||||
provider = str(task_cfg.get("provider") or "auto").strip() or "auto"
|
||||
model = str(task_cfg.get("model") or "").strip()
|
||||
if base_url:
|
||||
short = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
return f"custom ({short})" + (f" · {model}" if model else "")
|
||||
if provider == "auto":
|
||||
return "auto" + (f" · {model}" if model else "")
|
||||
if model:
|
||||
return f"{provider} · {model}"
|
||||
return provider
|
||||
|
||||
|
||||
def _save_aux_choice(
|
||||
task: str,
|
||||
*,
|
||||
provider: str,
|
||||
model: str = "",
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
) -> None:
|
||||
"""Persist an auxiliary task's provider/model to config.yaml.
|
||||
|
||||
Only writes the four routing fields — timeout, download_timeout, and any
|
||||
other task-specific settings are preserved untouched. The main model
|
||||
config (``model.default``/``model.provider``) is never modified.
|
||||
"""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
aux = cfg.setdefault("auxiliary", {})
|
||||
if not isinstance(aux, dict):
|
||||
aux = {}
|
||||
cfg["auxiliary"] = aux
|
||||
entry = aux.setdefault(task, {})
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
aux[task] = entry
|
||||
entry["provider"] = provider
|
||||
entry["model"] = model or ""
|
||||
entry["base_url"] = base_url or ""
|
||||
entry["api_key"] = api_key or ""
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def _reset_aux_to_auto() -> int:
|
||||
"""Reset every known aux task back to auto/empty. Returns number reset."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
aux = cfg.setdefault("auxiliary", {})
|
||||
if not isinstance(aux, dict):
|
||||
aux = {}
|
||||
cfg["auxiliary"] = aux
|
||||
count = 0
|
||||
for task, _name, _desc in _AUX_TASKS:
|
||||
entry = aux.setdefault(task, {})
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
aux[task] = entry
|
||||
changed = False
|
||||
if entry.get("provider") not in (None, "", "auto"):
|
||||
entry["provider"] = "auto"
|
||||
changed = True
|
||||
for field in ("model", "base_url", "api_key"):
|
||||
if entry.get(field):
|
||||
entry[field] = ""
|
||||
changed = True
|
||||
# Preserve timeout/download_timeout — those are user-tuned, not routing
|
||||
if changed:
|
||||
count += 1
|
||||
save_config(cfg)
|
||||
return count
|
||||
|
||||
|
||||
def _aux_config_menu() -> None:
|
||||
"""Top-level auxiliary-model picker — choose a task to configure.
|
||||
|
||||
Loops until the user picks "Back" so multiple tasks can be configured
|
||||
without returning to the main provider menu.
|
||||
"""
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
while True:
|
||||
cfg = load_config()
|
||||
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
|
||||
|
||||
print()
|
||||
print(" Auxiliary models — side-task routing")
|
||||
print()
|
||||
print(" Hermes uses small, fast models for vision, compression, web")
|
||||
print(" extraction, and other side tasks. \"auto\" lets Hermes pick the")
|
||||
print(" best available backend automatically (OpenRouter → Nous Portal")
|
||||
print(" → your main provider). You rarely need to change these —")
|
||||
print(" override only if you want a specific model for a task.")
|
||||
print()
|
||||
|
||||
# Build the task menu with current settings inline
|
||||
name_col = max(len(name) for _, name, _ in _AUX_TASKS) + 2
|
||||
desc_col = max(len(desc) for _, _, desc in _AUX_TASKS) + 4
|
||||
entries: list[tuple[str, str]] = []
|
||||
for task_key, name, desc in _AUX_TASKS:
|
||||
task_cfg = aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {}
|
||||
current = _format_aux_current(task_cfg)
|
||||
label = f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}"
|
||||
entries.append((task_key, label))
|
||||
entries.append(("__reset__", "Reset all to auto"))
|
||||
entries.append(("__back__", "Back"))
|
||||
|
||||
idx = _prompt_provider_choice(
|
||||
[label for _, label in entries], default=0,
|
||||
)
|
||||
if idx is None:
|
||||
return
|
||||
key = entries[idx][0]
|
||||
if key == "__back__":
|
||||
return
|
||||
if key == "__reset__":
|
||||
n = _reset_aux_to_auto()
|
||||
if n:
|
||||
print(f"Reset {n} auxiliary task(s) to auto.")
|
||||
else:
|
||||
print("All auxiliary tasks were already set to auto.")
|
||||
print()
|
||||
continue
|
||||
# Otherwise configure the specific task
|
||||
_aux_select_for_task(key)
|
||||
|
||||
|
||||
def _aux_select_for_task(task: str) -> None:
|
||||
"""Pick a provider + model for a single auxiliary task and persist it.
|
||||
|
||||
Uses ``list_authenticated_providers()`` to only show providers the user
|
||||
has already configured. This avoids re-running OAuth/credential flows
|
||||
inside the aux picker — users set up new providers through the normal
|
||||
``hermes model`` flow, then route aux tasks to them here.
|
||||
"""
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
cfg = load_config()
|
||||
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
|
||||
task_cfg = aux.get(task, {}) if isinstance(aux.get(task), dict) else {}
|
||||
current_provider = str(task_cfg.get("provider") or "auto").strip() or "auto"
|
||||
current_model = str(task_cfg.get("model") or "").strip()
|
||||
current_base_url = str(task_cfg.get("base_url") or "").strip()
|
||||
|
||||
display_name = next((name for key, name, _ in _AUX_TASKS if key == task), task)
|
||||
|
||||
# Gather authenticated providers (has credentials + curated model list)
|
||||
try:
|
||||
providers = list_authenticated_providers(current_provider=current_provider)
|
||||
except Exception as exc:
|
||||
print(f"Could not detect authenticated providers: {exc}")
|
||||
providers = []
|
||||
|
||||
entries: list[tuple[str, str, list[str]]] = [] # (slug, label, models)
|
||||
# "auto" always first
|
||||
auto_marker = " ← current" if current_provider == "auto" and not current_base_url else ""
|
||||
entries.append(("__auto__", f"auto (recommended){auto_marker}", []))
|
||||
|
||||
for p in providers:
|
||||
slug = p.get("slug", "")
|
||||
name = p.get("name") or slug
|
||||
total = p.get("total_models", 0)
|
||||
models = p.get("models") or []
|
||||
model_hint = f" — {total} models" if total else ""
|
||||
marker = " ← current" if slug == current_provider and not current_base_url else ""
|
||||
entries.append((slug, f"{name}{model_hint}{marker}", list(models)))
|
||||
|
||||
# Custom endpoint (raw base_url)
|
||||
custom_marker = " ← current" if current_base_url else ""
|
||||
entries.append(("__custom__", f"Custom endpoint (direct URL){custom_marker}", []))
|
||||
entries.append(("__back__", "Back", []))
|
||||
|
||||
print()
|
||||
print(f" Configure {display_name} — current: {_format_aux_current(task_cfg)}")
|
||||
print()
|
||||
|
||||
idx = _prompt_provider_choice([label for _, label, _ in entries], default=0)
|
||||
if idx is None:
|
||||
return
|
||||
slug, _label, models = entries[idx]
|
||||
|
||||
if slug == "__back__":
|
||||
return
|
||||
|
||||
if slug == "__auto__":
|
||||
_save_aux_choice(task, provider="auto", model="", base_url="", api_key="")
|
||||
print(f"{display_name}: reset to auto.")
|
||||
return
|
||||
|
||||
if slug == "__custom__":
|
||||
_aux_flow_custom_endpoint(task, task_cfg)
|
||||
return
|
||||
|
||||
# Regular provider — pick a model from its curated list
|
||||
_aux_flow_provider_model(task, slug, models, current_model)
|
||||
|
||||
|
||||
def _aux_flow_provider_model(
|
||||
task: str,
|
||||
provider_slug: str,
|
||||
curated_models: list,
|
||||
current_model: str = "",
|
||||
) -> None:
|
||||
"""Prompt for a model under an already-authenticated provider, save to aux."""
|
||||
from hermes_cli.auth import _prompt_model_selection
|
||||
from hermes_cli.models import get_pricing_for_provider
|
||||
|
||||
display_name = next((name for key, name, _ in _AUX_TASKS if key == task), task)
|
||||
|
||||
# Fetch live pricing for this provider (non-blocking)
|
||||
pricing: dict = {}
|
||||
try:
|
||||
pricing = get_pricing_for_provider(provider_slug) or {}
|
||||
except Exception:
|
||||
pricing = {}
|
||||
|
||||
model_list = list(curated_models)
|
||||
|
||||
# Let the user pick a model. _prompt_model_selection supports "Enter custom
|
||||
# model name" and cancel. When there's no curated list (rare), fall back
|
||||
# to a raw input prompt.
|
||||
if not model_list:
|
||||
print(f"No curated model list for {provider_slug}.")
|
||||
print("Enter a model slug manually (blank = use provider default):")
|
||||
try:
|
||||
val = input("Model: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
selected = val or ""
|
||||
else:
|
||||
selected = _prompt_model_selection(
|
||||
model_list, current_model=current_model, pricing=pricing,
|
||||
)
|
||||
if selected is None:
|
||||
print("No change.")
|
||||
return
|
||||
|
||||
_save_aux_choice(task, provider=provider_slug, model=selected or "",
|
||||
base_url="", api_key="")
|
||||
if selected:
|
||||
print(f"{display_name}: {provider_slug} · {selected}")
|
||||
else:
|
||||
print(f"{display_name}: {provider_slug} (provider default model)")
|
||||
|
||||
|
||||
def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
|
||||
"""Prompt for a direct OpenAI-compatible base_url + optional api_key/model."""
|
||||
import getpass
|
||||
|
||||
display_name = next((name for key, name, _ in _AUX_TASKS if key == task), task)
|
||||
current_base_url = str(task_cfg.get("base_url") or "").strip()
|
||||
current_model = str(task_cfg.get("model") or "").strip()
|
||||
|
||||
print()
|
||||
print(f" Custom endpoint for {display_name}")
|
||||
print(" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)")
|
||||
print()
|
||||
try:
|
||||
url_prompt = f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: "
|
||||
url = input(url_prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
url = url or current_base_url
|
||||
if not url:
|
||||
print("No URL provided. No change.")
|
||||
return
|
||||
try:
|
||||
model_prompt = f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): "
|
||||
model = input(model_prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
model = model or current_model
|
||||
try:
|
||||
api_key = getpass.getpass("API key (optional, blank = use OPENAI_API_KEY): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
|
||||
_save_aux_choice(
|
||||
task, provider="custom", model=model, base_url=url, api_key=api_key,
|
||||
)
|
||||
short_url = url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
|
||||
|
||||
|
||||
def _prompt_provider_choice(choices, *, default=0):
|
||||
"""Show provider selection menu with curses arrow-key navigation.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue