Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -20,12 +20,12 @@ from agent.credential_pool import (
|
||||
STRATEGY_LEAST_USED,
|
||||
SUPPORTED_POOL_STRATEGIES,
|
||||
PooledCredential,
|
||||
_exhausted_until,
|
||||
_normalize_custom_pool_name,
|
||||
get_pool_strategy,
|
||||
label_from_token,
|
||||
list_custom_pool_providers,
|
||||
load_pool,
|
||||
_exhausted_ttl,
|
||||
)
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
@@ -113,21 +113,27 @@ def _display_source(source: str) -> str:
|
||||
def _format_exhausted_status(entry) -> str:
|
||||
if entry.last_status != STATUS_EXHAUSTED:
|
||||
return ""
|
||||
reason = getattr(entry, "last_error_reason", None)
|
||||
reason_text = f" {reason}" if isinstance(reason, str) and reason.strip() else ""
|
||||
code = f" ({entry.last_error_code})" if entry.last_error_code else ""
|
||||
if not entry.last_status_at:
|
||||
return f" exhausted{code}"
|
||||
remaining = max(0, int(math.ceil((entry.last_status_at + _exhausted_ttl(entry.last_error_code)) - time.time())))
|
||||
exhausted_until = _exhausted_until(entry)
|
||||
if exhausted_until is None:
|
||||
return f" exhausted{reason_text}{code}"
|
||||
remaining = max(0, int(math.ceil(exhausted_until - time.time())))
|
||||
if remaining <= 0:
|
||||
return f" exhausted{code} (ready to retry)"
|
||||
return f" exhausted{reason_text}{code} (ready to retry)"
|
||||
minutes, seconds = divmod(remaining, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
if hours:
|
||||
days, hours = divmod(hours, 24)
|
||||
if days:
|
||||
wait = f"{days}d {hours}h"
|
||||
elif hours:
|
||||
wait = f"{hours}h {minutes}m"
|
||||
elif minutes:
|
||||
wait = f"{minutes}m {seconds}s"
|
||||
else:
|
||||
wait = f"{seconds}s"
|
||||
return f" exhausted{code} ({wait} left)"
|
||||
return f" exhausted{reason_text}{code} ({wait} left)"
|
||||
|
||||
|
||||
def auth_add_command(args) -> None:
|
||||
@@ -277,13 +283,28 @@ def auth_list_command(args) -> None:
|
||||
|
||||
def auth_remove_command(args) -> None:
|
||||
provider = _normalize_provider(getattr(args, "provider", ""))
|
||||
index = int(getattr(args, "index"))
|
||||
target = getattr(args, "target", None)
|
||||
if target is None:
|
||||
target = getattr(args, "index", None)
|
||||
pool = load_pool(provider)
|
||||
index, matched, error = pool.resolve_target(target)
|
||||
if matched is None or index is None:
|
||||
raise SystemExit(f"{error} Provider: {provider}.")
|
||||
removed = pool.remove_index(index)
|
||||
if removed is None:
|
||||
raise SystemExit(f"No credential #{index} for provider {provider}.")
|
||||
raise SystemExit(f'No credential matching "{target}" for provider {provider}.')
|
||||
print(f"Removed {provider} credential #{index} ({removed.label})")
|
||||
|
||||
# If this was an env-seeded credential, also clear the env var from .env
|
||||
# so it doesn't get re-seeded on the next load_pool() call.
|
||||
if removed.source.startswith("env:"):
|
||||
env_var = removed.source[len("env:"):]
|
||||
if env_var:
|
||||
from hermes_cli.config import remove_env_value
|
||||
cleared = remove_env_value(env_var)
|
||||
if cleared:
|
||||
print(f"Cleared {env_var} from .env")
|
||||
|
||||
|
||||
def auth_reset_command(args) -> None:
|
||||
provider = _normalize_provider(getattr(args, "provider", ""))
|
||||
@@ -369,8 +390,16 @@ def _interactive_add() -> None:
|
||||
else:
|
||||
auth_type = "api_key"
|
||||
|
||||
label = None
|
||||
try:
|
||||
typed_label = input("Label / account name (optional): ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return
|
||||
if typed_label:
|
||||
label = typed_label
|
||||
|
||||
auth_add_command(SimpleNamespace(
|
||||
provider=provider, auth_type=auth_type, label=None, api_key=None,
|
||||
provider=provider, auth_type=auth_type, label=label, api_key=None,
|
||||
portal_url=None, inference_url=None, client_id=None, scope=None,
|
||||
no_browser=False, timeout=None, insecure=False, ca_bundle=None,
|
||||
))
|
||||
@@ -386,22 +415,16 @@ def _interactive_remove() -> None:
|
||||
# Show entries with indices
|
||||
for i, e in enumerate(pool.entries(), 1):
|
||||
exhausted = _format_exhausted_status(e)
|
||||
print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{exhausted}")
|
||||
print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{exhausted} [id:{e.id}]")
|
||||
|
||||
try:
|
||||
raw = input("Remove # (or blank to cancel): ").strip()
|
||||
raw = input("Remove #, id, or label (blank to cancel): ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return
|
||||
if not raw:
|
||||
return
|
||||
|
||||
try:
|
||||
index = int(raw)
|
||||
except ValueError:
|
||||
print("Invalid number.")
|
||||
return
|
||||
|
||||
auth_remove_command(SimpleNamespace(provider=provider, index=index))
|
||||
auth_remove_command(SimpleNamespace(provider=provider, target=raw))
|
||||
|
||||
|
||||
def _interactive_reset() -> None:
|
||||
|
||||
@@ -84,6 +84,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
# Configuration
|
||||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"),
|
||||
CommandDef("provider", "Show available providers and current provider",
|
||||
"Configuration"),
|
||||
CommandDef("prompt", "View/set custom system prompt", "Configuration",
|
||||
@@ -744,6 +745,39 @@ class SlashCommandCompleter(Completer):
|
||||
)
|
||||
count += 1
|
||||
|
||||
def _model_completions(self, sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /model from config aliases + built-in aliases."""
|
||||
seen = set()
|
||||
# Config-based direct aliases (preferred — include provider info)
|
||||
try:
|
||||
from hermes_cli.model_switch import (
|
||||
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
|
||||
)
|
||||
_ensure_direct_aliases()
|
||||
for name, da in DIRECT_ALIASES.items():
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
seen.add(name)
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=f"{da.model} ({da.provider})",
|
||||
)
|
||||
# Built-in catalog aliases not already covered
|
||||
for name in sorted(MODEL_ALIASES.keys()):
|
||||
if name in seen:
|
||||
continue
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
identity = MODEL_ALIASES[name]
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=f"{identity.vendor}/{identity.family}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text_before_cursor
|
||||
if not text.startswith("/"):
|
||||
@@ -765,6 +799,11 @@ class SlashCommandCompleter(Completer):
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
# Dynamic model alias completions for /model
|
||||
if " " not in sub_text and base_cmd == "/model":
|
||||
yield from self._model_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS:
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
|
||||
@@ -199,6 +199,7 @@ def ensure_hermes_home():
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"model": "",
|
||||
"providers": {},
|
||||
"fallback_providers": [],
|
||||
"credential_pool_strategies": {},
|
||||
"toolsets": ["hermes-cli"],
|
||||
@@ -314,7 +315,7 @@ DEFAULT_CONFIG = {
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30, # seconds — increase for slow local models
|
||||
"timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models
|
||||
},
|
||||
"compression": {
|
||||
"provider": "auto",
|
||||
@@ -531,7 +532,7 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 11,
|
||||
"_config_version": 12,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -1312,6 +1313,69 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Version 11 → 12: migrate custom_providers list → providers dict ──
|
||||
if current_ver < 12:
|
||||
config = load_config()
|
||||
custom_list = config.get("custom_providers")
|
||||
if isinstance(custom_list, list) and custom_list:
|
||||
providers_dict = config.get("providers", {})
|
||||
if not isinstance(providers_dict, dict):
|
||||
providers_dict = {}
|
||||
migrated_count = 0
|
||||
for entry in custom_list:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
old_name = entry.get("name", "")
|
||||
old_url = entry.get("base_url", "") or entry.get("url", "") or ""
|
||||
old_key = entry.get("api_key", "")
|
||||
if not old_url:
|
||||
continue # skip entries with no URL
|
||||
|
||||
# Generate a kebab-case key from the display name
|
||||
key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "")
|
||||
# Remove consecutive hyphens and trailing hyphens
|
||||
while "--" in key:
|
||||
key = key.replace("--", "-")
|
||||
key = key.strip("-")
|
||||
if not key:
|
||||
# Fallback: derive from URL hostname
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(old_url)
|
||||
key = (parsed.hostname or "endpoint").replace(".", "-")
|
||||
except Exception:
|
||||
key = f"endpoint-{migrated_count}"
|
||||
|
||||
# Don't overwrite existing entries
|
||||
if key in providers_dict:
|
||||
key = f"{key}-{migrated_count}"
|
||||
|
||||
new_entry = {"api": old_url}
|
||||
if old_name:
|
||||
new_entry["name"] = old_name
|
||||
if old_key and old_key not in ("no-key", "no-key-required", ""):
|
||||
new_entry["api_key"] = old_key
|
||||
|
||||
# Carry over model and api_mode if present
|
||||
if entry.get("model"):
|
||||
new_entry["default_model"] = entry["model"]
|
||||
if entry.get("api_mode"):
|
||||
new_entry["transport"] = entry["api_mode"]
|
||||
|
||||
providers_dict[key] = new_entry
|
||||
migrated_count += 1
|
||||
|
||||
if migrated_count > 0:
|
||||
config["providers"] = providers_dict
|
||||
# Remove the old list
|
||||
del config["custom_providers"]
|
||||
save_config(config)
|
||||
if not quiet:
|
||||
print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section")
|
||||
for key in list(providers_dict.keys())[-migrated_count:]:
|
||||
ep = providers_dict[key]
|
||||
print(f" → {key}: {ep.get('api', '')}")
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
@@ -1836,6 +1900,51 @@ def save_env_value(key: str, value: str):
|
||||
pass
|
||||
|
||||
|
||||
def remove_env_value(key: str) -> bool:
|
||||
"""Remove a key from ~/.hermes/.env and os.environ.
|
||||
|
||||
Returns True if the key was found and removed, False otherwise.
|
||||
"""
|
||||
if is_managed():
|
||||
managed_error(f"remove {key}")
|
||||
return False
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||
env_path = get_env_path()
|
||||
if not env_path.exists():
|
||||
os.environ.pop(key, None)
|
||||
return False
|
||||
|
||||
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
|
||||
write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
|
||||
|
||||
with open(env_path, **read_kw) as f:
|
||||
lines = f.readlines()
|
||||
lines = _sanitize_env_lines(lines)
|
||||
|
||||
new_lines = [line for line in lines if not line.strip().startswith(f"{key}=")]
|
||||
found = len(new_lines) < len(lines)
|
||||
|
||||
if found:
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', **write_kw) as f:
|
||||
f.writelines(new_lines)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_secure_file(env_path)
|
||||
|
||||
os.environ.pop(key, None)
|
||||
return found
|
||||
|
||||
|
||||
def save_anthropic_oauth_token(value: str, save_fn=None):
|
||||
"""Persist an Anthropic OAuth/setup token and clear the API-key slot."""
|
||||
writer = save_fn or save_env_value
|
||||
|
||||
@@ -873,6 +873,11 @@ def get_launchd_label() -> str:
|
||||
return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
|
||||
|
||||
def _launchd_domain() -> str:
|
||||
import os
|
||||
return f"gui/{os.getuid()}"
|
||||
|
||||
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
@@ -963,18 +968,19 @@ def launchd_plist_is_current() -> bool:
|
||||
def refresh_launchd_plist_if_needed() -> bool:
|
||||
"""Rewrite the installed launchd plist when the generated definition has changed.
|
||||
|
||||
Unlike systemd, launchd picks up plist changes on the next ``launchctl stop``/
|
||||
``launchctl start`` cycle — no daemon-reload is needed. We still unload/reload
|
||||
to make launchd re-read the updated plist immediately.
|
||||
Unlike systemd, launchd picks up plist changes on the next ``launchctl kill``/
|
||||
``launchctl kickstart`` cycle — no daemon-reload is needed. We still bootout/
|
||||
bootstrap to make launchd re-read the updated plist immediately.
|
||||
"""
|
||||
plist_path = get_launchd_plist_path()
|
||||
if not plist_path.exists() or launchd_plist_is_current():
|
||||
return False
|
||||
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
# Unload/reload so launchd picks up the new definition
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)], check=False)
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=False)
|
||||
label = get_launchd_label()
|
||||
# Bootout/bootstrap so launchd picks up the new definition
|
||||
subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False)
|
||||
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=False)
|
||||
print("↻ Updated gateway launchd service definition to match the current Hermes install")
|
||||
return True
|
||||
|
||||
@@ -996,7 +1002,7 @@ def launchd_install(force: bool = False):
|
||||
print(f"Installing launchd service to: {plist_path}")
|
||||
plist_path.write_text(generate_launchd_plist())
|
||||
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True)
|
||||
|
||||
print()
|
||||
print("✓ Service installed and loaded!")
|
||||
@@ -1008,7 +1014,8 @@ def launchd_install(force: bool = False):
|
||||
|
||||
def launchd_uninstall():
|
||||
plist_path = get_launchd_plist_path()
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)], check=False)
|
||||
label = get_launchd_label()
|
||||
subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False)
|
||||
|
||||
if plist_path.exists():
|
||||
plist_path.unlink()
|
||||
@@ -1025,25 +1032,25 @@ def launchd_start():
|
||||
print("↻ launchd plist missing; regenerating service definition")
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "start", label], check=True)
|
||||
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True)
|
||||
print("✓ Service started")
|
||||
return
|
||||
|
||||
refresh_launchd_plist_if_needed()
|
||||
try:
|
||||
subprocess.run(["launchctl", "start", label], check=True)
|
||||
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode != 3:
|
||||
raise
|
||||
print("↻ launchd job was unloaded; reloading service definition")
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "start", label], check=True)
|
||||
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True)
|
||||
print("✓ Service started")
|
||||
|
||||
def launchd_stop():
|
||||
label = get_launchd_label()
|
||||
subprocess.run(["launchctl", "stop", label], check=True)
|
||||
subprocess.run(["launchctl", "kill", "SIGTERM", f"{_launchd_domain()}/{label}"], check=True)
|
||||
print("✓ Service stopped")
|
||||
|
||||
def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
|
||||
@@ -1087,14 +1094,23 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
|
||||
|
||||
|
||||
def launchd_restart():
|
||||
label = get_launchd_label()
|
||||
target = f"{_launchd_domain()}/{label}"
|
||||
# Use kickstart -k so launchd performs an atomic kill+restart.
|
||||
# A two-step stop/start from inside the gateway's own process tree
|
||||
# would kill the shell before the start command is reached.
|
||||
try:
|
||||
launchd_stop()
|
||||
subprocess.run(["launchctl", "kickstart", "-k", target], check=True)
|
||||
print("✓ Service restarted")
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode != 3:
|
||||
raise
|
||||
print("↻ launchd job was unloaded; skipping stop")
|
||||
_wait_for_gateway_exit()
|
||||
launchd_start()
|
||||
# Job not loaded — bootstrap and start fresh
|
||||
print("↻ launchd job was unloaded; reloading")
|
||||
plist_path = get_launchd_plist_path()
|
||||
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "kickstart", target], check=True)
|
||||
print("✓ Service restarted")
|
||||
|
||||
def launchd_status(deep: bool = False):
|
||||
plist_path = get_launchd_plist_path()
|
||||
|
||||
@@ -2554,6 +2554,57 @@ def _clear_bytecode_cache(root: Path) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) -> str:
|
||||
"""File-based IPC prompt for gateway mode.
|
||||
|
||||
Writes a prompt marker file so the gateway can forward the question to the
|
||||
user, then polls for a response file. Falls back to *default* on timeout.
|
||||
|
||||
Used by ``hermes update --gateway`` so interactive prompts (stash restore,
|
||||
config migration) are forwarded to the messenger instead of being silently
|
||||
skipped.
|
||||
"""
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
home = get_hermes_home()
|
||||
prompt_path = home / ".update_prompt.json"
|
||||
response_path = home / ".update_response"
|
||||
|
||||
# Clean any stale response file
|
||||
response_path.unlink(missing_ok=True)
|
||||
|
||||
payload = {
|
||||
"prompt": prompt_text,
|
||||
"default": default,
|
||||
"id": str(_uuid.uuid4()),
|
||||
}
|
||||
tmp = prompt_path.with_suffix(".tmp")
|
||||
tmp.write_text(_json.dumps(payload))
|
||||
tmp.replace(prompt_path)
|
||||
|
||||
# Poll for response
|
||||
import time as _time
|
||||
deadline = _time.monotonic() + timeout
|
||||
while _time.monotonic() < deadline:
|
||||
if response_path.exists():
|
||||
try:
|
||||
answer = response_path.read_text().strip()
|
||||
response_path.unlink(missing_ok=True)
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
return answer if answer else default
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
_time.sleep(0.5)
|
||||
|
||||
# Timeout — clean up and use default
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
response_path.unlink(missing_ok=True)
|
||||
print(f" (no response after {int(timeout)}s, using default: {default!r})")
|
||||
return default
|
||||
|
||||
|
||||
def _update_via_zip(args):
|
||||
"""Update Hermes Agent by downloading a ZIP archive.
|
||||
|
||||
@@ -2747,6 +2798,7 @@ def _restore_stashed_changes(
|
||||
cwd: Path,
|
||||
stash_ref: str,
|
||||
prompt_user: bool = False,
|
||||
input_fn=None,
|
||||
) -> bool:
|
||||
if prompt_user:
|
||||
print()
|
||||
@@ -2754,7 +2806,10 @@ def _restore_stashed_changes(
|
||||
print(" Restoring them may reapply local customizations onto the updated codebase.")
|
||||
print(" Review the result afterward if Hermes behaves unexpectedly.")
|
||||
print("Restore local changes now? [Y/n]")
|
||||
response = input().strip().lower()
|
||||
if input_fn is not None:
|
||||
response = input_fn("Restore local changes now? [Y/n]", "y")
|
||||
else:
|
||||
response = input().strip().lower()
|
||||
if response not in ("", "y", "yes"):
|
||||
print("Skipped restoring local changes.")
|
||||
print("Your changes are still preserved in git stash.")
|
||||
@@ -3185,6 +3240,10 @@ def cmd_update(args):
|
||||
if is_managed():
|
||||
managed_error("update Hermes Agent")
|
||||
return
|
||||
|
||||
gateway_mode = getattr(args, "gateway", False)
|
||||
# In gateway mode, use file-based IPC for prompts instead of stdin
|
||||
gw_input_fn = (lambda prompt, default="": _gateway_prompt(prompt, default)) if gateway_mode else None
|
||||
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
@@ -3281,7 +3340,9 @@ def cmd_update(args):
|
||||
else:
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
|
||||
prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()
|
||||
prompt_for_restore = auto_stash_ref is not None and (
|
||||
gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())
|
||||
)
|
||||
|
||||
# Check if there are updates
|
||||
result = subprocess.run(
|
||||
@@ -3300,6 +3361,7 @@ def cmd_update(args):
|
||||
_restore_stashed_changes(
|
||||
git_cmd, PROJECT_ROOT, auto_stash_ref,
|
||||
prompt_user=prompt_for_restore,
|
||||
input_fn=gw_input_fn,
|
||||
)
|
||||
if current_branch not in ("main", "HEAD"):
|
||||
subprocess.run(
|
||||
@@ -3351,6 +3413,7 @@ def cmd_update(args):
|
||||
PROJECT_ROOT,
|
||||
auto_stash_ref,
|
||||
prompt_user=prompt_for_restore,
|
||||
input_fn=gw_input_fn,
|
||||
)
|
||||
|
||||
_invalidate_update_cache()
|
||||
@@ -3490,7 +3553,11 @@ def cmd_update(args):
|
||||
print(f" ℹ️ {len(missing_config)} new config option(s) available")
|
||||
|
||||
print()
|
||||
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
if gateway_mode:
|
||||
response = _gateway_prompt(
|
||||
"Would you like to configure new options now? [Y/n]", "n"
|
||||
).strip().lower()
|
||||
elif not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
print(" ℹ Non-interactive session — skipping config migration prompt.")
|
||||
print(" Run 'hermes config migrate' later to apply any new config/env options.")
|
||||
response = "n"
|
||||
@@ -3502,11 +3569,15 @@ def cmd_update(args):
|
||||
|
||||
if response in ('', 'y', 'yes'):
|
||||
print()
|
||||
results = migrate_config(interactive=True, quiet=False)
|
||||
# In gateway mode, run auto-migrations only (no input() prompts
|
||||
# for API keys which would hang the detached process).
|
||||
results = migrate_config(interactive=not gateway_mode, quiet=False)
|
||||
|
||||
if results["env_added"] or results["config_added"]:
|
||||
print()
|
||||
print("✓ Configuration updated!")
|
||||
if gateway_mode and missing_env:
|
||||
print(" ℹ API keys require manual entry: hermes config migrate")
|
||||
else:
|
||||
print()
|
||||
print("Skipped. Run 'hermes config migrate' later to configure.")
|
||||
@@ -3943,7 +4014,7 @@ Examples:
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <n> Remove pooled credential by index
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes config View configuration
|
||||
@@ -4033,7 +4104,7 @@ For more help on a command:
|
||||
chat_parser.add_argument(
|
||||
"-s", "--skills",
|
||||
action="append",
|
||||
default=None,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
@@ -4055,6 +4126,7 @@ For more help on a command:
|
||||
chat_parser.add_argument(
|
||||
"--resume", "-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
@@ -4062,14 +4134,14 @@ For more help on a command:
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree", "-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
@@ -4088,13 +4160,13 @@ For more help on a command:
|
||||
chat_parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
@@ -4332,9 +4404,9 @@ For more help on a command:
|
||||
auth_add.add_argument("--ca-bundle", help="Custom CA bundle for OAuth login")
|
||||
auth_list = auth_subparsers.add_parser("list", help="List pooled credentials")
|
||||
auth_list.add_argument("provider", nargs="?", help="Optional provider filter")
|
||||
auth_remove = auth_subparsers.add_parser("remove", help="Remove a pooled credential by index")
|
||||
auth_remove = auth_subparsers.add_parser("remove", help="Remove a pooled credential by index, id, or label")
|
||||
auth_remove.add_argument("provider", help="Provider id")
|
||||
auth_remove.add_argument("index", type=int, help="1-based credential index")
|
||||
auth_remove.add_argument("target", help="Credential index, entry id, or exact label")
|
||||
auth_reset = auth_subparsers.add_parser("reset", help="Clear exhaustion status for all credentials for a provider")
|
||||
auth_reset.add_argument("provider", help="Provider id")
|
||||
auth_parser.set_defaults(func=cmd_auth)
|
||||
@@ -4660,106 +4732,23 @@ For more help on a command:
|
||||
plugins_parser.set_defaults(func=cmd_plugins)
|
||||
|
||||
# =========================================================================
|
||||
# honcho command — Honcho-specific config (peer, mode, tokens, profiles)
|
||||
# Provider selection happens via 'hermes memory setup'.
|
||||
# Plugin CLI commands — dynamically registered by memory/general plugins.
|
||||
# Plugins provide a register_cli(subparser) function that builds their
|
||||
# own argparse tree. No hardcoded plugin commands in main.py.
|
||||
# =========================================================================
|
||||
honcho_parser = subparsers.add_parser(
|
||||
"honcho",
|
||||
help="Manage Honcho memory provider config (peer, mode, profiles)",
|
||||
description=(
|
||||
"Configure Honcho-specific settings. Honcho is now a memory provider\n"
|
||||
"plugin — initial setup is via 'hermes memory setup'. These commands\n"
|
||||
"manage Honcho's own config: peer names, memory mode, token budgets,\n"
|
||||
"per-profile host blocks, and cross-profile observability."
|
||||
),
|
||||
formatter_class=__import__("argparse").RawDescriptionHelpFormatter,
|
||||
)
|
||||
honcho_parser.add_argument(
|
||||
"--target-profile", metavar="NAME", dest="target_profile",
|
||||
help="Target a specific profile's Honcho config without switching",
|
||||
)
|
||||
honcho_subparsers = honcho_parser.add_subparsers(dest="honcho_command")
|
||||
|
||||
honcho_subparsers.add_parser("setup", help="Initial Honcho setup (redirects to hermes memory setup)")
|
||||
honcho_status = honcho_subparsers.add_parser("status", help="Show current Honcho config and connection status")
|
||||
honcho_status.add_argument("--all", action="store_true", help="Show config overview across all profiles")
|
||||
honcho_subparsers.add_parser("peers", help="Show peer identities across all profiles")
|
||||
honcho_subparsers.add_parser("sessions", help="List known Honcho session mappings")
|
||||
|
||||
honcho_map = honcho_subparsers.add_parser(
|
||||
"map", help="Map current directory to a Honcho session name (no arg = list mappings)"
|
||||
)
|
||||
honcho_map.add_argument(
|
||||
"session_name", nargs="?", default=None,
|
||||
help="Session name to associate with this directory. Omit to list current mappings.",
|
||||
)
|
||||
|
||||
honcho_peer = honcho_subparsers.add_parser(
|
||||
"peer", help="Show or update peer names and dialectic reasoning level"
|
||||
)
|
||||
honcho_peer.add_argument("--user", metavar="NAME", help="Set user peer name")
|
||||
honcho_peer.add_argument("--ai", metavar="NAME", help="Set AI peer name")
|
||||
honcho_peer.add_argument(
|
||||
"--reasoning",
|
||||
metavar="LEVEL",
|
||||
choices=("minimal", "low", "medium", "high", "max"),
|
||||
help="Set default dialectic reasoning level (minimal/low/medium/high/max)",
|
||||
)
|
||||
|
||||
honcho_mode = honcho_subparsers.add_parser(
|
||||
"mode", help="Show or set memory mode (hybrid/honcho/local)"
|
||||
)
|
||||
honcho_mode.add_argument(
|
||||
"mode", nargs="?", metavar="MODE",
|
||||
choices=("hybrid", "honcho", "local"),
|
||||
help="Memory mode to set (hybrid/honcho/local). Omit to show current.",
|
||||
)
|
||||
|
||||
honcho_tokens = honcho_subparsers.add_parser(
|
||||
"tokens", help="Show or set token budget for context and dialectic"
|
||||
)
|
||||
honcho_tokens.add_argument(
|
||||
"--context", type=int, metavar="N",
|
||||
help="Max tokens Honcho returns from session.context() per turn",
|
||||
)
|
||||
honcho_tokens.add_argument(
|
||||
"--dialectic", type=int, metavar="N",
|
||||
help="Max chars of dialectic result to inject into system prompt",
|
||||
)
|
||||
|
||||
honcho_identity = honcho_subparsers.add_parser(
|
||||
"identity", help="Seed or show the AI peer's Honcho identity representation"
|
||||
)
|
||||
honcho_identity.add_argument(
|
||||
"file", nargs="?", default=None,
|
||||
help="Path to file to seed from (e.g. SOUL.md). Omit to show usage.",
|
||||
)
|
||||
honcho_identity.add_argument(
|
||||
"--show", action="store_true",
|
||||
help="Show current AI peer representation from Honcho",
|
||||
)
|
||||
|
||||
honcho_subparsers.add_parser(
|
||||
"migrate",
|
||||
help="Step-by-step migration guide from openclaw-honcho to Hermes Honcho",
|
||||
)
|
||||
honcho_subparsers.add_parser("enable", help="Enable Honcho for the active profile")
|
||||
honcho_subparsers.add_parser("disable", help="Disable Honcho for the active profile")
|
||||
honcho_subparsers.add_parser("sync", help="Sync Honcho config to all existing profiles")
|
||||
|
||||
def cmd_honcho(args):
|
||||
sub = getattr(args, "honcho_command", None)
|
||||
if sub == "setup":
|
||||
# Redirect to the generic memory setup
|
||||
print("\n Honcho is now configured via the memory provider system.")
|
||||
print(" Running 'hermes memory setup'...\n")
|
||||
from hermes_cli.memory_setup import memory_command
|
||||
memory_command(args)
|
||||
return
|
||||
from plugins.memory.honcho.cli import honcho_command
|
||||
honcho_command(args)
|
||||
|
||||
honcho_parser.set_defaults(func=cmd_honcho)
|
||||
try:
|
||||
from plugins.memory import discover_plugin_cli_commands
|
||||
for cmd_info in discover_plugin_cli_commands():
|
||||
plugin_parser = subparsers.add_parser(
|
||||
cmd_info["name"],
|
||||
help=cmd_info["help"],
|
||||
description=cmd_info.get("description", ""),
|
||||
formatter_class=__import__("argparse").RawDescriptionHelpFormatter,
|
||||
)
|
||||
cmd_info["setup_fn"](plugin_parser)
|
||||
except Exception as _exc:
|
||||
import logging as _log
|
||||
_log.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc)
|
||||
|
||||
# =========================================================================
|
||||
# memory command
|
||||
@@ -5246,6 +5235,10 @@ For more help on a command:
|
||||
help="Update Hermes Agent to the latest version",
|
||||
description="Pull the latest changes from git and reinstall dependencies"
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--gateway", action="store_true", default=False,
|
||||
help="Gateway mode: use file-based IPC for prompts instead of stdin (used internally by /update)"
|
||||
)
|
||||
update_parser.set_defaults(func=cmd_update)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -229,15 +229,19 @@ def _get_available_providers() -> list:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
# Override description with setup hint
|
||||
|
||||
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
|
||||
has_secrets = any(f.get("secret") for f in schema)
|
||||
if has_secrets:
|
||||
has_non_secrets = any(not f.get("secret") for f in schema)
|
||||
if has_secrets and has_non_secrets:
|
||||
setup_hint = "API key / local"
|
||||
elif has_secrets:
|
||||
setup_hint = "requires API key"
|
||||
elif not schema:
|
||||
setup_hint = "no setup needed"
|
||||
else:
|
||||
setup_hint = "local"
|
||||
|
||||
results.append((name, setup_hint, provider))
|
||||
return results
|
||||
|
||||
@@ -246,6 +250,42 @@ def _get_available_providers() -> list:
|
||||
# Setup wizard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_setup_provider(provider_name: str) -> None:
|
||||
"""Run memory setup for a specific provider, skipping the picker."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
providers = _get_available_providers()
|
||||
match = None
|
||||
for name, desc, provider in providers:
|
||||
if name == provider_name:
|
||||
match = (name, desc, provider)
|
||||
break
|
||||
|
||||
if not match:
|
||||
print(f"\n Memory provider '{provider_name}' not found.")
|
||||
print(" Run 'hermes memory setup' to see available providers.\n")
|
||||
return
|
||||
|
||||
name, _, provider = match
|
||||
|
||||
_install_dependencies(name)
|
||||
|
||||
config = load_config()
|
||||
if not isinstance(config.get("memory"), dict):
|
||||
config["memory"] = {}
|
||||
|
||||
if hasattr(provider, "post_setup"):
|
||||
hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))))
|
||||
provider.post_setup(hermes_home, config)
|
||||
return
|
||||
|
||||
# Fallback: generic schema-based setup (same as cmd_setup)
|
||||
config["memory"]["provider"] = name
|
||||
save_config(config)
|
||||
print(f"\n Memory provider: {name}")
|
||||
print(f" Activation saved to config.yaml\n")
|
||||
|
||||
|
||||
def cmd_setup(args) -> None:
|
||||
"""Interactive memory provider setup wizard."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
@@ -283,6 +323,13 @@ def cmd_setup(args) -> None:
|
||||
# Install pip dependencies if declared in plugin.yaml
|
||||
_install_dependencies(name)
|
||||
|
||||
# If the provider has a post_setup hook, delegate entirely to it.
|
||||
# The hook handles its own config, connection test, and activation.
|
||||
if hasattr(provider, "post_setup"):
|
||||
hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))))
|
||||
provider.post_setup(hermes_home, config)
|
||||
return
|
||||
|
||||
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
|
||||
|
||||
provider_config = config["memory"].get(name, {})
|
||||
@@ -358,18 +405,18 @@ def cmd_setup(args) -> None:
|
||||
try:
|
||||
provider.save_config(provider_config, hermes_home)
|
||||
except Exception as e:
|
||||
print(f" ⚠ Failed to write provider config: {e}")
|
||||
print(f" Failed to write provider config: {e}")
|
||||
|
||||
# Write secrets to .env
|
||||
if env_writes:
|
||||
_write_env_vars(env_path, env_writes)
|
||||
|
||||
print(f"\n ✓ Memory provider: {name}")
|
||||
print(f" ✓ Activation saved to config.yaml")
|
||||
print(f"\n Memory provider: {name}")
|
||||
print(f" Activation saved to config.yaml")
|
||||
if provider_config:
|
||||
print(f" ✓ Provider config saved")
|
||||
print(f" Provider config saved")
|
||||
if env_writes:
|
||||
print(f" ✓ API keys saved to .env")
|
||||
print(f" API keys saved to .env")
|
||||
print(f"\n Start a new session to activate.\n")
|
||||
|
||||
|
||||
|
||||
359
hermes_cli/model_normalize.py
Normal file
359
hermes_cli/model_normalize.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""Per-provider model name normalization.
|
||||
|
||||
Different LLM providers expect model identifiers in different formats:
|
||||
|
||||
- **Aggregators** (OpenRouter, Nous, AI Gateway, Kilo Code) need
|
||||
``vendor/model`` slugs like ``anthropic/claude-sonnet-4.6``.
|
||||
- **Anthropic** native API expects bare names with dots replaced by
|
||||
hyphens: ``claude-sonnet-4-6``.
|
||||
- **Copilot** expects bare names *with* dots preserved:
|
||||
``claude-sonnet-4.6``.
|
||||
- **OpenCode** (Zen & Go) follows the same dot-to-hyphen convention as
|
||||
Anthropic: ``claude-sonnet-4-6``.
|
||||
- **DeepSeek** only accepts two model identifiers:
|
||||
``deepseek-chat`` and ``deepseek-reasoner``.
|
||||
- **Custom** and remaining providers pass the name through as-is.
|
||||
|
||||
This module centralises that translation so callers can simply write::
|
||||
|
||||
api_model = normalize_model_for_provider(user_input, provider)
|
||||
|
||||
Inspired by Clawdbot's ``normalizeAnthropicModelId`` pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vendor prefix mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps the first hyphen-delimited token of a bare model name to the vendor
|
||||
# slug used by aggregator APIs (OpenRouter, Nous, etc.).
|
||||
#
|
||||
# Example: "claude-sonnet-4.6" -> first token "claude" -> vendor "anthropic"
|
||||
# -> aggregator slug: "anthropic/claude-sonnet-4.6"
|
||||
|
||||
_VENDOR_PREFIXES: dict[str, str] = {
|
||||
"claude": "anthropic",
|
||||
"gpt": "openai",
|
||||
"o1": "openai",
|
||||
"o3": "openai",
|
||||
"o4": "openai",
|
||||
"gemini": "google",
|
||||
"deepseek": "deepseek",
|
||||
"glm": "z-ai",
|
||||
"kimi": "moonshotai",
|
||||
"minimax": "minimax",
|
||||
"grok": "x-ai",
|
||||
"qwen": "qwen",
|
||||
"mimo": "xiaomi",
|
||||
"nemotron": "nvidia",
|
||||
"llama": "meta-llama",
|
||||
"step": "stepfun",
|
||||
"trinity": "arcee-ai",
|
||||
}
|
||||
|
||||
# Providers whose APIs consume vendor/model slugs.
|
||||
_AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({
|
||||
"openrouter",
|
||||
"nous",
|
||||
"ai-gateway",
|
||||
"kilocode",
|
||||
})
|
||||
|
||||
# Providers that want bare names with dots replaced by hyphens.
|
||||
_DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({
|
||||
"anthropic",
|
||||
"opencode-zen",
|
||||
"opencode-go",
|
||||
})
|
||||
|
||||
# Providers that want bare names with dots preserved.
|
||||
_STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
|
||||
"copilot",
|
||||
"copilot-acp",
|
||||
})
|
||||
|
||||
# Providers whose own naming is authoritative -- pass through unchanged.
|
||||
_PASSTHROUGH_PROVIDERS: frozenset[str] = frozenset({
|
||||
"zai",
|
||||
"kimi-coding",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"alibaba",
|
||||
"huggingface",
|
||||
"openai-codex",
|
||||
"custom",
|
||||
})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeepSeek special handling
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeepSeek's API only recognises exactly two model identifiers. We map
|
||||
# common aliases and patterns to the canonical names.
|
||||
|
||||
_DEEPSEEK_REASONER_KEYWORDS: frozenset[str] = frozenset({
|
||||
"reasoner",
|
||||
"r1",
|
||||
"think",
|
||||
"reasoning",
|
||||
"cot",
|
||||
})
|
||||
|
||||
_DEEPSEEK_CANONICAL_MODELS: frozenset[str] = frozenset({
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
})
|
||||
|
||||
|
||||
def _normalize_for_deepseek(model_name: str) -> str:
|
||||
"""Map any model input to one of DeepSeek's two accepted identifiers.
|
||||
|
||||
Rules:
|
||||
- Already ``deepseek-chat`` or ``deepseek-reasoner`` -> pass through.
|
||||
- Contains any reasoner keyword (r1, think, reasoning, cot, reasoner)
|
||||
-> ``deepseek-reasoner``.
|
||||
- Everything else -> ``deepseek-chat``.
|
||||
|
||||
Args:
|
||||
model_name: The bare model name (vendor prefix already stripped).
|
||||
|
||||
Returns:
|
||||
One of ``"deepseek-chat"`` or ``"deepseek-reasoner"``.
|
||||
"""
|
||||
bare = _strip_vendor_prefix(model_name).lower()
|
||||
|
||||
if bare in _DEEPSEEK_CANONICAL_MODELS:
|
||||
return bare
|
||||
|
||||
# Check for reasoner-like keywords anywhere in the name
|
||||
for keyword in _DEEPSEEK_REASONER_KEYWORDS:
|
||||
if keyword in bare:
|
||||
return "deepseek-reasoner"
|
||||
|
||||
return "deepseek-chat"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _strip_vendor_prefix(model_name: str) -> str:
|
||||
"""Remove a ``vendor/`` prefix if present.
|
||||
|
||||
Examples::
|
||||
|
||||
>>> _strip_vendor_prefix("anthropic/claude-sonnet-4.6")
|
||||
'claude-sonnet-4.6'
|
||||
>>> _strip_vendor_prefix("claude-sonnet-4.6")
|
||||
'claude-sonnet-4.6'
|
||||
>>> _strip_vendor_prefix("meta-llama/llama-4-scout")
|
||||
'llama-4-scout'
|
||||
"""
|
||||
if "/" in model_name:
|
||||
return model_name.split("/", 1)[1]
|
||||
return model_name
|
||||
|
||||
|
||||
def _dots_to_hyphens(model_name: str) -> str:
|
||||
"""Replace dots with hyphens in a model name.
|
||||
|
||||
Anthropic's native API uses hyphens where marketing names use dots:
|
||||
``claude-sonnet-4.6`` -> ``claude-sonnet-4-6``.
|
||||
"""
|
||||
return model_name.replace(".", "-")
|
||||
|
||||
|
||||
def detect_vendor(model_name: str) -> Optional[str]:
|
||||
"""Detect the vendor slug from a bare model name.
|
||||
|
||||
Uses the first hyphen-delimited token of the model name to look up
|
||||
the corresponding vendor in ``_VENDOR_PREFIXES``. Also handles
|
||||
case-insensitive matching and special patterns.
|
||||
|
||||
Args:
|
||||
model_name: A model name, optionally already including a
|
||||
``vendor/`` prefix. If a prefix is present it is used
|
||||
directly.
|
||||
|
||||
Returns:
|
||||
The vendor slug (e.g. ``"anthropic"``, ``"openai"``) or ``None``
|
||||
if no vendor can be confidently detected.
|
||||
|
||||
Examples::
|
||||
|
||||
>>> detect_vendor("claude-sonnet-4.6")
|
||||
'anthropic'
|
||||
>>> detect_vendor("gpt-5.4-mini")
|
||||
'openai'
|
||||
>>> detect_vendor("anthropic/claude-sonnet-4.6")
|
||||
'anthropic'
|
||||
>>> detect_vendor("my-custom-model")
|
||||
"""
|
||||
name = model_name.strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# If there's already a vendor/ prefix, extract it
|
||||
if "/" in name:
|
||||
return name.split("/", 1)[0].lower() or None
|
||||
|
||||
name_lower = name.lower()
|
||||
|
||||
# Try first hyphen-delimited token (exact match)
|
||||
first_token = name_lower.split("-")[0]
|
||||
if first_token in _VENDOR_PREFIXES:
|
||||
return _VENDOR_PREFIXES[first_token]
|
||||
|
||||
# Handle patterns where the first token includes version digits,
|
||||
# e.g. "qwen3.5-plus" -> first token "qwen3.5", but prefix is "qwen"
|
||||
for prefix, vendor in _VENDOR_PREFIXES.items():
|
||||
if name_lower.startswith(prefix):
|
||||
return vendor
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _prepend_vendor(model_name: str) -> str:
|
||||
"""Prepend the detected ``vendor/`` prefix if missing.
|
||||
|
||||
Used for aggregator providers that require ``vendor/model`` format.
|
||||
If the name already contains a ``/``, it is returned as-is.
|
||||
If no vendor can be detected, the name is returned unchanged
|
||||
(aggregators may still accept it or return an error).
|
||||
|
||||
Examples::
|
||||
|
||||
>>> _prepend_vendor("claude-sonnet-4.6")
|
||||
'anthropic/claude-sonnet-4.6'
|
||||
>>> _prepend_vendor("anthropic/claude-sonnet-4.6")
|
||||
'anthropic/claude-sonnet-4.6'
|
||||
>>> _prepend_vendor("my-custom-thing")
|
||||
'my-custom-thing'
|
||||
"""
|
||||
if "/" in model_name:
|
||||
return model_name
|
||||
|
||||
vendor = detect_vendor(model_name)
|
||||
if vendor:
|
||||
return f"{vendor}/{model_name}"
|
||||
return model_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main normalisation entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
|
||||
"""Translate a model name into the format the target provider's API expects.
|
||||
|
||||
This is the primary entry point for model name normalisation. It
|
||||
accepts any user-facing model identifier and transforms it for the
|
||||
specific provider that will receive the API call.
|
||||
|
||||
Args:
|
||||
model_input: The model name as provided by the user or config.
|
||||
Can be bare (``"claude-sonnet-4.6"``), vendor-prefixed
|
||||
(``"anthropic/claude-sonnet-4.6"``), or already in native
|
||||
format (``"claude-sonnet-4-6"``).
|
||||
target_provider: The canonical Hermes provider id, e.g.
|
||||
``"openrouter"``, ``"anthropic"``, ``"copilot"``,
|
||||
``"deepseek"``, ``"custom"``. Should already be normalised
|
||||
via ``hermes_cli.models.normalize_provider()``.
|
||||
|
||||
Returns:
|
||||
The model identifier string that the target provider's API
|
||||
expects.
|
||||
|
||||
Raises:
|
||||
No exceptions -- always returns a best-effort string.
|
||||
|
||||
Examples::
|
||||
|
||||
>>> normalize_model_for_provider("claude-sonnet-4.6", "openrouter")
|
||||
'anthropic/claude-sonnet-4.6'
|
||||
|
||||
>>> normalize_model_for_provider("anthropic/claude-sonnet-4.6", "anthropic")
|
||||
'claude-sonnet-4-6'
|
||||
|
||||
>>> normalize_model_for_provider("anthropic/claude-sonnet-4.6", "copilot")
|
||||
'claude-sonnet-4.6'
|
||||
|
||||
>>> normalize_model_for_provider("openai/gpt-5.4", "copilot")
|
||||
'gpt-5.4'
|
||||
|
||||
>>> normalize_model_for_provider("claude-sonnet-4.6", "opencode-zen")
|
||||
'claude-sonnet-4-6'
|
||||
|
||||
>>> normalize_model_for_provider("deepseek-v3", "deepseek")
|
||||
'deepseek-chat'
|
||||
|
||||
>>> normalize_model_for_provider("deepseek-r1", "deepseek")
|
||||
'deepseek-reasoner'
|
||||
|
||||
>>> normalize_model_for_provider("my-model", "custom")
|
||||
'my-model'
|
||||
|
||||
>>> normalize_model_for_provider("claude-sonnet-4.6", "zai")
|
||||
'claude-sonnet-4.6'
|
||||
"""
|
||||
name = (model_input or "").strip()
|
||||
if not name:
|
||||
return name
|
||||
|
||||
provider = (target_provider or "").strip().lower()
|
||||
|
||||
# --- Aggregators: need vendor/model format ---
|
||||
if provider in _AGGREGATOR_PROVIDERS:
|
||||
return _prepend_vendor(name)
|
||||
|
||||
# --- Anthropic / OpenCode: strip vendor, dots -> hyphens ---
|
||||
if provider in _DOT_TO_HYPHEN_PROVIDERS:
|
||||
bare = _strip_vendor_prefix(name)
|
||||
return _dots_to_hyphens(bare)
|
||||
|
||||
# --- Copilot: strip vendor, keep dots ---
|
||||
if provider in _STRIP_VENDOR_ONLY_PROVIDERS:
|
||||
return _strip_vendor_prefix(name)
|
||||
|
||||
# --- DeepSeek: map to one of two canonical names ---
|
||||
if provider == "deepseek":
|
||||
return _normalize_for_deepseek(name)
|
||||
|
||||
# --- Custom & all others: pass through as-is ---
|
||||
return name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch / convenience helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def model_display_name(model_id: str) -> str:
|
||||
"""Return a short, human-readable display name for a model id.
|
||||
|
||||
Strips the vendor prefix (if any) for a cleaner display in menus
|
||||
and status bars, while preserving dots for readability.
|
||||
|
||||
Examples::
|
||||
|
||||
>>> model_display_name("anthropic/claude-sonnet-4.6")
|
||||
'claude-sonnet-4.6'
|
||||
>>> model_display_name("claude-sonnet-4-6")
|
||||
'claude-sonnet-4-6'
|
||||
"""
|
||||
return _strip_vendor_prefix((model_id or "").strip())
|
||||
|
||||
|
||||
def is_aggregator_provider(provider: str) -> bool:
|
||||
"""Check if a provider is an aggregator that needs vendor/model format."""
|
||||
return (provider or "").strip().lower() in _AGGREGATOR_PROVIDERS
|
||||
|
||||
|
||||
def vendor_for_model(model_name: str) -> str:
|
||||
"""Return the vendor slug for a model, or ``""`` if unknown.
|
||||
|
||||
Convenience wrapper around :func:`detect_vendor` that never returns
|
||||
``None``.
|
||||
"""
|
||||
return detect_vendor(model_name) or ""
|
||||
@@ -3,18 +3,185 @@
|
||||
Both the CLI (cli.py) and gateway (gateway/run.py) /model handlers
|
||||
share the same core pipeline:
|
||||
|
||||
parse_model_input → is_custom detection → auto-detect provider
|
||||
→ credential resolution → validate model → return result
|
||||
parse flags -> alias resolution -> provider resolution ->
|
||||
credential resolution -> normalize model name ->
|
||||
metadata lookup -> build result
|
||||
|
||||
This module extracts that shared pipeline into pure functions that
|
||||
return result objects. The callers handle all platform-specific
|
||||
concerns: state mutation, config persistence, output formatting.
|
||||
This module ties together the foundation layers:
|
||||
|
||||
- ``agent.models_dev`` -- models.dev catalog, ModelInfo, ProviderInfo
|
||||
- ``hermes_cli.providers`` -- canonical provider identity + overlays
|
||||
- ``hermes_cli.model_normalize`` -- per-provider name formatting
|
||||
|
||||
Provider switching uses the ``--provider`` flag exclusively.
|
||||
No colon-based ``provider:model`` syntax — colons are reserved for
|
||||
OpenRouter variant suffixes (``:free``, ``:extended``, ``:fast``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
from hermes_cli.providers import (
|
||||
ALIASES,
|
||||
LABELS,
|
||||
TRANSPORT_TO_API_MODE,
|
||||
determine_api_mode,
|
||||
get_label,
|
||||
get_provider,
|
||||
is_aggregator,
|
||||
normalize_provider,
|
||||
resolve_provider_full,
|
||||
)
|
||||
from hermes_cli.model_normalize import (
|
||||
detect_vendor,
|
||||
normalize_model_for_provider,
|
||||
)
|
||||
from agent.models_dev import (
|
||||
ModelCapabilities,
|
||||
ModelInfo,
|
||||
get_model_capabilities,
|
||||
get_model_info,
|
||||
list_provider_models,
|
||||
search_models_dev,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model aliases -- short names -> (vendor, family) with NO version numbers.
|
||||
# Resolved dynamically against the live models.dev catalog.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ModelIdentity(NamedTuple):
|
||||
"""Vendor slug and family prefix used for catalog resolution."""
|
||||
vendor: str
|
||||
family: str
|
||||
|
||||
|
||||
MODEL_ALIASES: dict[str, ModelIdentity] = {
|
||||
# Anthropic
|
||||
"sonnet": ModelIdentity("anthropic", "claude-sonnet"),
|
||||
"opus": ModelIdentity("anthropic", "claude-opus"),
|
||||
"haiku": ModelIdentity("anthropic", "claude-haiku"),
|
||||
"claude": ModelIdentity("anthropic", "claude"),
|
||||
|
||||
# OpenAI
|
||||
"gpt5": ModelIdentity("openai", "gpt-5"),
|
||||
"gpt": ModelIdentity("openai", "gpt"),
|
||||
"codex": ModelIdentity("openai", "codex"),
|
||||
"o3": ModelIdentity("openai", "o3"),
|
||||
"o4": ModelIdentity("openai", "o4"),
|
||||
|
||||
# Google
|
||||
"gemini": ModelIdentity("google", "gemini"),
|
||||
|
||||
# DeepSeek
|
||||
"deepseek": ModelIdentity("deepseek", "deepseek-chat"),
|
||||
|
||||
# X.AI
|
||||
"grok": ModelIdentity("x-ai", "grok"),
|
||||
|
||||
# Meta
|
||||
"llama": ModelIdentity("meta-llama", "llama"),
|
||||
|
||||
# Qwen / Alibaba
|
||||
"qwen": ModelIdentity("qwen", "qwen"),
|
||||
|
||||
# MiniMax
|
||||
"minimax": ModelIdentity("minimax", "minimax"),
|
||||
|
||||
# Nvidia
|
||||
"nemotron": ModelIdentity("nvidia", "nemotron"),
|
||||
|
||||
# Moonshot / Kimi
|
||||
"kimi": ModelIdentity("moonshotai", "kimi"),
|
||||
|
||||
# Z.AI / GLM
|
||||
"glm": ModelIdentity("z-ai", "glm"),
|
||||
|
||||
# StepFun
|
||||
"step": ModelIdentity("stepfun", "step"),
|
||||
|
||||
# Xiaomi
|
||||
"mimo": ModelIdentity("xiaomi", "mimo"),
|
||||
|
||||
# Arcee
|
||||
"trinity": ModelIdentity("arcee-ai", "trinity"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct aliases — exact model+provider+base_url for endpoints that aren't
|
||||
# in the models.dev catalog (e.g. Ollama Cloud, local servers).
|
||||
# Checked BEFORE catalog resolution. Format:
|
||||
# alias -> (model_id, provider, base_url)
|
||||
# These can also be loaded from config.yaml ``model_aliases:`` section.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DirectAlias(NamedTuple):
|
||||
"""Exact model mapping that bypasses catalog resolution."""
|
||||
model: str
|
||||
provider: str
|
||||
base_url: str
|
||||
|
||||
|
||||
# Built-in direct aliases (can be extended via config.yaml model_aliases:)
|
||||
_BUILTIN_DIRECT_ALIASES: dict[str, DirectAlias] = {}
|
||||
|
||||
# Merged dict (builtins + user config); populated by _load_direct_aliases()
|
||||
DIRECT_ALIASES: dict[str, DirectAlias] = {}
|
||||
|
||||
|
||||
def _load_direct_aliases() -> dict[str, DirectAlias]:
|
||||
"""Load direct aliases from config.yaml ``model_aliases:`` section.
|
||||
|
||||
Config format::
|
||||
|
||||
model_aliases:
|
||||
qwen:
|
||||
model: "qwen3.5:397b"
|
||||
provider: custom
|
||||
base_url: "https://ollama.com/v1"
|
||||
minimax:
|
||||
model: "minimax-m2.7"
|
||||
provider: custom
|
||||
base_url: "https://ollama.com/v1"
|
||||
"""
|
||||
merged = dict(_BUILTIN_DIRECT_ALIASES)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
user_aliases = cfg.get("model_aliases")
|
||||
if isinstance(user_aliases, dict):
|
||||
for name, entry in user_aliases.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
model = entry.get("model", "")
|
||||
provider = entry.get("provider", "custom")
|
||||
base_url = entry.get("base_url", "")
|
||||
if model:
|
||||
merged[name.strip().lower()] = DirectAlias(
|
||||
model=model, provider=provider, base_url=base_url,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return merged
|
||||
|
||||
|
||||
def _ensure_direct_aliases() -> None:
|
||||
"""Lazy-load direct aliases on first use."""
|
||||
global DIRECT_ALIASES
|
||||
if not DIRECT_ALIASES:
|
||||
DIRECT_ALIASES = _load_direct_aliases()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ModelSwitchResult:
|
||||
@@ -27,11 +194,13 @@ class ModelSwitchResult:
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
api_mode: str = ""
|
||||
persist: bool = False
|
||||
error_message: str = ""
|
||||
warning_message: str = ""
|
||||
is_custom_target: bool = False
|
||||
provider_label: str = ""
|
||||
resolved_via_alias: str = ""
|
||||
capabilities: Optional[ModelCapabilities] = None
|
||||
model_info: Optional[ModelInfo] = None
|
||||
is_global: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -45,91 +214,350 @@ class CustomAutoResult:
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flag parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_model_flags(raw_args: str) -> tuple[str, str, bool]:
|
||||
"""Parse --provider and --global flags from /model command args.
|
||||
|
||||
Returns (model_input, explicit_provider, is_global).
|
||||
|
||||
Examples::
|
||||
|
||||
"sonnet" -> ("sonnet", "", False)
|
||||
"sonnet --global" -> ("sonnet", "", True)
|
||||
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False)
|
||||
"--provider my-ollama" -> ("", "my-ollama", False)
|
||||
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True)
|
||||
"""
|
||||
is_global = False
|
||||
explicit_provider = ""
|
||||
|
||||
# Extract --global
|
||||
if "--global" in raw_args:
|
||||
is_global = True
|
||||
raw_args = raw_args.replace("--global", "").strip()
|
||||
|
||||
# Extract --provider <name>
|
||||
parts = raw_args.split()
|
||||
i = 0
|
||||
filtered: list[str] = []
|
||||
while i < len(parts):
|
||||
if parts[i] == "--provider" and i + 1 < len(parts):
|
||||
explicit_provider = parts[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
filtered.append(parts[i])
|
||||
i += 1
|
||||
|
||||
model_input = " ".join(filtered).strip()
|
||||
return (model_input, explicit_provider, is_global)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alias resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_alias(
|
||||
raw_input: str,
|
||||
current_provider: str,
|
||||
) -> Optional[tuple[str, str, str]]:
|
||||
"""Resolve a short alias against the current provider's catalog.
|
||||
|
||||
Looks up *raw_input* in :data:`MODEL_ALIASES`, then searches the
|
||||
current provider's models.dev catalog for the first model whose ID
|
||||
starts with ``vendor/family`` (or just ``family`` for non-aggregator
|
||||
providers).
|
||||
|
||||
Returns:
|
||||
``(provider, resolved_model_id, alias_name)`` if a match is
|
||||
found on the current provider, or ``None`` if the alias doesn't
|
||||
exist or no matching model is available.
|
||||
"""
|
||||
key = raw_input.strip().lower()
|
||||
|
||||
# Check direct aliases first (exact model+provider+base_url mappings)
|
||||
_ensure_direct_aliases()
|
||||
direct = DIRECT_ALIASES.get(key)
|
||||
if direct is not None:
|
||||
return (direct.provider, direct.model, key)
|
||||
|
||||
# Reverse lookup: match by model ID so full names (e.g. "kimi-k2.5",
|
||||
# "glm-4.7") route through direct aliases instead of falling through
|
||||
# to the catalog/OpenRouter.
|
||||
for alias_name, da in DIRECT_ALIASES.items():
|
||||
if da.model.lower() == key:
|
||||
return (da.provider, da.model, alias_name)
|
||||
|
||||
identity = MODEL_ALIASES.get(key)
|
||||
if identity is None:
|
||||
return None
|
||||
|
||||
vendor, family = identity
|
||||
|
||||
# Search the provider's catalog from models.dev
|
||||
catalog = list_provider_models(current_provider)
|
||||
if not catalog:
|
||||
return None
|
||||
|
||||
# For aggregators, models are vendor/model-name format
|
||||
aggregator = is_aggregator(current_provider)
|
||||
|
||||
for model_id in catalog:
|
||||
mid_lower = model_id.lower()
|
||||
if aggregator:
|
||||
# Match vendor/family prefix -- e.g. "anthropic/claude-sonnet"
|
||||
prefix = f"{vendor}/{family}".lower()
|
||||
if mid_lower.startswith(prefix):
|
||||
return (current_provider, model_id, key)
|
||||
else:
|
||||
# Non-aggregator: bare names -- e.g. "claude-sonnet-4-6"
|
||||
family_lower = family.lower()
|
||||
if mid_lower.startswith(family_lower):
|
||||
return (current_provider, model_id, key)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_alias_fallback(
|
||||
raw_input: str,
|
||||
fallback_providers: tuple[str, ...] = ("openrouter", "nous"),
|
||||
) -> Optional[tuple[str, str, str]]:
|
||||
"""Try to resolve an alias on fallback providers."""
|
||||
for provider in fallback_providers:
|
||||
result = resolve_alias(raw_input, provider)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core model-switching pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def switch_model(
|
||||
raw_input: str,
|
||||
current_provider: str,
|
||||
current_model: str,
|
||||
current_base_url: str = "",
|
||||
current_api_key: str = "",
|
||||
is_global: bool = False,
|
||||
explicit_provider: str = "",
|
||||
user_providers: dict = None,
|
||||
) -> ModelSwitchResult:
|
||||
"""Core model-switching pipeline shared between CLI and gateway.
|
||||
|
||||
Handles parsing, provider detection, credential resolution, and
|
||||
model validation. Does NOT handle config persistence, state
|
||||
mutation, or output formatting — those are caller responsibilities.
|
||||
Resolution chain:
|
||||
|
||||
If --provider given:
|
||||
a. Resolve provider via resolve_provider_full()
|
||||
b. Resolve credentials
|
||||
c. If model given, resolve alias on target provider or use as-is
|
||||
d. If no model, auto-detect from endpoint
|
||||
|
||||
If no --provider:
|
||||
a. Try alias resolution on current provider
|
||||
b. If alias exists but not on current provider -> fallback
|
||||
c. On aggregator, try vendor/model slug conversion
|
||||
d. Aggregator catalog search
|
||||
e. detect_provider_for_model() as last resort
|
||||
f. Resolve credentials
|
||||
g. Normalize model name for target provider
|
||||
|
||||
Finally:
|
||||
h. Get full model metadata from models.dev
|
||||
i. Build result
|
||||
|
||||
Args:
|
||||
raw_input: The user's model input (e.g. "claude-sonnet-4",
|
||||
"zai:glm-5", "custom:local:qwen").
|
||||
raw_input: The model name (after flag parsing).
|
||||
current_provider: The currently active provider.
|
||||
current_base_url: The currently active base URL (used for
|
||||
is_custom detection).
|
||||
current_model: The currently active model name.
|
||||
current_base_url: The currently active base URL.
|
||||
current_api_key: The currently active API key.
|
||||
is_global: Whether to persist the switch.
|
||||
explicit_provider: From --provider flag (empty = no explicit provider).
|
||||
user_providers: The ``providers:`` dict from config.yaml (for user endpoints).
|
||||
|
||||
Returns:
|
||||
ModelSwitchResult with all information the caller needs to
|
||||
apply the switch and format output.
|
||||
ModelSwitchResult with all information the caller needs.
|
||||
"""
|
||||
from hermes_cli.models import (
|
||||
parse_model_input,
|
||||
detect_provider_for_model,
|
||||
validate_requested_model,
|
||||
_PROVIDER_LABELS,
|
||||
opencode_model_api_mode,
|
||||
)
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
# Step 1: Parse provider:model syntax
|
||||
target_provider, new_model = parse_model_input(raw_input, current_provider)
|
||||
resolved_alias = ""
|
||||
new_model = raw_input.strip()
|
||||
target_provider = current_provider
|
||||
|
||||
# Step 2: Detect if we're currently on a custom endpoint
|
||||
_base = current_base_url or ""
|
||||
is_custom = current_provider == "custom" or (
|
||||
"localhost" in _base or "127.0.0.1" in _base
|
||||
)
|
||||
# =================================================================
|
||||
# PATH A: Explicit --provider given
|
||||
# =================================================================
|
||||
if explicit_provider:
|
||||
# Resolve the provider
|
||||
pdef = resolve_provider_full(explicit_provider, user_providers)
|
||||
if pdef is None:
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Unknown provider '{explicit_provider}'. "
|
||||
f"Check 'hermes model' for available providers, or define it "
|
||||
f"in config.yaml under 'providers:'."
|
||||
),
|
||||
)
|
||||
|
||||
# Step 3: Auto-detect provider when no explicit provider:model syntax
|
||||
# was used. Skip for custom providers — the model name might
|
||||
# coincidentally match a known provider's catalog.
|
||||
if target_provider == current_provider and not is_custom:
|
||||
detected = detect_provider_for_model(new_model, current_provider)
|
||||
if detected:
|
||||
target_provider, new_model = detected
|
||||
target_provider = pdef.id
|
||||
|
||||
# If no model specified, try auto-detect from endpoint
|
||||
if not new_model:
|
||||
if pdef.base_url:
|
||||
from hermes_cli.runtime_provider import _auto_detect_local_model
|
||||
detected = _auto_detect_local_model(pdef.base_url)
|
||||
if detected:
|
||||
new_model = detected
|
||||
else:
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=pdef.name,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"No model detected on {pdef.name} ({pdef.base_url}). "
|
||||
f"Specify the model explicitly: /model <model-name> --provider {explicit_provider}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=pdef.name,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Provider '{pdef.name}' has no base URL configured. "
|
||||
f"Specify a model: /model <model-name> --provider {explicit_provider}"
|
||||
),
|
||||
)
|
||||
|
||||
# Resolve alias on the TARGET provider
|
||||
alias_result = resolve_alias(new_model, target_provider)
|
||||
if alias_result is not None:
|
||||
_, new_model, resolved_alias = alias_result
|
||||
|
||||
# =================================================================
|
||||
# PATH B: No explicit provider — resolve from model input
|
||||
# =================================================================
|
||||
else:
|
||||
# --- Step a: Try alias resolution on current provider ---
|
||||
alias_result = resolve_alias(raw_input, current_provider)
|
||||
|
||||
if alias_result is not None:
|
||||
target_provider, new_model, resolved_alias = alias_result
|
||||
logger.debug(
|
||||
"Alias '%s' resolved to %s on %s",
|
||||
resolved_alias, new_model, target_provider,
|
||||
)
|
||||
else:
|
||||
# --- Step b: Alias exists but not on current provider -> fallback ---
|
||||
key = raw_input.strip().lower()
|
||||
if key in MODEL_ALIASES:
|
||||
fallback_result = _resolve_alias_fallback(raw_input)
|
||||
if fallback_result is not None:
|
||||
target_provider, new_model, resolved_alias = fallback_result
|
||||
logger.debug(
|
||||
"Alias '%s' resolved via fallback to %s on %s",
|
||||
resolved_alias, new_model, target_provider,
|
||||
)
|
||||
else:
|
||||
identity = MODEL_ALIASES[key]
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Alias '{key}' maps to {identity.vendor}/{identity.family} "
|
||||
f"but no matching model was found in any provider catalog. "
|
||||
f"Try specifying the full model name."
|
||||
),
|
||||
)
|
||||
else:
|
||||
# --- Step c: On aggregator, convert vendor:model to vendor/model ---
|
||||
colon_pos = raw_input.find(":")
|
||||
if colon_pos > 0 and is_aggregator(current_provider):
|
||||
left = raw_input[:colon_pos].strip().lower()
|
||||
right = raw_input[colon_pos + 1:].strip()
|
||||
if left and right:
|
||||
# Colons become slashes for aggregator slugs
|
||||
new_model = f"{left}/{right}"
|
||||
logger.debug(
|
||||
"Converted vendor:model '%s' to aggregator slug '%s'",
|
||||
raw_input, new_model,
|
||||
)
|
||||
|
||||
# --- Step d: Aggregator catalog search ---
|
||||
if is_aggregator(target_provider) and not resolved_alias:
|
||||
catalog = list_provider_models(target_provider)
|
||||
if catalog:
|
||||
new_model_lower = new_model.lower()
|
||||
for mid in catalog:
|
||||
if mid.lower() == new_model_lower:
|
||||
new_model = mid
|
||||
break
|
||||
else:
|
||||
for mid in catalog:
|
||||
if "/" in mid:
|
||||
_, bare = mid.split("/", 1)
|
||||
if bare.lower() == new_model_lower:
|
||||
new_model = mid
|
||||
break
|
||||
|
||||
# --- Step e: detect_provider_for_model() as last resort ---
|
||||
_base = current_base_url or ""
|
||||
is_custom = current_provider in ("custom", "local") or (
|
||||
"localhost" in _base or "127.0.0.1" in _base
|
||||
)
|
||||
|
||||
if (
|
||||
target_provider == current_provider
|
||||
and not is_custom
|
||||
and not resolved_alias
|
||||
):
|
||||
detected = detect_provider_for_model(new_model, current_provider)
|
||||
if detected:
|
||||
target_provider, new_model = detected
|
||||
|
||||
# =================================================================
|
||||
# COMMON PATH: Resolve credentials, normalize, get metadata
|
||||
# =================================================================
|
||||
|
||||
provider_changed = target_provider != current_provider
|
||||
provider_label = get_label(target_provider)
|
||||
|
||||
# Step 4: Resolve credentials for target provider
|
||||
# --- Resolve credentials ---
|
||||
api_key = current_api_key
|
||||
base_url = current_base_url
|
||||
api_mode = ""
|
||||
if provider_changed:
|
||||
|
||||
if provider_changed or explicit_provider:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(requested=target_provider)
|
||||
api_key = runtime.get("api_key", "")
|
||||
base_url = runtime.get("base_url", "")
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception as e:
|
||||
provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
|
||||
if target_provider == "custom":
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
error_message=(
|
||||
"No custom endpoint configured. Set model.base_url "
|
||||
"in config.yaml, or set OPENAI_BASE_URL in .env, "
|
||||
"or run: hermes setup → Custom OpenAI-compatible endpoint"
|
||||
),
|
||||
)
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Could not resolve credentials for provider "
|
||||
f"'{provider_label}': {e}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Gateway also resolves for unchanged provider to get accurate
|
||||
# base_url for validation probing.
|
||||
try:
|
||||
runtime = resolve_runtime_provider(requested=current_provider)
|
||||
api_key = runtime.get("api_key", "")
|
||||
@@ -138,7 +566,19 @@ def switch_model(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 5: Validate the model
|
||||
# --- Direct alias override: use exact base_url from the alias if set ---
|
||||
if resolved_alias:
|
||||
_ensure_direct_aliases()
|
||||
_da = DIRECT_ALIASES.get(resolved_alias)
|
||||
if _da is not None and _da.base_url:
|
||||
base_url = _da.base_url
|
||||
if not api_key:
|
||||
api_key = "no-key-required"
|
||||
|
||||
# --- Normalize model name for target provider ---
|
||||
new_model = normalize_model_for_provider(new_model, target_provider)
|
||||
|
||||
# --- Validate ---
|
||||
try:
|
||||
validation = validate_requested_model(
|
||||
new_model,
|
||||
@@ -160,23 +600,26 @@ def switch_model(
|
||||
success=False,
|
||||
new_model=new_model,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=msg,
|
||||
)
|
||||
|
||||
# Step 6: Build result
|
||||
provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
|
||||
is_custom_target = target_provider == "custom" or (
|
||||
base_url
|
||||
and "openrouter.ai" not in (base_url or "")
|
||||
and ("localhost" in (base_url or "") or "127.0.0.1" in (base_url or ""))
|
||||
)
|
||||
|
||||
if target_provider in {"opencode-zen", "opencode-go"}:
|
||||
# Recompute against the requested new model, not the currently-configured
|
||||
# model used during runtime resolution. OpenCode mixes API surfaces by
|
||||
# model family, so a same-provider model switch can change api_mode.
|
||||
# --- OpenCode api_mode override ---
|
||||
if target_provider in {"opencode-zen", "opencode-go", "opencode", "opencode-go"}:
|
||||
api_mode = opencode_model_api_mode(target_provider, new_model)
|
||||
|
||||
# --- Determine api_mode if not already set ---
|
||||
if not api_mode:
|
||||
api_mode = determine_api_mode(target_provider, base_url)
|
||||
|
||||
# --- Get capabilities (legacy) ---
|
||||
capabilities = get_model_capabilities(target_provider, new_model)
|
||||
|
||||
# --- Get full model info from models.dev ---
|
||||
model_info = get_model_info(target_provider, new_model)
|
||||
|
||||
# --- Build result ---
|
||||
return ModelSwitchResult(
|
||||
success=True,
|
||||
new_model=new_model,
|
||||
@@ -185,18 +628,191 @@ def switch_model(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
api_mode=api_mode,
|
||||
persist=bool(validation.get("persist")),
|
||||
warning_message=validation.get("message") or "",
|
||||
is_custom_target=is_custom_target,
|
||||
provider_label=provider_label,
|
||||
resolved_via_alias=resolved_alias,
|
||||
capabilities=capabilities,
|
||||
model_info=model_info,
|
||||
is_global=is_global,
|
||||
)
|
||||
|
||||
|
||||
def switch_to_custom_provider() -> CustomAutoResult:
|
||||
"""Handle bare '/model custom' — resolve endpoint and auto-detect model.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authenticated providers listing (for /model no-args display)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Returns a result object; the caller handles persistence and output.
|
||||
def list_authenticated_providers(
|
||||
current_provider: str = "",
|
||||
user_providers: dict = None,
|
||||
max_models: int = 8,
|
||||
) -> List[dict]:
|
||||
"""Detect which providers have credentials and list their curated models.
|
||||
|
||||
Uses the curated model lists from hermes_cli/models.py (OPENROUTER_MODELS,
|
||||
_PROVIDER_MODELS) — NOT the full models.dev catalog. These are hand-picked
|
||||
agentic models that work well as agent backends.
|
||||
|
||||
Returns a list of dicts, each with:
|
||||
- slug: str — the --provider value to use
|
||||
- name: str — display name
|
||||
- is_current: bool
|
||||
- is_user_defined: bool
|
||||
- models: list[str] — curated model IDs (up to max_models)
|
||||
- total_models: int — total curated count
|
||||
- source: str — "built-in", "models.dev", "user-config"
|
||||
|
||||
Only includes providers that have API keys set or are user-defined endpoints.
|
||||
"""
|
||||
import os
|
||||
from agent.models_dev import (
|
||||
PROVIDER_TO_MODELS_DEV,
|
||||
fetch_models_dev,
|
||||
get_provider_info as _mdev_pinfo,
|
||||
)
|
||||
from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS
|
||||
|
||||
results: List[dict] = []
|
||||
seen_slugs: set = set()
|
||||
|
||||
data = fetch_models_dev()
|
||||
|
||||
# Build curated model lists keyed by hermes provider ID
|
||||
curated: dict[str, list[str]] = dict(_PROVIDER_MODELS)
|
||||
curated["openrouter"] = [mid for mid, _ in OPENROUTER_MODELS]
|
||||
# "nous" shares OpenRouter's curated list if not separately defined
|
||||
if "nous" not in curated:
|
||||
curated["nous"] = curated["openrouter"]
|
||||
|
||||
# --- 1. Check Hermes-mapped providers ---
|
||||
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
|
||||
pdata = data.get(mdev_id)
|
||||
if not isinstance(pdata, dict):
|
||||
continue
|
||||
|
||||
env_vars = pdata.get("env", [])
|
||||
if not isinstance(env_vars, list):
|
||||
continue
|
||||
|
||||
# Check if any env var is set
|
||||
has_creds = any(os.environ.get(ev) for ev in env_vars)
|
||||
if not has_creds:
|
||||
continue
|
||||
|
||||
# Use curated list, falling back to models.dev if no curated list
|
||||
model_ids = curated.get(hermes_id, [])
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
slug = hermes_id
|
||||
pinfo = _mdev_pinfo(mdev_id)
|
||||
display_name = pinfo.name if pinfo else mdev_id
|
||||
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"is_current": slug == current_provider or mdev_id == current_provider,
|
||||
"is_user_defined": False,
|
||||
"models": top,
|
||||
"total_models": total,
|
||||
"source": "built-in",
|
||||
})
|
||||
seen_slugs.add(slug)
|
||||
|
||||
# --- 2. Check Hermes-only providers (nous, openai-codex, copilot) ---
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
for pid, overlay in HERMES_OVERLAYS.items():
|
||||
if pid in seen_slugs:
|
||||
continue
|
||||
# Check if credentials exist
|
||||
has_creds = False
|
||||
if overlay.extra_env_vars:
|
||||
has_creds = any(os.environ.get(ev) for ev in overlay.extra_env_vars)
|
||||
if overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"):
|
||||
# These use auth stores, not env vars — check for auth.json entries
|
||||
try:
|
||||
from hermes_cli.auth import _read_auth_store
|
||||
store = _read_auth_store()
|
||||
if store and pid in store:
|
||||
has_creds = True
|
||||
except Exception:
|
||||
pass
|
||||
if not has_creds:
|
||||
continue
|
||||
|
||||
# Use curated list
|
||||
model_ids = curated.get(pid, [])
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
results.append({
|
||||
"slug": pid,
|
||||
"name": get_label(pid),
|
||||
"is_current": pid == current_provider,
|
||||
"is_user_defined": False,
|
||||
"models": top,
|
||||
"total_models": total,
|
||||
"source": "hermes",
|
||||
})
|
||||
seen_slugs.add(pid)
|
||||
|
||||
# --- 3. User-defined endpoints from config ---
|
||||
if user_providers and isinstance(user_providers, dict):
|
||||
for ep_name, ep_cfg in user_providers.items():
|
||||
if not isinstance(ep_cfg, dict):
|
||||
continue
|
||||
display_name = ep_cfg.get("name", "") or ep_name
|
||||
api_url = ep_cfg.get("api", "") or ep_cfg.get("url", "") or ""
|
||||
default_model = ep_cfg.get("default_model", "")
|
||||
|
||||
models_list = []
|
||||
if default_model:
|
||||
models_list.append(default_model)
|
||||
|
||||
# Try to probe /v1/models if URL is set (but don't block on it)
|
||||
# For now just show what we know from config
|
||||
results.append({
|
||||
"slug": ep_name,
|
||||
"name": display_name,
|
||||
"is_current": ep_name == current_provider,
|
||||
"is_user_defined": True,
|
||||
"models": models_list,
|
||||
"total_models": len(models_list) if models_list else 0,
|
||||
"source": "user-config",
|
||||
"api_url": api_url,
|
||||
})
|
||||
|
||||
# Sort: current provider first, then by model count descending
|
||||
results.sort(key=lambda r: (not r["is_current"], -r["total_models"]))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fuzzy suggestions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def suggest_models(raw_input: str, limit: int = 3) -> List[str]:
|
||||
"""Return fuzzy model suggestions for a (possibly misspelled) input."""
|
||||
query = raw_input.strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
results = search_models_dev(query, limit=limit)
|
||||
suggestions: list[str] = []
|
||||
for r in results:
|
||||
mid = r.get("model_id", "")
|
||||
if mid:
|
||||
suggestions.append(mid)
|
||||
|
||||
return suggestions[:limit]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom provider switch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def switch_to_custom_provider() -> CustomAutoResult:
|
||||
"""Handle bare '/model --provider custom' — resolve endpoint and auto-detect model."""
|
||||
from hermes_cli.runtime_provider import (
|
||||
resolve_runtime_provider,
|
||||
_auto_detect_local_model,
|
||||
@@ -219,7 +835,7 @@ def switch_to_custom_provider() -> CustomAutoResult:
|
||||
error_message=(
|
||||
"No custom endpoint configured. "
|
||||
"Set model.base_url in config.yaml, or set OPENAI_BASE_URL "
|
||||
"in .env, or run: hermes setup → Custom OpenAI-compatible endpoint"
|
||||
"in .env, or run: hermes setup -> Custom OpenAI-compatible endpoint"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -232,7 +848,7 @@ def switch_to_custom_provider() -> CustomAutoResult:
|
||||
error_message=(
|
||||
f"Custom endpoint at {cust_base} is reachable but no single "
|
||||
f"model was auto-detected. Specify the model explicitly: "
|
||||
f"/model custom:<model-name>"
|
||||
f"/model <model-name> --provider custom"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -182,6 +182,32 @@ class PluginContext:
|
||||
cli._pending_input.put(msg)
|
||||
return True
|
||||
|
||||
# -- CLI command registration --------------------------------------------
|
||||
|
||||
def register_cli_command(
|
||||
self,
|
||||
name: str,
|
||||
help: str,
|
||||
setup_fn: Callable,
|
||||
handler_fn: Callable | None = None,
|
||||
description: str = "",
|
||||
) -> None:
|
||||
"""Register a CLI subcommand (e.g. ``hermes honcho ...``).
|
||||
|
||||
The *setup_fn* receives an argparse subparser and should add any
|
||||
arguments/sub-subparsers. If *handler_fn* is provided it is set
|
||||
as the default dispatch function via ``set_defaults(func=...)``.
|
||||
"""
|
||||
self._manager._cli_commands[name] = {
|
||||
"name": name,
|
||||
"help": help,
|
||||
"description": description,
|
||||
"setup_fn": setup_fn,
|
||||
"handler_fn": handler_fn,
|
||||
"plugin": self.manifest.name,
|
||||
}
|
||||
logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name)
|
||||
|
||||
# -- hook registration --------------------------------------------------
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||
@@ -213,6 +239,7 @@ class PluginManager:
|
||||
self._plugins: Dict[str, LoadedPlugin] = {}
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
self._plugin_tool_names: Set[str] = set()
|
||||
self._cli_commands: Dict[str, dict] = {}
|
||||
self._discovered: bool = False
|
||||
self._cli_ref = None # Set by CLI after plugin discovery
|
||||
|
||||
@@ -441,8 +468,18 @@ class PluginManager:
|
||||
plugin cannot break the core agent loop.
|
||||
|
||||
Returns a list of non-``None`` return values from callbacks.
|
||||
This allows hooks like ``pre_llm_call`` to contribute context
|
||||
that the agent core can collect and inject.
|
||||
|
||||
For ``pre_llm_call``, callbacks may return a dict describing
|
||||
context to inject into the current turn's user message::
|
||||
|
||||
{"context": "recalled text..."}
|
||||
"recalled text..." # plain string, equivalent
|
||||
|
||||
Context is ALWAYS injected into the user message, never the
|
||||
system prompt. This preserves the prompt cache prefix — the
|
||||
system prompt stays identical across turns so cached tokens
|
||||
are reused. All injected context is ephemeral — never
|
||||
persisted to session DB.
|
||||
"""
|
||||
callbacks = self._hooks.get(hook_name, [])
|
||||
results: List[Any] = []
|
||||
@@ -516,6 +553,15 @@ def get_plugin_tool_names() -> Set[str]:
|
||||
return get_plugin_manager()._plugin_tool_names
|
||||
|
||||
|
||||
def get_plugin_cli_commands() -> Dict[str, dict]:
|
||||
"""Return CLI commands registered by general plugins.
|
||||
|
||||
Returns a dict of ``{name: {help, setup_fn, handler_fn, ...}}``
|
||||
suitable for wiring into argparse subparsers.
|
||||
"""
|
||||
return dict(get_plugin_manager()._cli_commands)
|
||||
|
||||
|
||||
def get_plugin_toolsets() -> List[tuple]:
|
||||
"""Return plugin toolsets as ``(key, label, description)`` tuples.
|
||||
|
||||
|
||||
519
hermes_cli/providers.py
Normal file
519
hermes_cli/providers.py
Normal file
@@ -0,0 +1,519 @@
|
||||
"""
|
||||
Single source of truth for provider identity in Hermes Agent.
|
||||
|
||||
Two data sources, merged at runtime:
|
||||
|
||||
1. **models.dev catalog** — 109+ providers with base URLs, env vars, display
|
||||
names, and full model metadata (context, cost, capabilities). This is
|
||||
the primary database.
|
||||
|
||||
2. **Hermes overlays** — transport type, auth patterns, aggregator flags,
|
||||
and additional env vars that models.dev doesn't track. Small dict,
|
||||
maintained here.
|
||||
|
||||
3. **User config** (``providers:`` section in config.yaml) — user-defined
|
||||
endpoints and overrides. Merged on top of everything else.
|
||||
|
||||
Other modules import from this file. No parallel registries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -- Hermes overlay ----------------------------------------------------------
|
||||
# Hermes-specific metadata that models.dev doesn't provide.
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HermesOverlay:
|
||||
"""Hermes-specific provider metadata layered on top of models.dev."""
|
||||
|
||||
transport: str = "openai_chat" # openai_chat | anthropic_messages | codex_responses
|
||||
is_aggregator: bool = False
|
||||
auth_type: str = "api_key" # api_key | oauth_device_code | oauth_external | external_process
|
||||
extra_env_vars: Tuple[str, ...] = () # env vars models.dev doesn't list
|
||||
base_url_override: str = "" # override if models.dev URL is wrong/missing
|
||||
base_url_env_var: str = "" # env var for user-custom base URL
|
||||
|
||||
|
||||
HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
"openrouter": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
extra_env_vars=("OPENAI_API_KEY",),
|
||||
base_url_env_var="OPENROUTER_BASE_URL",
|
||||
),
|
||||
"nous": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
auth_type="oauth_device_code",
|
||||
base_url_override="https://inference-api.nousresearch.com/v1",
|
||||
),
|
||||
"openai-codex": HermesOverlay(
|
||||
transport="codex_responses",
|
||||
auth_type="oauth_external",
|
||||
base_url_override="https://chatgpt.com/backend-api/codex",
|
||||
),
|
||||
"copilot-acp": HermesOverlay(
|
||||
transport="codex_responses",
|
||||
auth_type="external_process",
|
||||
base_url_override="acp://copilot",
|
||||
base_url_env_var="COPILOT_ACP_BASE_URL",
|
||||
),
|
||||
"github-copilot": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"),
|
||||
),
|
||||
"anthropic": HermesOverlay(
|
||||
transport="anthropic_messages",
|
||||
extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
|
||||
),
|
||||
"zai": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
|
||||
base_url_env_var="GLM_BASE_URL",
|
||||
),
|
||||
"kimi-for-coding": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_env_var="KIMI_BASE_URL",
|
||||
),
|
||||
"minimax": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_env_var="MINIMAX_BASE_URL",
|
||||
),
|
||||
"minimax-cn": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_env_var="MINIMAX_CN_BASE_URL",
|
||||
),
|
||||
"deepseek": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_env_var="DEEPSEEK_BASE_URL",
|
||||
),
|
||||
"alibaba": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_env_var="DASHSCOPE_BASE_URL",
|
||||
),
|
||||
"vercel": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
),
|
||||
"opencode": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
base_url_env_var="OPENCODE_ZEN_BASE_URL",
|
||||
),
|
||||
"opencode-go": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
base_url_env_var="OPENCODE_GO_BASE_URL",
|
||||
),
|
||||
"kilo": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
base_url_env_var="KILOCODE_BASE_URL",
|
||||
),
|
||||
"huggingface": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
base_url_env_var="HF_BASE_URL",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# -- Resolved provider -------------------------------------------------------
|
||||
# The merged result of models.dev + overlay + user config.
|
||||
|
||||
@dataclass
|
||||
class ProviderDef:
|
||||
"""Complete provider definition — merged from all sources."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
transport: str # openai_chat | anthropic_messages | codex_responses
|
||||
api_key_env_vars: Tuple[str, ...] # all env vars to check for API key
|
||||
base_url: str = ""
|
||||
base_url_env_var: str = ""
|
||||
is_aggregator: bool = False
|
||||
auth_type: str = "api_key"
|
||||
doc: str = ""
|
||||
source: str = "" # "models.dev", "hermes", "user-config"
|
||||
|
||||
@property
|
||||
def is_user_defined(self) -> bool:
|
||||
return self.source == "user-config"
|
||||
|
||||
|
||||
# -- Aliases ------------------------------------------------------------------
|
||||
# Maps human-friendly / legacy names to canonical provider IDs.
|
||||
# Uses models.dev IDs where possible.
|
||||
|
||||
ALIASES: Dict[str, str] = {
|
||||
# openrouter
|
||||
"openai": "openrouter", # bare "openai" → route through aggregator
|
||||
|
||||
# zai
|
||||
"glm": "zai",
|
||||
"z-ai": "zai",
|
||||
"z.ai": "zai",
|
||||
"zhipu": "zai",
|
||||
|
||||
# kimi-for-coding (models.dev ID)
|
||||
"kimi": "kimi-for-coding",
|
||||
"kimi-coding": "kimi-for-coding",
|
||||
"moonshot": "kimi-for-coding",
|
||||
|
||||
# minimax-cn
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
|
||||
# anthropic
|
||||
"claude": "anthropic",
|
||||
"claude-code": "anthropic",
|
||||
|
||||
# github-copilot (models.dev ID)
|
||||
"copilot": "github-copilot",
|
||||
"github": "github-copilot",
|
||||
"github-copilot-acp": "copilot-acp",
|
||||
|
||||
# vercel (models.dev ID for AI Gateway)
|
||||
"ai-gateway": "vercel",
|
||||
"aigateway": "vercel",
|
||||
"vercel-ai-gateway": "vercel",
|
||||
|
||||
# opencode (models.dev ID for OpenCode Zen)
|
||||
"opencode-zen": "opencode",
|
||||
"zen": "opencode",
|
||||
|
||||
# opencode-go
|
||||
"go": "opencode-go",
|
||||
"opencode-go-sub": "opencode-go",
|
||||
|
||||
# kilo (models.dev ID for KiloCode)
|
||||
"kilocode": "kilo",
|
||||
"kilo-code": "kilo",
|
||||
"kilo-gateway": "kilo",
|
||||
|
||||
# deepseek
|
||||
"deep-seek": "deepseek",
|
||||
|
||||
# alibaba
|
||||
"dashscope": "alibaba",
|
||||
"aliyun": "alibaba",
|
||||
"qwen": "alibaba",
|
||||
"alibaba-cloud": "alibaba",
|
||||
|
||||
# huggingface
|
||||
"hf": "huggingface",
|
||||
"hugging-face": "huggingface",
|
||||
"huggingface-hub": "huggingface",
|
||||
|
||||
# Local server aliases → virtual "local" concept (resolved via user config)
|
||||
"lmstudio": "lmstudio",
|
||||
"lm-studio": "lmstudio",
|
||||
"lm_studio": "lmstudio",
|
||||
"ollama": "ollama-cloud",
|
||||
"vllm": "local",
|
||||
"llamacpp": "local",
|
||||
"llama.cpp": "local",
|
||||
"llama-cpp": "local",
|
||||
}
|
||||
|
||||
|
||||
# -- Display labels -----------------------------------------------------------
|
||||
# Built dynamically from models.dev + overlays. Fallback for providers
|
||||
# not in the catalog.
|
||||
|
||||
_LABEL_OVERRIDES: Dict[str, str] = {
|
||||
"nous": "Nous Portal",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"local": "Local endpoint",
|
||||
}
|
||||
|
||||
|
||||
# -- Transport → API mode mapping ---------------------------------------------
|
||||
|
||||
TRANSPORT_TO_API_MODE: Dict[str, str] = {
|
||||
"openai_chat": "chat_completions",
|
||||
"anthropic_messages": "anthropic_messages",
|
||||
"codex_responses": "codex_responses",
|
||||
}
|
||||
|
||||
|
||||
# -- Helper functions ---------------------------------------------------------
|
||||
|
||||
def normalize_provider(name: str) -> str:
|
||||
"""Resolve aliases and normalise casing to a canonical provider id.
|
||||
|
||||
Returns the canonical id string. Does *not* validate that the id
|
||||
corresponds to a known provider.
|
||||
"""
|
||||
key = name.strip().lower()
|
||||
return ALIASES.get(key, key)
|
||||
|
||||
|
||||
def get_overlay(provider_id: str) -> Optional[HermesOverlay]:
|
||||
"""Get Hermes overlay for a provider, if one exists."""
|
||||
canonical = normalize_provider(provider_id)
|
||||
return HERMES_OVERLAYS.get(canonical)
|
||||
|
||||
|
||||
def get_provider(name: str) -> Optional[ProviderDef]:
|
||||
"""Look up a provider by id or alias, merging all data sources.
|
||||
|
||||
Resolution order:
|
||||
1. Hermes overlays (for providers not in models.dev: nous, openai-codex, etc.)
|
||||
2. models.dev catalog + Hermes overlay
|
||||
3. User-defined providers from config (TODO: Phase 4)
|
||||
|
||||
Returns a fully-resolved ProviderDef or None.
|
||||
"""
|
||||
canonical = normalize_provider(name)
|
||||
|
||||
# Try to get models.dev data
|
||||
try:
|
||||
from agent.models_dev import get_provider_info as _mdev_provider
|
||||
mdev_info = _mdev_provider(canonical)
|
||||
except Exception:
|
||||
mdev_info = None
|
||||
|
||||
overlay = HERMES_OVERLAYS.get(canonical)
|
||||
|
||||
if mdev_info is not None:
|
||||
# Merge models.dev + overlay
|
||||
transport = overlay.transport if overlay else "openai_chat"
|
||||
is_agg = overlay.is_aggregator if overlay else False
|
||||
auth = overlay.auth_type if overlay else "api_key"
|
||||
base_url_env = overlay.base_url_env_var if overlay else ""
|
||||
base_url_override = overlay.base_url_override if overlay else ""
|
||||
|
||||
# Combine env vars: models.dev env + hermes extra
|
||||
env_vars = list(mdev_info.env)
|
||||
if overlay and overlay.extra_env_vars:
|
||||
for ev in overlay.extra_env_vars:
|
||||
if ev not in env_vars:
|
||||
env_vars.append(ev)
|
||||
|
||||
return ProviderDef(
|
||||
id=canonical,
|
||||
name=mdev_info.name,
|
||||
transport=transport,
|
||||
api_key_env_vars=tuple(env_vars),
|
||||
base_url=base_url_override or mdev_info.api,
|
||||
base_url_env_var=base_url_env,
|
||||
is_aggregator=is_agg,
|
||||
auth_type=auth,
|
||||
doc=mdev_info.doc,
|
||||
source="models.dev",
|
||||
)
|
||||
|
||||
if overlay is not None:
|
||||
# Hermes-only provider (not in models.dev)
|
||||
return ProviderDef(
|
||||
id=canonical,
|
||||
name=_LABEL_OVERRIDES.get(canonical, canonical),
|
||||
transport=overlay.transport,
|
||||
api_key_env_vars=overlay.extra_env_vars,
|
||||
base_url=overlay.base_url_override,
|
||||
base_url_env_var=overlay.base_url_env_var,
|
||||
is_aggregator=overlay.is_aggregator,
|
||||
auth_type=overlay.auth_type,
|
||||
source="hermes",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_label(provider_id: str) -> str:
|
||||
"""Get a human-readable display name for a provider."""
|
||||
canonical = normalize_provider(provider_id)
|
||||
|
||||
# Check label overrides first
|
||||
if canonical in _LABEL_OVERRIDES:
|
||||
return _LABEL_OVERRIDES[canonical]
|
||||
|
||||
# Try models.dev
|
||||
pdef = get_provider(canonical)
|
||||
if pdef:
|
||||
return pdef.name
|
||||
|
||||
return canonical
|
||||
|
||||
|
||||
# Build LABELS dict for backward compat
|
||||
def _build_labels() -> Dict[str, str]:
|
||||
"""Build labels dict from overlays + overrides. Lazy, cached."""
|
||||
labels: Dict[str, str] = {}
|
||||
for pid in HERMES_OVERLAYS:
|
||||
labels[pid] = get_label(pid)
|
||||
labels.update(_LABEL_OVERRIDES)
|
||||
return labels
|
||||
|
||||
# Lazy-built on first access
|
||||
_labels_cache: Optional[Dict[str, str]] = None
|
||||
|
||||
@property
|
||||
def LABELS() -> Dict[str, str]:
|
||||
"""Backward-compatible labels dict."""
|
||||
global _labels_cache
|
||||
if _labels_cache is None:
|
||||
_labels_cache = _build_labels()
|
||||
return _labels_cache
|
||||
|
||||
# For direct import compat, expose as module-level dict
|
||||
# Built on demand by get_label() calls
|
||||
LABELS: Dict[str, str] = {
|
||||
# Static entries for backward compat — get_label() is the proper API
|
||||
"openrouter": "OpenRouter",
|
||||
"nous": "Nous Portal",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
"anthropic": "Anthropic",
|
||||
"zai": "Z.AI / GLM",
|
||||
"kimi-for-coding": "Kimi / Moonshot",
|
||||
"minimax": "MiniMax",
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
"deepseek": "DeepSeek",
|
||||
"alibaba": "Alibaba Cloud (DashScope)",
|
||||
"vercel": "Vercel AI Gateway",
|
||||
"opencode": "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"kilo": "Kilo Gateway",
|
||||
"huggingface": "Hugging Face",
|
||||
"local": "Local endpoint",
|
||||
"custom": "Custom endpoint",
|
||||
# Legacy Hermes IDs (point to same providers)
|
||||
"ai-gateway": "Vercel AI Gateway",
|
||||
"kilocode": "Kilo Gateway",
|
||||
"copilot": "GitHub Copilot",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
"opencode-zen": "OpenCode Zen",
|
||||
}
|
||||
|
||||
|
||||
def is_aggregator(provider: str) -> bool:
|
||||
"""Return True when the provider is a multi-model aggregator."""
|
||||
pdef = get_provider(provider)
|
||||
return pdef.is_aggregator if pdef else False
|
||||
|
||||
|
||||
def determine_api_mode(provider: str, base_url: str = "") -> str:
|
||||
"""Determine the API mode (wire protocol) for a provider/endpoint.
|
||||
|
||||
Resolution order:
|
||||
1. Known provider → transport → TRANSPORT_TO_API_MODE.
|
||||
2. URL heuristics for unknown / custom providers.
|
||||
3. Default: 'chat_completions'.
|
||||
"""
|
||||
pdef = get_provider(provider)
|
||||
if pdef is not None:
|
||||
return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions")
|
||||
|
||||
# URL-based heuristics for custom / unknown providers
|
||||
if base_url:
|
||||
url_lower = base_url.rstrip("/").lower()
|
||||
if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower:
|
||||
return "anthropic_messages"
|
||||
if "api.openai.com" in url_lower:
|
||||
return "codex_responses"
|
||||
|
||||
return "chat_completions"
|
||||
|
||||
|
||||
# -- Provider from user config ------------------------------------------------
|
||||
|
||||
def resolve_user_provider(name: str, user_config: Dict[str, Any]) -> Optional[ProviderDef]:
|
||||
"""Resolve a provider from the user's config.yaml ``providers:`` section.
|
||||
|
||||
Args:
|
||||
name: Provider name as given by the user.
|
||||
user_config: The ``providers:`` dict from config.yaml.
|
||||
|
||||
Returns:
|
||||
ProviderDef if found, else None.
|
||||
"""
|
||||
if not user_config or not isinstance(user_config, dict):
|
||||
return None
|
||||
|
||||
entry = user_config.get(name)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
|
||||
# Extract fields
|
||||
display_name = entry.get("name", "") or name
|
||||
api_url = entry.get("api", "") or entry.get("url", "") or entry.get("base_url", "") or ""
|
||||
key_env = entry.get("key_env", "") or ""
|
||||
transport = entry.get("transport", "openai_chat") or "openai_chat"
|
||||
|
||||
env_vars: List[str] = []
|
||||
if key_env:
|
||||
env_vars.append(key_env)
|
||||
|
||||
return ProviderDef(
|
||||
id=name,
|
||||
name=display_name,
|
||||
transport=transport,
|
||||
api_key_env_vars=tuple(env_vars),
|
||||
base_url=api_url,
|
||||
is_aggregator=False,
|
||||
auth_type="api_key",
|
||||
source="user-config",
|
||||
)
|
||||
|
||||
|
||||
def resolve_provider_full(
|
||||
name: str,
|
||||
user_providers: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ProviderDef]:
|
||||
"""Full resolution chain: built-in → models.dev → user config.
|
||||
|
||||
This is the main entry point for --provider flag resolution.
|
||||
|
||||
Args:
|
||||
name: Provider name or alias.
|
||||
user_providers: The ``providers:`` dict from config.yaml (optional).
|
||||
|
||||
Returns:
|
||||
ProviderDef if found, else None.
|
||||
"""
|
||||
canonical = normalize_provider(name)
|
||||
|
||||
# 1. Built-in (models.dev + overlays)
|
||||
pdef = get_provider(canonical)
|
||||
if pdef is not None:
|
||||
return pdef
|
||||
|
||||
# 2. User-defined providers from config
|
||||
if user_providers:
|
||||
# Try canonical name
|
||||
user_pdef = resolve_user_provider(canonical, user_providers)
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
# Try original name (in case alias didn't match)
|
||||
user_pdef = resolve_user_provider(name.strip().lower(), user_providers)
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
|
||||
# 3. Try models.dev directly (for providers not in our ALIASES)
|
||||
try:
|
||||
from agent.models_dev import get_provider_info as _mdev_provider
|
||||
mdev_info = _mdev_provider(canonical)
|
||||
if mdev_info is not None:
|
||||
return ProviderDef(
|
||||
id=canonical,
|
||||
name=mdev_info.name,
|
||||
transport="openai_chat",
|
||||
api_key_env_vars=mdev_info.env,
|
||||
base_url=mdev_info.api,
|
||||
source="models.dev",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
@@ -377,9 +377,13 @@ def _resolve_openrouter_runtime(
|
||||
]
|
||||
else:
|
||||
# Custom endpoint: use api_key from config when using config base_url (#1760).
|
||||
# When the endpoint is Ollama Cloud, check OLLAMA_API_KEY — it's
|
||||
# the canonical env var for ollama.com authentication.
|
||||
_is_ollama_url = "ollama.com" in base_url.lower()
|
||||
api_key_candidates = [
|
||||
explicit_api_key,
|
||||
(cfg_api_key if use_config_base_url else ""),
|
||||
(os.getenv("OLLAMA_API_KEY") if _is_ollama_url else ""),
|
||||
os.getenv("OPENAI_API_KEY"),
|
||||
os.getenv("OPENROUTER_API_KEY"),
|
||||
]
|
||||
|
||||
@@ -14,6 +14,7 @@ Config files are stored in ~/.hermes/ for easy access.
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
@@ -30,6 +31,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
_DOCS_BASE = "https://hermes-agent.nousresearch.com/docs"
|
||||
|
||||
|
||||
def _model_config_dict(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
current_model = config.get("model")
|
||||
@@ -899,6 +902,7 @@ def setup_model_provider(config: dict):
|
||||
|
||||
print_header("Inference Provider")
|
||||
print_info("Choose how to connect to your main chat model.")
|
||||
print_info(f" Guide: {_DOCS_BASE}/integrations/providers")
|
||||
print()
|
||||
|
||||
# Delegate to the shared hermes model flow — handles provider picker,
|
||||
@@ -1310,6 +1314,7 @@ def setup_terminal_backend(config: dict):
|
||||
print_header("Terminal Backend")
|
||||
print_info("Choose where Hermes runs shell commands and code.")
|
||||
print_info("This affects tool execution, file access, and isolation.")
|
||||
print_info(f" Guide: {_DOCS_BASE}/developer-guide/environments")
|
||||
print()
|
||||
|
||||
current_backend = config.get("terminal", {}).get("backend", "local")
|
||||
@@ -1651,6 +1656,8 @@ def setup_agent_settings(config: dict):
|
||||
|
||||
# ── Max Iterations ──
|
||||
print_header("Agent Settings")
|
||||
print_info(f" Guide: {_DOCS_BASE}/user-guide/configuration")
|
||||
print()
|
||||
|
||||
current_max = get_env_value("HERMES_MAX_ITERATIONS") or str(
|
||||
config.get("agent", {}).get("max_turns", 90)
|
||||
@@ -1818,6 +1825,7 @@ def setup_gateway(config: dict):
|
||||
"""Configure messaging platform integrations."""
|
||||
print_header("Messaging Platforms")
|
||||
print_info("Connect to messaging platforms to chat with Hermes from anywhere.")
|
||||
print_info(f" All platforms: {_DOCS_BASE}/user-guide/messaging")
|
||||
print()
|
||||
|
||||
# ── Telegram ──
|
||||
@@ -1829,6 +1837,8 @@ def setup_gateway(config: dict):
|
||||
|
||||
if not existing_telegram and prompt_yes_no("Set up Telegram bot?", False):
|
||||
print_info("Create a bot via @BotFather on Telegram")
|
||||
print_info(f" Full guide: {_DOCS_BASE}/user-guide/messaging/telegram")
|
||||
print()
|
||||
token = prompt("Telegram bot token", password=True)
|
||||
if token:
|
||||
save_env_value("TELEGRAM_BOT_TOKEN", token)
|
||||
@@ -1913,6 +1923,8 @@ def setup_gateway(config: dict):
|
||||
|
||||
if not existing_discord and prompt_yes_no("Set up Discord bot?", False):
|
||||
print_info("Create a bot at https://discord.com/developers/applications")
|
||||
print_info(f" Full guide: {_DOCS_BASE}/user-guide/messaging/discord")
|
||||
print()
|
||||
token = prompt("Discord bot token", password=True)
|
||||
if token:
|
||||
save_env_value("DISCORD_BOT_TOKEN", token)
|
||||
@@ -2033,7 +2045,7 @@ def setup_gateway(config: dict):
|
||||
)
|
||||
print()
|
||||
print_info(
|
||||
" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/"
|
||||
f" Full guide: {_DOCS_BASE}/user-guide/messaging/slack"
|
||||
)
|
||||
print()
|
||||
bot_token = prompt("Slack Bot Token (xoxb-...)", password=True)
|
||||
@@ -2084,6 +2096,7 @@ def setup_gateway(config: dict):
|
||||
print_info("Works with any Matrix homeserver (Synapse, Conduit, Dendrite, or matrix.org).")
|
||||
print_info(" 1. Create a bot user on your homeserver, or use your own account")
|
||||
print_info(" 2. Get an access token from Element, or provide user ID + password")
|
||||
print_info(f" Full guide: {_DOCS_BASE}/user-guide/messaging/matrix")
|
||||
print()
|
||||
homeserver = prompt("Homeserver URL (e.g. https://matrix.example.org)")
|
||||
if homeserver:
|
||||
@@ -2188,6 +2201,7 @@ def setup_gateway(config: dict):
|
||||
print_info("Works with any self-hosted Mattermost instance.")
|
||||
print_info(" 1. In Mattermost: Integrations → Bot Accounts → Add Bot Account")
|
||||
print_info(" 2. Copy the bot token")
|
||||
print_info(f" Full guide: {_DOCS_BASE}/user-guide/messaging/mattermost")
|
||||
print()
|
||||
mm_url = prompt("Mattermost server URL (e.g. https://mm.example.com)")
|
||||
if mm_url:
|
||||
@@ -2237,6 +2251,7 @@ def setup_gateway(config: dict):
|
||||
if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False):
|
||||
print_info("WhatsApp connects via a built-in bridge (Baileys).")
|
||||
print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.")
|
||||
print_info(f" Full guide: {_DOCS_BASE}/user-guide/messaging/whatsapp")
|
||||
print()
|
||||
if prompt_yes_no("Enable WhatsApp now?", True):
|
||||
save_env_value("WHATSAPP_ENABLED", "true")
|
||||
@@ -2264,7 +2279,7 @@ def setup_gateway(config: dict):
|
||||
)
|
||||
print()
|
||||
print_info(
|
||||
" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/"
|
||||
f" Full guide: {_DOCS_BASE}/user-guide/messaging/webhooks"
|
||||
)
|
||||
print()
|
||||
|
||||
@@ -2295,7 +2310,7 @@ def setup_gateway(config: dict):
|
||||
" Route configuration guide:"
|
||||
)
|
||||
print_info(
|
||||
" https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/#configuring-routes"
|
||||
f" {_DOCS_BASE}/user-guide/messaging/webhooks#configuring-routes"
|
||||
)
|
||||
print()
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
@@ -1336,6 +1336,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
print(color("⚕ Hermes Tool Configuration", Colors.CYAN, Colors.BOLD))
|
||||
print(color(" Enable or disable tools per platform.", Colors.DIM))
|
||||
print(color(" Tools that need API keys will be configured when enabled.", Colors.DIM))
|
||||
print(color(" Guide: https://hermes-agent.nousresearch.com/docs/user-guide/features/tools", Colors.DIM))
|
||||
print()
|
||||
|
||||
# ── First-time install: linear flow, no platform menu ──
|
||||
|
||||
Reference in New Issue
Block a user