mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
Merge remote-tracking branch 'origin/main' into pr48275-rebase
# Conflicts: # cron/scheduler.py
This commit is contained in:
commit
a58287afcb
162 changed files with 8521 additions and 634 deletions
|
|
@ -34,14 +34,38 @@ logger = logging.getLogger(__name__)
|
|||
# ``hermes-agent`` is special-cased to root level only in ``_should_exclude``
|
||||
# so that skill directories like ``skills/autonomous-ai-agents/hermes-agent/``
|
||||
# are not accidentally excluded.
|
||||
#
|
||||
# The dependency/cache entries below matter for more than tidiness: without
|
||||
# them a single plugin venv, MCP-server install, or pip/uv cache living under
|
||||
# HERMES_HOME gets walked file-by-file, ballooning a backup to hundreds of
|
||||
# thousands of entries that crawl for hours — the exact "backup stuck for
|
||||
# days / 426543 files" symptom users hit. The dependency/test-env names mostly
|
||||
# mirror ``agent.skill_utils.EXCLUDED_SKILL_DIRS`` (the project's canonical
|
||||
# "regeneratable dir" set); ``.cache`` is an additional backup-only entry, as
|
||||
# it names a broad regeneratable cache convention (pip/uv/etc.) that the skill
|
||||
# scanner doesn't need to prune but a backup walk does. We deliberately do NOT
|
||||
# exclude ``.archive`` here because the curator's ``skills/.archive/`` holds
|
||||
# restorable user skills that must survive a backup.
|
||||
_EXCLUDED_DIRS = {
|
||||
"hermes-agent", # the codebase repo — re-clone instead
|
||||
"__pycache__", # bytecode caches — regenerated on import
|
||||
".git", # nested git dirs (profiles shouldn't have these, but safety)
|
||||
"node_modules", # js deps if website/ somehow leaks in
|
||||
"node_modules", # js deps — reinstalled on demand
|
||||
"backups", # prior auto-backups — don't nest backups exponentially
|
||||
"checkpoints", # session-local trajectory caches — regenerated per-session,
|
||||
# session-hash-keyed so they don't port to another machine anyway
|
||||
# Python dependency trees (plugin / MCP-server venvs under HERMES_HOME) —
|
||||
# regenerated by reinstalling; never irreplaceable state.
|
||||
".venv",
|
||||
"venv",
|
||||
"site-packages",
|
||||
# Tool / build caches — all regeneratable.
|
||||
".cache",
|
||||
".tox",
|
||||
".nox",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
}
|
||||
|
||||
# File-name suffixes to skip
|
||||
|
|
|
|||
|
|
@ -123,8 +123,8 @@ 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] [--provider name] [--global] [--refresh]"),
|
||||
CommandDef("model", "Switch model (persists by default)", "Configuration",
|
||||
args_hint="[model] [--provider name] [--global|--session] [--refresh]"),
|
||||
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
||||
"Configuration", aliases=("codex_runtime",),
|
||||
args_hint="[auto|codex_app_server]"),
|
||||
|
|
|
|||
|
|
@ -1581,6 +1581,14 @@ DEFAULT_CONFIG = {
|
|||
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
|
||||
# spinner), or ascii. Live-swappable via `/indicator <style>`.
|
||||
"tui_status_indicator": "kaomoji",
|
||||
# Seconds between prompt_toolkit redraws in the classic CLI when idle.
|
||||
# Default 1.0 keeps the wall-clock status-bar read-outs (idle-since-
|
||||
# last-turn) ticking and keeps the bottom chrome alive during idle —
|
||||
# without it prompt_toolkit stops repainting the status bar after a
|
||||
# turn and it can go stale/disappear (#45592).
|
||||
# Set 0 to disable the background refresh if it fights terminal
|
||||
# auto-scroll in non-fullscreen mode on some emulators (#48309).
|
||||
"cli_refresh_interval": 1.0,
|
||||
"user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
|
||||
"first_lines": 2,
|
||||
"last_lines": 2,
|
||||
|
|
@ -3453,6 +3461,7 @@ OPTIONAL_ENV_VARS = {
|
|||
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
|
||||
"im:history, im:read, im:write, users:read, files:read, files:write",
|
||||
"prompt": "Slack Bot Token (xoxb-...)",
|
||||
"help": "In your Slack app, add the required bot scopes, install the app to the workspace, then copy OAuth & Permissions > Bot User OAuth Token.",
|
||||
"url": "https://api.slack.com/apps",
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
|
|
@ -3462,10 +3471,19 @@ OPTIONAL_ENV_VARS = {
|
|||
"App-Level Tokens. Also ensure Event Subscriptions include: message.im, "
|
||||
"message.channels, message.groups, app_mention",
|
||||
"prompt": "Slack App Token (xapp-...)",
|
||||
"help": "In your Slack app, enable Socket Mode, then create Basic Information > App-Level Tokens with the connections:write scope.",
|
||||
"url": "https://api.slack.com/apps",
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
},
|
||||
"SLACK_ALLOWED_USERS": {
|
||||
"description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.",
|
||||
"prompt": "Allowed Slack member IDs",
|
||||
"help": "In Slack, open your profile, choose More or the three-dot menu, then Copy member ID. Add multiple IDs comma-separated.",
|
||||
"url": "https://api.slack.com/apps",
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"MATTERMOST_URL": {
|
||||
"description": "Mattermost server URL (e.g. https://mm.example.com)",
|
||||
"prompt": "Mattermost server URL",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ _GATEWAY_LIFECYCLE_PATTERNS = re.compile(
|
|||
r"(?i)"
|
||||
r"(hermes\s+gateway\s+(restart|stop|start))"
|
||||
r"|(launchctl\s+(kickstart|unload|load|stop|restart)\s+.*hermes)"
|
||||
r"|(systemctl\s+(restart|stop|start)\s+.*hermes)"
|
||||
r"|(systemctl\s+(-\S+\s+)*(restart|stop|start)\s+.*hermes)"
|
||||
r"|(p?kill\s+.*hermes.*gateway)"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -191,10 +191,10 @@ _PRIVACY_NOTICE = """\
|
|||
⚠️ This will upload the following to a public paste service:
|
||||
• System info (OS, Python version, Hermes version, provider, which API keys
|
||||
are configured — NOT the actual keys)
|
||||
• Recent log lines (agent.log, errors.log, gateway.log, desktop.log — may
|
||||
contain conversation fragments and file paths)
|
||||
• Full agent.log, gateway.log, and desktop.log (up to 512 KB each — likely
|
||||
contains conversation content, tool outputs, and file paths)
|
||||
• Recent log lines (agent.log, errors.log, gateway.log, gui.log, desktop.log
|
||||
— may contain conversation fragments and file paths)
|
||||
• Full agent.log, gateway.log, gui.log, and desktop.log (up to 512 KB each —
|
||||
likely contains conversation content, tool outputs, and file paths)
|
||||
|
||||
Pastes auto-delete after 6 hours.
|
||||
"""
|
||||
|
|
@ -503,6 +503,9 @@ def _capture_default_log_snapshots(
|
|||
"gateway": _capture_log_snapshot(
|
||||
"gateway", tail_lines=errors_lines, redact=redact
|
||||
),
|
||||
"gui": _capture_log_snapshot(
|
||||
"gui", tail_lines=errors_lines, redact=redact
|
||||
),
|
||||
"desktop": _capture_log_snapshot(
|
||||
"desktop", tail_lines=errors_lines, redact=redact
|
||||
),
|
||||
|
|
@ -574,6 +577,10 @@ def collect_debug_report(
|
|||
buf.write(log_snapshots["gateway"].tail_text)
|
||||
buf.write("\n\n")
|
||||
|
||||
buf.write(f"--- gui.log (last {errors_lines} lines) ---\n")
|
||||
buf.write(log_snapshots["gui"].tail_text)
|
||||
buf.write("\n\n")
|
||||
|
||||
buf.write(f"--- desktop.log (last {errors_lines} lines) ---\n")
|
||||
buf.write(log_snapshots["desktop"].tail_text)
|
||||
buf.write("\n")
|
||||
|
|
@ -639,6 +646,7 @@ def build_debug_share(
|
|||
)
|
||||
agent_log = log_snapshots["agent"].full_text
|
||||
gateway_log = log_snapshots["gateway"].full_text
|
||||
gui_log = log_snapshots["gui"].full_text
|
||||
desktop_log = log_snapshots["desktop"].full_text
|
||||
|
||||
# Prepend dump header to each full log so every paste is self-contained.
|
||||
|
|
@ -646,6 +654,8 @@ def build_debug_share(
|
|||
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
|
||||
if gateway_log:
|
||||
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
|
||||
if gui_log:
|
||||
gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log
|
||||
if desktop_log:
|
||||
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
|
||||
|
||||
|
|
@ -657,6 +667,8 @@ def build_debug_share(
|
|||
agent_log = _REDACTION_BANNER + agent_log
|
||||
if gateway_log:
|
||||
gateway_log = _REDACTION_BANNER + gateway_log
|
||||
if gui_log:
|
||||
gui_log = _REDACTION_BANNER + gui_log
|
||||
if desktop_log:
|
||||
desktop_log = _REDACTION_BANNER + desktop_log
|
||||
|
||||
|
|
@ -670,6 +682,7 @@ def build_debug_share(
|
|||
for label, content in (
|
||||
("agent.log", agent_log),
|
||||
("gateway.log", gateway_log),
|
||||
("gui.log", gui_log),
|
||||
("desktop.log", desktop_log),
|
||||
):
|
||||
if not content:
|
||||
|
|
@ -712,11 +725,14 @@ def run_debug_share(args):
|
|||
)
|
||||
agent_log = log_snapshots["agent"].full_text
|
||||
gateway_log = log_snapshots["gateway"].full_text
|
||||
gui_log = log_snapshots["gui"].full_text
|
||||
desktop_log = log_snapshots["desktop"].full_text
|
||||
if agent_log:
|
||||
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
|
||||
if gateway_log:
|
||||
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
|
||||
if gui_log:
|
||||
gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log
|
||||
if desktop_log:
|
||||
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
|
||||
if redact:
|
||||
|
|
@ -725,12 +741,15 @@ def run_debug_share(args):
|
|||
agent_log = _REDACTION_BANNER + agent_log
|
||||
if gateway_log:
|
||||
gateway_log = _REDACTION_BANNER + gateway_log
|
||||
if gui_log:
|
||||
gui_log = _REDACTION_BANNER + gui_log
|
||||
if desktop_log:
|
||||
desktop_log = _REDACTION_BANNER + desktop_log
|
||||
print(report)
|
||||
for title, body in (
|
||||
("FULL agent.log", agent_log),
|
||||
("FULL gateway.log", gateway_log),
|
||||
("FULL gui.log", gui_log),
|
||||
("FULL desktop.log", desktop_log),
|
||||
):
|
||||
if body:
|
||||
|
|
|
|||
|
|
@ -319,23 +319,12 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
|
|||
# gateway. See #13242.
|
||||
exclude_pids = exclude_pids | _get_ancestor_pids()
|
||||
pids: list[int] = []
|
||||
patterns = [
|
||||
"hermes_cli.main gateway",
|
||||
"hermes_cli.main --profile",
|
||||
"hermes_cli.main -p",
|
||||
"hermes_cli/main.py gateway",
|
||||
"hermes_cli/main.py --profile",
|
||||
"hermes_cli/main.py -p",
|
||||
"hermes gateway",
|
||||
# Windows: only match invocations that actually carry the ``gateway``
|
||||
# subcommand or the gateway-dedicated console-script shim. Bare
|
||||
# ``hermes.exe --profile`` / ``hermes.exe -p`` would also match
|
||||
# ``hermes.exe --profile foo dashboard`` and other CLI subcommands,
|
||||
# producing false-positive gateway PIDs (Copilot review).
|
||||
"hermes.exe gateway",
|
||||
"hermes-gateway.exe",
|
||||
"gateway/run.py",
|
||||
]
|
||||
# Strict command-line matcher shared with gateway.status: requires the
|
||||
# actual ``gateway run`` subcommand (or the dedicated entrypoints), so this
|
||||
# scan no longer false-matches ``gateway status``/``dashboard`` siblings or
|
||||
# unrelated processes like ``python -m tui_gateway``. Lazy import mirrors the
|
||||
# circular-import avoidance used elsewhere in this module.
|
||||
from gateway.status import looks_like_gateway_command_line
|
||||
current_home = str(get_hermes_home().resolve())
|
||||
current_home_lc = current_home.lower()
|
||||
current_profile_arg = _profile_arg(current_home)
|
||||
|
|
@ -430,8 +419,7 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
|
|||
current_cmd = line[len("CommandLine=") :]
|
||||
elif line.startswith("ProcessId="):
|
||||
pid_str = line[len("ProcessId=") :]
|
||||
current_cmd_lc = current_cmd.lower()
|
||||
if any(p in current_cmd_lc for p in patterns) and (
|
||||
if looks_like_gateway_command_line(current_cmd) and (
|
||||
all_profiles or _matches_current_profile(current_cmd)
|
||||
):
|
||||
try:
|
||||
|
|
@ -456,8 +444,7 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
|
|||
with open(f"/proc/{pid}/cmdline", "rb") as _f:
|
||||
cmdline = _f.read().decode("utf-8", errors="replace")
|
||||
cmdline = cmdline.replace("\x00", " ")
|
||||
cmdline_lc = cmdline.lower()
|
||||
if any(p in cmdline_lc for p in patterns) and (
|
||||
if looks_like_gateway_command_line(cmdline) and (
|
||||
all_profiles or _matches_current_profile(cmdline)
|
||||
):
|
||||
_append_unique_pid(pids, pid, exclude_pids)
|
||||
|
|
@ -500,8 +487,7 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
|
|||
|
||||
if pid is None:
|
||||
continue
|
||||
command_lc = command.lower()
|
||||
if any(pattern in command_lc for pattern in patterns) and (
|
||||
if looks_like_gateway_command_line(command) and (
|
||||
all_profiles or _matches_current_profile(command)
|
||||
):
|
||||
_append_unique_pid(pids, pid, exclude_pids)
|
||||
|
|
@ -3865,6 +3851,86 @@ def _running_under_gateway_supervisor() -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _guard_named_profile_under_multiplexer(force: bool = False) -> None:
|
||||
"""Refuse a named-profile gateway when a multiplexer is already serving it.
|
||||
|
||||
When the default profile's gateway runs with gateway.multiplex_profiles=on,
|
||||
it is the sole inbound process for EVERY profile on the host. Starting a
|
||||
separate gateway for a named profile would double-bind that profile's
|
||||
platforms (two pollers on one bot token, port fights). In that mode a
|
||||
named-profile ``hermes gateway run`` is always a misconfiguration, so we
|
||||
hard-error with a pointer to the multiplexer. ``--force`` overrides.
|
||||
|
||||
Inert unless ALL of: (a) this invocation is a named profile, (b) a default-
|
||||
profile gateway is running, (c) that gateway's config has multiplexing on.
|
||||
"""
|
||||
if force:
|
||||
return
|
||||
# (a) Are we a named profile? Default/custom-hash homes return "".
|
||||
try:
|
||||
suffix = _profile_suffix()
|
||||
except Exception:
|
||||
return
|
||||
if not suffix:
|
||||
return # default profile (or unrecognized) — this guard doesn't apply
|
||||
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
default_root = get_default_hermes_root()
|
||||
# (b) Is the default-profile gateway running?
|
||||
from gateway.status import get_running_pid as _default_running_pid # noqa
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
import yaml as _yaml
|
||||
from gateway.status import _read_pid_record # type: ignore
|
||||
|
||||
# (b) default gateway PID file present + alive
|
||||
default_pid_path = default_root / "gateway.pid"
|
||||
rec = _read_pid_record(default_pid_path)
|
||||
if not rec:
|
||||
return
|
||||
from gateway.status import _pid_exists, _pid_from_record
|
||||
pid = _pid_from_record(rec)
|
||||
if not pid or not _pid_exists(pid):
|
||||
return
|
||||
|
||||
# (c) default config has multiplexing on
|
||||
cfg_path = default_root / "config.yaml"
|
||||
if not cfg_path.exists():
|
||||
return
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
cfg = _yaml.safe_load(f) or {}
|
||||
multiplex = bool(
|
||||
cfg.get("multiplex_profiles")
|
||||
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
|
||||
)
|
||||
if not multiplex:
|
||||
return
|
||||
except Exception:
|
||||
logger.debug("Multiplexer-conflict probe failed", exc_info=True)
|
||||
return
|
||||
|
||||
print_error(
|
||||
f"The default gateway is running as a profile multiplexer and already "
|
||||
f"serves profile '{suffix}'."
|
||||
)
|
||||
print(
|
||||
" When gateway.multiplex_profiles is on, the default gateway is the\n"
|
||||
" single inbound process for every profile. Starting a separate\n"
|
||||
" gateway for this profile would double-bind its platforms (two\n"
|
||||
" pollers on one bot token, port conflicts).\n"
|
||||
)
|
||||
print(" Manage the multiplexer instead (from the default profile):")
|
||||
print()
|
||||
print(" hermes gateway restart")
|
||||
print()
|
||||
print(" Pass --force to start a separate profile gateway anyway (not")
|
||||
print(" recommended while the multiplexer is running).")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _guard_supervised_gateway_conflict(force: bool = False) -> None:
|
||||
"""Refuse a foreground gateway when a service manager already supervises one.
|
||||
|
||||
|
|
@ -3977,6 +4043,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, fo
|
|||
systemd/launchd service is already supervising this profile.
|
||||
"""
|
||||
_guard_official_docker_root_gateway()
|
||||
_guard_named_profile_under_multiplexer(force=force)
|
||||
_guard_supervised_gateway_conflict(force=force)
|
||||
_guard_existing_gateway_process_conflict(replace=replace)
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
|
|
|||
|
|
@ -1302,10 +1302,54 @@ def stop() -> None:
|
|||
print("✗ No gateway was running")
|
||||
|
||||
|
||||
def _wait_for_gateway_absent(timeout_s: float = 30.0, interval_s: float = 0.5) -> bool:
|
||||
"""Block until no gateway process is detectable, or the timeout elapses.
|
||||
|
||||
``stop()`` can return while the previous gateway is still draining
|
||||
in-flight agents (the drain runs up to the restart-drain timeout). Uses the
|
||||
authoritative ``get_running_pid()`` (lock + liveness + start-time +
|
||||
gateway-shape) plus the now-strict ``_gateway_pids()`` scan so a relaunch
|
||||
never races a still-alive old process.
|
||||
"""
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
deadline = time.monotonic() + max(timeout_s, interval_s)
|
||||
while time.monotonic() < deadline:
|
||||
if get_running_pid() is None and not _gateway_pids():
|
||||
return True
|
||||
time.sleep(interval_s)
|
||||
return get_running_pid() is None and not _gateway_pids()
|
||||
|
||||
|
||||
def restart() -> None:
|
||||
"""Stop the gateway then start it again."""
|
||||
"""Stop the gateway then start it again.
|
||||
|
||||
Waits for the old gateway to be authoritatively gone before relaunching --
|
||||
otherwise ``start()``'s "already running" guard sees the still-draining old
|
||||
process and no-ops, and when that process later exits nothing replaces it (a
|
||||
silent outage). Fails loudly if the process can't be cleared or the relaunch
|
||||
doesn't produce a running gateway.
|
||||
"""
|
||||
_assert_windows()
|
||||
from hermes_cli.gateway import kill_gateway_processes
|
||||
|
||||
stop()
|
||||
|
||||
if not _wait_for_gateway_absent(timeout_s=30.0):
|
||||
print("⚠ Gateway still present after stop; forcing termination before restart...")
|
||||
kill_gateway_processes(all_profiles=False, force=True)
|
||||
if not _wait_for_gateway_absent(timeout_s=10.0):
|
||||
raise RuntimeError(
|
||||
"Gateway process still detected after force kill; refusing to "
|
||||
"start a duplicate. Investigate stray PIDs before retrying."
|
||||
)
|
||||
|
||||
# Give Windows a moment to release the listening port.
|
||||
time.sleep(1.0)
|
||||
start()
|
||||
|
||||
if not _wait_for_gateway_ready(timeout_s=15.0):
|
||||
raise RuntimeError(
|
||||
"Gateway restart did not produce a running gateway process. "
|
||||
"Check logs/gateway.log and run `hermes gateway status`."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -121,6 +121,16 @@ DEFAULT_CLAIM_TTL_SECONDS = 15 * 60
|
|||
# effect of normal API traffic.
|
||||
DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS = 60 * 60
|
||||
|
||||
# Grace added to a claim when a reclaim is deferred because the previous
|
||||
# host-local worker is still alive after a termination attempt. Releasing the
|
||||
# claim in that state would spawn a duplicate alongside the surviving worker —
|
||||
# the runaway seen when a cgroup memory.high throttle parks a worker in
|
||||
# uninterruptible (D) state, where a pending SIGKILL cannot be delivered until
|
||||
# the throttle lifts. Holding the claim a short grace and retrying next tick
|
||||
# stops the duplication; once no duplicate is spawned the pressure eases, the
|
||||
# signal lands, and the following tick reclaims cleanly.
|
||||
RECLAIM_DEFER_GRACE_SECONDS = 120
|
||||
|
||||
|
||||
def _resolve_claim_ttl_seconds(ttl_seconds: Optional[int] = None) -> int:
|
||||
"""Return the effective claim TTL, honoring the kanban env override.
|
||||
|
|
@ -3286,6 +3296,14 @@ def release_stale_claims(
|
|||
termination = _terminate_reclaimed_worker(
|
||||
row["worker_pid"], row["claim_lock"], signal_fn=signal_fn,
|
||||
)
|
||||
# Never release a claim while our own worker is still alive: that would
|
||||
# spawn a duplicate beside it. Hold the claim and retry next tick.
|
||||
if _worker_survived_termination(termination):
|
||||
_defer_reclaim_for_live_worker(
|
||||
conn, row["id"], row["claim_lock"], now, termination,
|
||||
reason="ttl_expired_worker_alive",
|
||||
)
|
||||
continue
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
|
|
@ -5113,7 +5131,13 @@ def _terminate_reclaimed_worker(
|
|||
info["termination_attempted"] = True
|
||||
try:
|
||||
kill(int(pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, OSError):
|
||||
except ProcessLookupError:
|
||||
# Process is already gone — that's a successful termination, not a
|
||||
# survival. Leaving terminated=False here would make the reclaim guard
|
||||
# misread a dead worker as still-alive and defer forever.
|
||||
info["terminated"] = True
|
||||
return info
|
||||
except OSError:
|
||||
return info
|
||||
|
||||
for _ in range(10):
|
||||
|
|
@ -5136,6 +5160,63 @@ def _terminate_reclaimed_worker(
|
|||
return info
|
||||
|
||||
|
||||
def _worker_survived_termination(termination: dict) -> bool:
|
||||
"""True when we tried to kill our own host-local worker and it is still alive.
|
||||
|
||||
Reclaiming in this state would release the claim and let the dispatcher
|
||||
spawn a second worker while the first is still running — the duplication
|
||||
loop. Only host-local workers we actually signalled count: a non-local
|
||||
claim lock or a no-op attempt (no ``os.kill`` available) must fall through
|
||||
to the normal release path, since we cannot manage that worker anyway.
|
||||
"""
|
||||
return bool(
|
||||
termination.get("termination_attempted")
|
||||
and termination.get("host_local")
|
||||
and not termination.get("terminated")
|
||||
)
|
||||
|
||||
|
||||
def _defer_reclaim_for_live_worker(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
claim_lock: Optional[str],
|
||||
now: int,
|
||||
termination: dict,
|
||||
*,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Hold a claim whose worker survived termination instead of releasing it.
|
||||
|
||||
Extends ``claim_expires`` by ``RECLAIM_DEFER_GRACE_SECONDS`` so the task
|
||||
stays ``running`` (no duplicate spawn) and records a ``reclaim_deferred``
|
||||
event so the hold is visible in ``hermes kanban tail``. The next dispatch
|
||||
tick retries the kill; this is self-correcting because not spawning a
|
||||
duplicate is what lets the throttled worker finally die.
|
||||
"""
|
||||
grace = now + RECLAIM_DEFER_GRACE_SECONDS
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET claim_expires = ? "
|
||||
"WHERE id = ? AND status = 'running' AND claim_lock IS ?",
|
||||
(grace, task_id, claim_lock),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
return
|
||||
run_id = _current_run_id(conn, task_id)
|
||||
if run_id is not None:
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET claim_expires = ? WHERE id = ?",
|
||||
(grace, run_id),
|
||||
)
|
||||
payload = {
|
||||
"reason": reason,
|
||||
"claim_lock": claim_lock,
|
||||
"claim_expires_now": grace,
|
||||
}
|
||||
payload.update(termination)
|
||||
_append_event(conn, task_id, "reclaim_deferred", payload, run_id=run_id)
|
||||
|
||||
|
||||
def heartbeat_worker(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
|
|
@ -5374,6 +5455,15 @@ def detect_stale_running(
|
|||
pid, lock, signal_fn=signal_fn,
|
||||
)
|
||||
|
||||
# Never release a claim while our own worker is still alive: that would
|
||||
# spawn a duplicate beside it. Hold the claim and retry next tick.
|
||||
if _worker_survived_termination(termination):
|
||||
_defer_reclaim_for_live_worker(
|
||||
conn, tid, lock, now, termination,
|
||||
reason="heartbeat_stale_worker_alive",
|
||||
)
|
||||
continue
|
||||
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
|
|
|
|||
|
|
@ -299,34 +299,46 @@ class ModelSwitchResult:
|
|||
# Flag parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool]:
|
||||
"""Parse --provider, --global, and --refresh flags from /model command args.
|
||||
def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool, bool]:
|
||||
"""Parse --provider, --global, --session, and --refresh flags from /model command args.
|
||||
|
||||
Returns (model_input, explicit_provider, is_global, force_refresh).
|
||||
Returns ``(model_input, explicit_provider, is_global, force_refresh, is_session)``.
|
||||
|
||||
``is_global`` and ``is_session`` are independent flag presences; the
|
||||
*effective* persistence decision is resolved by
|
||||
:func:`resolve_persist_behavior` so the config-gated default
|
||||
(``model.persist_switch_by_default``) is applied in one place.
|
||||
|
||||
Examples::
|
||||
|
||||
"sonnet" -> ("sonnet", "", False, False)
|
||||
"sonnet --global" -> ("sonnet", "", True, False)
|
||||
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False, False)
|
||||
"--provider my-ollama" -> ("", "my-ollama", False, False)
|
||||
"--refresh" -> ("", "", False, True)
|
||||
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True, False)
|
||||
"sonnet" -> ("sonnet", "", False, False, False)
|
||||
"sonnet --global" -> ("sonnet", "", True, False, False)
|
||||
"sonnet --session" -> ("sonnet", "", False, False, True)
|
||||
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False, False, False)
|
||||
"--provider my-ollama" -> ("", "my-ollama", False, False, False)
|
||||
"--refresh" -> ("", "", False, True, False)
|
||||
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True, False, False)
|
||||
"""
|
||||
is_global = False
|
||||
explicit_provider = ""
|
||||
force_refresh = False
|
||||
is_session = False
|
||||
|
||||
# Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash)
|
||||
# A single Unicode dash before a flag keyword becomes "--"
|
||||
import re as _re
|
||||
raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|refresh)', r'--\1', raw_args)
|
||||
raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|session|refresh)', r'--\1', raw_args)
|
||||
|
||||
# Extract --global
|
||||
if "--global" in raw_args:
|
||||
is_global = True
|
||||
raw_args = raw_args.replace("--global", "").strip()
|
||||
|
||||
# Extract --session (explicit session-only; overrides the persist default)
|
||||
if "--session" in raw_args:
|
||||
is_session = True
|
||||
raw_args = raw_args.replace("--session", "").strip()
|
||||
|
||||
# Extract --refresh (bust the model picker disk cache before listing)
|
||||
if "--refresh" in raw_args:
|
||||
force_refresh = True
|
||||
|
|
@ -345,7 +357,37 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool]:
|
|||
i += 1
|
||||
|
||||
model_input = " ".join(filtered).strip()
|
||||
return (model_input, explicit_provider, is_global, force_refresh)
|
||||
return (model_input, explicit_provider, is_global, force_refresh, is_session)
|
||||
|
||||
|
||||
def resolve_persist_behavior(is_global: bool, is_session: bool) -> bool:
|
||||
"""Decide whether a ``/model`` switch should persist to ``config.yaml``.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. ``--session`` explicitly opts out → ``False`` (this session only).
|
||||
2. ``--global`` explicitly opts in → ``True``.
|
||||
3. Otherwise defer to ``model.persist_switch_by_default`` in
|
||||
``config.yaml`` (defaults to ``True``, so a plain ``/model <name>``
|
||||
survives across sessions — the behavior users expect).
|
||||
|
||||
The config read is defensive: on a fresh install ``model`` may be a
|
||||
flat string rather than a dict, in which case the built-in default
|
||||
(``True``) applies.
|
||||
"""
|
||||
if is_session:
|
||||
return False
|
||||
if is_global:
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
model_cfg = load_config().get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
return bool(model_cfg.get("persist_switch_by_default", True))
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import subprocess
|
|||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from agent.skill_utils import is_excluded_skill_path
|
||||
|
||||
|
|
@ -781,6 +781,47 @@ def list_profiles() -> List[ProfileInfo]:
|
|||
return profiles
|
||||
|
||||
|
||||
def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
|
||||
"""Return the ``(profile_name, hermes_home)`` pairs a gateway should serve.
|
||||
|
||||
This is the single chokepoint for "which profiles does the inbound gateway
|
||||
handle" so later multiplexing phases never re-derive the set.
|
||||
|
||||
- ``multiplex=False`` (default): returns exactly one entry for the *active*
|
||||
profile — byte-for-byte the single-profile behavior the gateway has
|
||||
always had. The name is ``"default"`` for the default profile or the
|
||||
active named profile's id.
|
||||
- ``multiplex=True``: returns the default profile plus every valid named
|
||||
profile under ``profiles/``, each paired with its own HERMES_HOME.
|
||||
|
||||
Intentionally lightweight (a directory scan + name validation only): no
|
||||
per-profile config reads, gateway-running probes, or skill counts like
|
||||
:func:`list_profiles`. It runs on gateway startup and must stay cheap.
|
||||
|
||||
The returned ``hermes_home`` is the path to pass to
|
||||
``set_hermes_home_override`` when scoping a turn to that profile.
|
||||
"""
|
||||
active = get_active_profile_name() or "default"
|
||||
if not multiplex:
|
||||
return [(active, get_profile_dir(active))]
|
||||
|
||||
serve: List[Tuple[str, Path]] = [("default", _get_default_hermes_home())]
|
||||
|
||||
profiles_root = _get_profiles_root()
|
||||
if profiles_root.is_dir():
|
||||
for entry in sorted(profiles_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
name = entry.name
|
||||
if name == "default":
|
||||
continue # default is the built-in entry already added above
|
||||
if not _PROFILE_ID_RE.match(name):
|
||||
continue
|
||||
serve.append((name, entry))
|
||||
|
||||
return serve
|
||||
|
||||
|
||||
def create_profile(
|
||||
name: str,
|
||||
clone_from: Optional[str] = None,
|
||||
|
|
|
|||
170
hermes_cli/provider_catalog.py
Normal file
170
hermes_cli/provider_catalog.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Unified provider catalog — one source of truth for the provider universe.
|
||||
|
||||
The provider list shown by ``hermes model`` (CLI/TUI) and the desktop Settings
|
||||
→ Providers tabs (Accounts + API keys) **must be the same set**. Historically
|
||||
they were not: the CLI picker read :data:`hermes_cli.models.CANONICAL_PROVIDERS`
|
||||
(which auto-extends from ``plugins/model-providers/<name>/``), while the desktop
|
||||
tabs read separate hand-maintained lists (``_OAUTH_PROVIDER_CATALOG``,
|
||||
``OPTIONAL_ENV_VARS`` + ``PROVIDER_GROUPS``) that nobody kept in sync. Every
|
||||
provider added after those lists were written silently went missing from the
|
||||
GUI — e.g. GitHub Copilot showing up only under "tools", or ``openai-api`` being
|
||||
configurable from the CLI but not the desktop app.
|
||||
|
||||
This module fixes that at the root: it derives ONE descriptor per provider from
|
||||
the same universe ``hermes model`` renders (``CANONICAL_PROVIDERS``), joining:
|
||||
|
||||
* ``auth_type`` / ``api_key_env_vars`` / ``base_url_env_var`` from
|
||||
:data:`hermes_cli.auth.PROVIDER_REGISTRY` (credential truth), and
|
||||
* ``display_name`` / ``description`` / ``signup_url`` from the provider's
|
||||
:class:`providers.base.ProviderProfile` when one exists, falling back to the
|
||||
``CANONICAL_PROVIDERS`` entry's ``label`` / ``tui_desc`` and the
|
||||
``OPTIONAL_ENV_VARS`` signup URL otherwise (many profiles leave these blank,
|
||||
and four canonical providers have no profile at all — lmstudio, openai-api,
|
||||
tencent-tokenhub, xai-oauth — so the fallbacks are load-bearing).
|
||||
|
||||
Each descriptor is tagged with the ``tab`` it belongs on (``keys`` vs
|
||||
``accounts``) based purely on how the provider authenticates. The desktop
|
||||
``/api/env`` and ``/api/providers/oauth`` endpoints derive their MEMBERSHIP from
|
||||
this catalog; the old hand lists are demoted to presentation/override overlays
|
||||
(bespoke OAuth flow + status resolvers, richer copy, icons, ordering) and no
|
||||
longer decide which providers exist.
|
||||
|
||||
Parity contract (locked by tests): the union of the two tabs equals the
|
||||
``CANONICAL_PROVIDERS`` universe, i.e. exactly what ``hermes model`` shows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Auth types that authenticate via an account / sign-in flow rather than a
|
||||
# pasted API key. These route to the desktop "Accounts" tab; everything else
|
||||
# (api_key, and aws_sdk which is configured via AWS_REGION/AWS_PROFILE) routes
|
||||
# to the "API keys" tab. Mirrors the auth_type strings used in
|
||||
# hermes_cli.auth.PROVIDER_REGISTRY and providers.base.ProviderProfile.
|
||||
_ACCOUNTS_AUTH_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"oauth_device_code",
|
||||
"oauth_external",
|
||||
"oauth_minimax",
|
||||
"external_process", # copilot-acp: spawns `copilot --acp --stdio`
|
||||
"copilot", # GitHub Copilot token / gh auth
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDescriptor:
|
||||
"""One provider, as seen by every surface (CLI picker + both GUI tabs)."""
|
||||
|
||||
slug: str # canonical id, e.g. "google-gemini-cli"
|
||||
label: str # human display name
|
||||
description: str # one-line description
|
||||
auth_type: str # api_key | oauth_* | external_process | copilot | aws_sdk
|
||||
tab: str # "keys" | "accounts"
|
||||
api_key_env_vars: tuple[str, ...] # credential env vars (may be empty)
|
||||
base_url_env_var: str # base-URL override env var (may be "")
|
||||
signup_url: str # signup / console URL (may be "")
|
||||
order: int # CANONICAL_PROVIDERS index — mirrors `hermes model`
|
||||
|
||||
|
||||
def tab_for_auth_type(auth_type: str) -> str:
|
||||
"""Return the desktop tab ("keys"|"accounts") a provider's auth maps to."""
|
||||
return "accounts" if auth_type in _ACCOUNTS_AUTH_TYPES else "keys"
|
||||
|
||||
|
||||
def _split_env_vars(env_vars: tuple[str, ...]) -> tuple[tuple[str, ...], str]:
|
||||
"""Split a profile's ``env_vars`` into (api_key_vars, base_url_var)."""
|
||||
keys = tuple(v for v in env_vars if not (v.endswith("_BASE_URL") or v.endswith("_URL")))
|
||||
base = next((v for v in env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), "")
|
||||
return keys, base
|
||||
|
||||
|
||||
def provider_catalog() -> list[ProviderDescriptor]:
|
||||
"""Return one descriptor per provider in the ``hermes model`` universe.
|
||||
|
||||
Membership is :data:`CANONICAL_PROVIDERS` (the list the CLI/TUI picker
|
||||
renders, which auto-extends from provider plugins). Auth + env come from
|
||||
``PROVIDER_REGISTRY``; display metadata from ``ProviderProfile`` with
|
||||
canonical/env fallbacks so providers without a profile (or with blank
|
||||
profile metadata) still resolve sensibly.
|
||||
"""
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS
|
||||
|
||||
# PROVIDER_REGISTRY / list_providers are imported lazily and defensively:
|
||||
# this module is on the import path of the web server and the CLI, and we
|
||||
# never want a provider-plugin import error to blank the whole catalog.
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
except Exception:
|
||||
PROVIDER_REGISTRY = {}
|
||||
|
||||
try:
|
||||
from providers import list_providers
|
||||
|
||||
profiles = {p.name: p for p in list_providers()}
|
||||
except Exception:
|
||||
profiles = {}
|
||||
|
||||
try:
|
||||
from hermes_cli.config import OPTIONAL_ENV_VARS
|
||||
except Exception:
|
||||
OPTIONAL_ENV_VARS = {}
|
||||
|
||||
out: list[ProviderDescriptor] = []
|
||||
for order, entry in enumerate(CANONICAL_PROVIDERS):
|
||||
slug = entry.slug
|
||||
cfg = PROVIDER_REGISTRY.get(slug)
|
||||
prof = profiles.get(slug)
|
||||
|
||||
# auth_type: registry is authoritative; fall back to profile, then api_key.
|
||||
auth_type = (
|
||||
(getattr(cfg, "auth_type", "") if cfg else "")
|
||||
or (getattr(prof, "auth_type", "") if prof else "")
|
||||
or "api_key"
|
||||
)
|
||||
|
||||
# Credential env vars: registry first (it already normalizes these),
|
||||
# else derive from the profile's env_vars tuple.
|
||||
if cfg and getattr(cfg, "api_key_env_vars", ()):
|
||||
api_key_vars = tuple(cfg.api_key_env_vars)
|
||||
base_url_var = getattr(cfg, "base_url_env_var", "") or ""
|
||||
elif prof and getattr(prof, "env_vars", ()):
|
||||
api_key_vars, base_url_var = _split_env_vars(tuple(prof.env_vars))
|
||||
else:
|
||||
api_key_vars, base_url_var = (), ""
|
||||
|
||||
label = (
|
||||
(getattr(prof, "display_name", "") if prof else "")
|
||||
or entry.label
|
||||
or slug
|
||||
)
|
||||
description = (
|
||||
(getattr(prof, "description", "") if prof else "")
|
||||
or entry.tui_desc
|
||||
or label
|
||||
)
|
||||
signup_url = (getattr(prof, "signup_url", "") if prof else "") or ""
|
||||
if not signup_url and api_key_vars:
|
||||
info = OPTIONAL_ENV_VARS.get(api_key_vars[0]) or {}
|
||||
signup_url = info.get("url") or ""
|
||||
|
||||
out.append(
|
||||
ProviderDescriptor(
|
||||
slug=slug,
|
||||
label=label,
|
||||
description=description,
|
||||
auth_type=auth_type,
|
||||
tab=tab_for_auth_type(auth_type),
|
||||
api_key_env_vars=api_key_vars,
|
||||
base_url_env_var=base_url_var,
|
||||
signup_url=signup_url,
|
||||
order=order,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def provider_catalog_by_slug() -> dict[str, ProviderDescriptor]:
|
||||
"""Convenience: the catalog keyed by slug."""
|
||||
return {d.slug: d for d in provider_catalog()}
|
||||
|
|
@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
from hermes_cli import auth as auth_mod
|
||||
from agent.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool
|
||||
from agent.secret_scope import get_secret as _get_secret
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
|
|
@ -35,6 +36,19 @@ from hermes_constants import OPENROUTER_BASE_URL
|
|||
from utils import base_url_host_matches, base_url_hostname, env_int
|
||||
|
||||
|
||||
def _getenv(name: str, default: str = "") -> str:
|
||||
"""Profile-scoped replacement for ``os.getenv`` on credential/provider reads.
|
||||
|
||||
Routes through the secret scope (Workstream A): identical to ``os.getenv``
|
||||
when multiplexing is off, scope-aware (and fail-closed on an unscoped read)
|
||||
when on. Genuinely-global vars are handled inside ``get_secret`` and still
|
||||
read ``os.environ``. Keeps the ``(name, default) -> str`` contract every
|
||||
call site here already relies on.
|
||||
"""
|
||||
val = _get_secret(name, default)
|
||||
return val if val is not None else default
|
||||
|
||||
|
||||
def _normalize_custom_provider_name(value: str) -> str:
|
||||
return value.strip().lower().replace(" ", "-")
|
||||
|
||||
|
|
@ -156,7 +170,7 @@ def _host_derived_api_key(base_url: str) -> str:
|
|||
if sanitized in ("OPENAI", "OPENROUTER", "OLLAMA"):
|
||||
return ""
|
||||
env_name = f"{sanitized}_API_KEY"
|
||||
return (os.getenv(env_name, "") or "").strip()
|
||||
return (_getenv(env_name, "") or "").strip()
|
||||
|
||||
|
||||
def _auto_detect_local_model(base_url: str) -> str:
|
||||
|
|
@ -437,7 +451,7 @@ def resolve_requested_provider(requested: Optional[str] = None) -> str:
|
|||
|
||||
# Prefer the persisted config selection over any stale shell/.env
|
||||
# provider override so chat uses the endpoint the user last saved.
|
||||
env_provider = os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
|
||||
env_provider = _getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
|
||||
if env_provider:
|
||||
return env_provider
|
||||
|
||||
|
|
@ -542,7 +556,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
|||
name_norm = _normalize_custom_provider_name(ep_name)
|
||||
# Resolve the API key from the env var name stored in key_env
|
||||
key_env = str(entry.get("key_env", "") or "").strip()
|
||||
resolved_api_key = os.getenv(key_env, "").strip() if key_env else ""
|
||||
resolved_api_key = _getenv(key_env, "").strip() if key_env else ""
|
||||
# Fall back to inline api_key when key_env is absent or unresolvable
|
||||
if not resolved_api_key:
|
||||
resolved_api_key = str(entry.get("api_key", "") or "").strip()
|
||||
|
|
@ -824,8 +838,8 @@ def _resolve_named_custom_runtime(
|
|||
api_key_candidates = [
|
||||
(explicit_api_key or "").strip(),
|
||||
# Gate env key fallbacks on authoritative hosts (#28660)
|
||||
(os.getenv("OPENAI_API_KEY", "").strip() if _da_is_openai_url else ""),
|
||||
(os.getenv("OPENROUTER_API_KEY", "").strip() if _da_is_openrouter else ""),
|
||||
(_getenv("OPENAI_API_KEY", "").strip() if _da_is_openai_url else ""),
|
||||
(_getenv("OPENROUTER_API_KEY", "").strip() if _da_is_openrouter else ""),
|
||||
# Bonus (#28660): derive `<VENDOR>_API_KEY` from the host so users
|
||||
# who set DEEPSEEK_API_KEY / GROQ_API_KEY / MISTRAL_API_KEY get the
|
||||
# intuitive match without configuring `custom_providers` first.
|
||||
|
|
@ -878,11 +892,11 @@ def _resolve_named_custom_runtime(
|
|||
api_key_candidates = [
|
||||
(explicit_api_key or "").strip(),
|
||||
str(custom_provider.get("api_key", "") or "").strip(),
|
||||
os.getenv(str(custom_provider.get("key_env", "") or "").strip(), "").strip(),
|
||||
_getenv(str(custom_provider.get("key_env", "") or "").strip(), "").strip(),
|
||||
# Gate provider env keys on their authoritative hosts — sending
|
||||
# OPENAI_API_KEY to a local-llm endpoint leaks credentials (#28660).
|
||||
(os.getenv("OPENAI_API_KEY", "").strip() if _cp_is_openai_url else ""),
|
||||
(os.getenv("OPENROUTER_API_KEY", "").strip() if _cp_is_openrouter else ""),
|
||||
(_getenv("OPENAI_API_KEY", "").strip() if _cp_is_openai_url else ""),
|
||||
(_getenv("OPENROUTER_API_KEY", "").strip() if _cp_is_openrouter else ""),
|
||||
# Bonus (#28660): derive `<VENDOR>_API_KEY` from the host as a final
|
||||
# fallback when key_env wasn't set explicitly.
|
||||
_host_derived_api_key(base_url),
|
||||
|
|
@ -941,8 +955,8 @@ def _resolve_openrouter_runtime(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip()
|
||||
env_custom_base_url = os.getenv("CUSTOM_BASE_URL", "").strip()
|
||||
env_openrouter_base_url = _getenv("OPENROUTER_BASE_URL", "").strip()
|
||||
env_custom_base_url = _getenv("CUSTOM_BASE_URL", "").strip()
|
||||
|
||||
# Use config base_url when available and the provider context matches.
|
||||
# OPENAI_BASE_URL env var is no longer consulted — config.yaml is
|
||||
|
|
@ -982,8 +996,8 @@ def _resolve_openrouter_runtime(
|
|||
if _is_openrouter_context:
|
||||
api_key_candidates = [
|
||||
explicit_api_key,
|
||||
os.getenv("OPENROUTER_API_KEY"),
|
||||
os.getenv("OPENAI_API_KEY"),
|
||||
_getenv("OPENROUTER_API_KEY"),
|
||||
_getenv("OPENAI_API_KEY"),
|
||||
]
|
||||
else:
|
||||
# Custom endpoint: use api_key from config when using config base_url (#1760).
|
||||
|
|
@ -1003,9 +1017,9 @@ def _resolve_openrouter_runtime(
|
|||
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") if (_is_openai_url or _is_openai_azure) else ""),
|
||||
(os.getenv("OPENROUTER_API_KEY") if _is_openrouter_url else ""),
|
||||
(_getenv("OLLAMA_API_KEY") if _is_ollama_url else ""),
|
||||
(_getenv("OPENAI_API_KEY") if (_is_openai_url or _is_openai_azure) else ""),
|
||||
(_getenv("OPENROUTER_API_KEY") if _is_openrouter_url else ""),
|
||||
# Bonus (#28660): derive `<VENDOR>_API_KEY` from the host so users
|
||||
# who set DEEPSEEK_API_KEY / GROQ_API_KEY / MISTRAL_API_KEY get the
|
||||
# intuitive match. Helper returns "" for IPs/loopback and for env
|
||||
|
|
@ -1108,7 +1122,7 @@ def _resolve_azure_foundry_runtime(
|
|||
if inferred:
|
||||
cfg_api_mode = inferred
|
||||
|
||||
env_base_url = os.getenv("AZURE_FOUNDRY_BASE_URL", "").strip().rstrip("/")
|
||||
env_base_url = _getenv("AZURE_FOUNDRY_BASE_URL", "").strip().rstrip("/")
|
||||
base_url = explicit_base_url_clean or cfg_base_url or env_base_url
|
||||
if not base_url:
|
||||
raise AuthError(
|
||||
|
|
@ -1197,7 +1211,7 @@ def _resolve_azure_foundry_runtime(
|
|||
except Exception:
|
||||
api_key = ""
|
||||
if not api_key:
|
||||
api_key = os.getenv("AZURE_FOUNDRY_API_KEY", "").strip()
|
||||
api_key = _getenv("AZURE_FOUNDRY_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise AuthError(
|
||||
"Azure Foundry requires an API key. Set AZURE_FOUNDRY_API_KEY in "
|
||||
|
|
@ -1297,7 +1311,7 @@ def _resolve_explicit_runtime(
|
|||
expires_at = state.get("agent_key_expires_at") or state.get("expires_at")
|
||||
if not api_key:
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
timeout_seconds=float(_getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
)
|
||||
api_key = creds.get("api_key", "")
|
||||
expires_at = creds.get("expires_at")
|
||||
|
|
@ -1326,7 +1340,7 @@ def _resolve_explicit_runtime(
|
|||
if pconfig and pconfig.auth_type == "api_key":
|
||||
env_url = ""
|
||||
if pconfig.base_url_env_var:
|
||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/")
|
||||
env_url = _getenv(pconfig.base_url_env_var, "").strip().rstrip("/")
|
||||
|
||||
base_url = explicit_base_url
|
||||
if not base_url:
|
||||
|
|
@ -1398,8 +1412,8 @@ def resolve_runtime_provider(
|
|||
if requested_provider == "anthropic" and "azure.com" in _eff_base:
|
||||
_azure_key = (
|
||||
(explicit_api_key or "").strip()
|
||||
or os.getenv("AZURE_ANTHROPIC_KEY", "").strip()
|
||||
or os.getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
or _getenv("AZURE_ANTHROPIC_KEY", "").strip()
|
||||
or _getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
)
|
||||
return {
|
||||
"provider": "anthropic",
|
||||
|
|
@ -1454,8 +1468,8 @@ def resolve_runtime_provider(
|
|||
if provider == "openrouter":
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
cfg_base_url = str(model_cfg.get("base_url") or "").strip()
|
||||
env_openai_base_url = os.getenv("OPENAI_BASE_URL", "").strip()
|
||||
env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip()
|
||||
env_openai_base_url = _getenv("OPENAI_BASE_URL", "").strip()
|
||||
env_openrouter_base_url = _getenv("OPENROUTER_BASE_URL", "").strip()
|
||||
has_custom_endpoint = bool(
|
||||
explicit_base_url
|
||||
or env_openai_base_url
|
||||
|
|
@ -1511,7 +1525,7 @@ def resolve_runtime_provider(
|
|||
if provider == "nous":
|
||||
try:
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
timeout_seconds=float(_getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
)
|
||||
return {
|
||||
"provider": "nous",
|
||||
|
|
@ -1664,7 +1678,7 @@ def resolve_runtime_provider(
|
|||
for hint_key in ("key_env", "api_key_env"):
|
||||
env_var = str(model_cfg.get(hint_key) or "").strip()
|
||||
if env_var:
|
||||
token = os.getenv(env_var, "").strip()
|
||||
token = _getenv(env_var, "").strip()
|
||||
if token:
|
||||
break
|
||||
# Next: an inline api_key on the model config (useful in multi-profile
|
||||
|
|
@ -1674,8 +1688,8 @@ def resolve_runtime_provider(
|
|||
# Finally fall back to the historical fixed names.
|
||||
if not token:
|
||||
token = (
|
||||
os.getenv("AZURE_ANTHROPIC_KEY", "").strip()
|
||||
or os.getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
_getenv("AZURE_ANTHROPIC_KEY", "").strip()
|
||||
or _getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
)
|
||||
if not token:
|
||||
raise AuthError(
|
||||
|
|
|
|||
|
|
@ -1554,6 +1554,7 @@ async def upload_managed_file_stream(
|
|||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
total = 0
|
||||
renamed = False
|
||||
try:
|
||||
with os.fdopen(tmp_fd, "wb") as out:
|
||||
while True:
|
||||
|
|
@ -1565,16 +1566,21 @@ async def upload_managed_file_stream(
|
|||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
out.write(chunk)
|
||||
os.replace(tmp_path, target)
|
||||
renamed = True
|
||||
except HTTPException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except PermissionError:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=403, detail="File is not writable")
|
||||
except OSError as exc:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
|
||||
finally:
|
||||
# Clean up the temp file on every non-success exit, including
|
||||
# BaseException paths the `except` clauses above don't catch — most
|
||||
# importantly asyncio.CancelledError when a browser aborts a large
|
||||
# upload mid-stream (the exact NS-501 scenario). os.replace clears
|
||||
# tmp_path on success, so only unlink when the rename didn't happen.
|
||||
if not renamed:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
await file.close()
|
||||
|
||||
return {
|
||||
|
|
@ -2316,6 +2322,43 @@ def _gateway_display_command(profile: Optional[str], verb: str) -> str:
|
|||
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
|
||||
|
||||
|
||||
# Slack member IDs (users U..., Enterprise Grid W...). Kept in sync with the
|
||||
# frontend SLACK_MEMBER_ID_RE in web/src/pages/ChannelsPage.tsx.
|
||||
_SLACK_MEMBER_ID_RE = re.compile(r"[UW][A-Z0-9]{2,}")
|
||||
|
||||
|
||||
def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None:
|
||||
"""Reject platform credentials that are clearly in the wrong field."""
|
||||
if platform_id != "slack" or not value:
|
||||
return
|
||||
|
||||
if key == "SLACK_BOT_TOKEN" and not value.startswith("xoxb-"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Slack Bot Token must start with xoxb-. Paste the bot token from OAuth & Permissions.",
|
||||
)
|
||||
if key == "SLACK_APP_TOKEN" and not value.startswith("xapp-"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.",
|
||||
)
|
||||
if key == "SLACK_ALLOWED_USERS":
|
||||
# Mirror the gateway's parse (gateway/platforms/slack.py): split on comma,
|
||||
# strip, and drop empty entries so a trailing/interior comma isn't rejected
|
||||
# here when the runtime would accept it. "*" is the allow-all wildcard.
|
||||
user_ids = [part.strip() for part in value.split(",") if part.strip()]
|
||||
invalid = [
|
||||
user_id
|
||||
for user_id in user_ids
|
||||
if user_id != "*" and not _SLACK_MEMBER_ID_RE.fullmatch(user_id)
|
||||
]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Slack allowed user IDs must be comma-separated member IDs like U01ABC2DEF3.",
|
||||
)
|
||||
|
||||
|
||||
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
|
||||
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
|
||||
|
||||
|
|
@ -3925,28 +3968,135 @@ async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
|
|||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
def _catalog_provider_env_metadata() -> dict:
|
||||
"""Map provider env vars → desktop card metadata, derived from the catalog.
|
||||
|
||||
Returns ``{env_var: {provider, provider_label, description, url, is_password,
|
||||
advanced}}`` for every API-key provider in the unified ``provider_catalog()``
|
||||
(i.e. the ``hermes model`` universe). This is what lets the desktop Keys tab
|
||||
render a card for a provider even when its env var was never hand-added to
|
||||
``OPTIONAL_ENV_VARS`` — closing the drift where CLI-configurable providers
|
||||
(openai-api, kilocode, novita, tencent-tokenhub, copilot, …) were missing
|
||||
from the GUI.
|
||||
|
||||
Hand ``OPTIONAL_ENV_VARS`` prose is layered ON TOP of this in the endpoint;
|
||||
this only supplies membership + grouping + sensible fallbacks.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.provider_catalog import provider_catalog
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# Env vars already declared with a NON-provider category (e.g. the shared
|
||||
# GITHUB_TOKEN, which is a Skills-Hub "tool" credential) must not be
|
||||
# promoted into a provider card. Copilot lists GITHUB_TOKEN among its auth
|
||||
# aliases, but its provider card uses the provider-owned COPILOT_GITHUB_TOKEN.
|
||||
try:
|
||||
from hermes_cli.config import OPTIONAL_ENV_VARS as _OPT
|
||||
except Exception:
|
||||
_OPT = {}
|
||||
_non_provider_keys = {
|
||||
k for k, v in _OPT.items()
|
||||
if (v or {}).get("category") and (v or {}).get("category") != "provider"
|
||||
}
|
||||
|
||||
meta: dict = {}
|
||||
for d in provider_catalog():
|
||||
if d.tab != "keys":
|
||||
continue
|
||||
# API-key vars: the first is the primary (password) field; any aliases
|
||||
# are kept as additional password fields so users can clear them too.
|
||||
for env_var in d.api_key_env_vars:
|
||||
if env_var in _non_provider_keys:
|
||||
continue # don't hijack a shared tool/messaging credential
|
||||
meta.setdefault(
|
||||
env_var,
|
||||
{
|
||||
"provider": d.slug,
|
||||
"provider_label": d.label,
|
||||
"description": d.description,
|
||||
"url": d.signup_url or None,
|
||||
"is_password": True,
|
||||
"advanced": False,
|
||||
"category": "provider",
|
||||
},
|
||||
)
|
||||
# Base-URL override is an advanced, non-secret field for the same card.
|
||||
if d.base_url_env_var:
|
||||
meta.setdefault(
|
||||
d.base_url_env_var,
|
||||
{
|
||||
"provider": d.slug,
|
||||
"provider_label": d.label,
|
||||
"description": f"{d.label} base URL override",
|
||||
"url": None,
|
||||
"is_password": False,
|
||||
"advanced": True,
|
||||
"category": "provider",
|
||||
},
|
||||
)
|
||||
|
||||
# AWS-SDK providers (Bedrock) authenticate via the AWS credential chain
|
||||
# rather than a pasted API key, so they have no api_key_env_vars. Tag
|
||||
# their AWS_* settings to the provider card so they still appear on the
|
||||
# Keys tab (otherwise Bedrock — a `hermes model` provider — would be
|
||||
# invisible in the desktop app).
|
||||
if d.auth_type == "aws_sdk":
|
||||
for aws_var in ("AWS_REGION", "AWS_PROFILE"):
|
||||
existing = meta.get(aws_var, {})
|
||||
meta[aws_var] = {
|
||||
"provider": d.slug,
|
||||
"provider_label": d.label,
|
||||
"description": existing.get("description") or f"{d.label} ({aws_var})",
|
||||
"url": existing.get("url"),
|
||||
"is_password": False,
|
||||
"advanced": existing.get("advanced", True),
|
||||
"category": "provider",
|
||||
}
|
||||
return meta
|
||||
|
||||
|
||||
@app.get("/api/env")
|
||||
async def get_env_vars(profile: Optional[str] = None):
|
||||
with _profile_scope(profile):
|
||||
env_on_disk = load_env()
|
||||
channel_keys = _channel_managed_env_keys()
|
||||
result = {}
|
||||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||||
catalog_meta = _catalog_provider_env_metadata()
|
||||
|
||||
def _row(var_name: str, info: dict) -> dict:
|
||||
value = env_on_disk.get(var_name)
|
||||
result[var_name] = {
|
||||
cat_meta = catalog_meta.get(var_name) or {}
|
||||
# Hand OPTIONAL_ENV_VARS prose wins where present; the catalog fills any
|
||||
# gaps (description/url) and always supplies provider grouping hints.
|
||||
return {
|
||||
"is_set": bool(value),
|
||||
"redacted_value": redact_key(value) if value else None,
|
||||
"description": info.get("description", ""),
|
||||
"url": info.get("url"),
|
||||
"category": info.get("category", ""),
|
||||
"is_password": info.get("password", False),
|
||||
"description": info.get("description") or cat_meta.get("description", ""),
|
||||
"url": info.get("url") if info.get("url") is not None else cat_meta.get("url"),
|
||||
"category": info.get("category") or cat_meta.get("category", ""),
|
||||
"is_password": info.get("password", cat_meta.get("is_password", False)),
|
||||
"tools": info.get("tools", []),
|
||||
"advanced": info.get("advanced", False),
|
||||
"advanced": info.get("advanced", cat_meta.get("advanced", False)),
|
||||
# True when this var is a messaging-platform credential owned by a
|
||||
# Channels page card. The Keys/Env page uses this to hide it and
|
||||
# avoid duplicating the (richer) Channels configuration UI.
|
||||
"channel_managed": var_name in channel_keys,
|
||||
# Provider grouping hints derived from the unified provider catalog
|
||||
# so the desktop Keys tab groups by the SAME provider identity the
|
||||
# CLI `hermes model` picker uses (not desktop-only prefix guesses).
|
||||
"provider": cat_meta.get("provider", ""),
|
||||
"provider_label": cat_meta.get("provider_label", ""),
|
||||
}
|
||||
|
||||
result = {}
|
||||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||||
result[var_name] = _row(var_name, info)
|
||||
# Synthesize rows for catalog provider env vars that have no hand entry in
|
||||
# OPTIONAL_ENV_VARS — these are the providers that were CLI-configurable but
|
||||
# invisible in the desktop app until now.
|
||||
for var_name in catalog_meta:
|
||||
if var_name not in result:
|
||||
result[var_name] = _row(var_name, {})
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -4146,9 +4296,9 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
|
|||
},
|
||||
"slack": {
|
||||
"name": "Slack",
|
||||
"description": "Use Hermes from Slack via Socket Mode.",
|
||||
"description": "Use Hermes from Slack via Socket Mode. Add allowed Slack member IDs so connected bots can respond.",
|
||||
"docs_url": "https://api.slack.com/apps",
|
||||
"env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
|
||||
"env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"),
|
||||
"required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
|
||||
},
|
||||
"mattermost": {
|
||||
|
|
@ -4633,6 +4783,7 @@ def _messaging_env_info(key: str) -> dict[str, Any]:
|
|||
return {
|
||||
"description": info.get("description", ""),
|
||||
"prompt": info.get("prompt", key),
|
||||
"help": info.get("help", ""),
|
||||
"url": info.get("url"),
|
||||
"is_password": info.get("password", False),
|
||||
"advanced": info.get("advanced", False),
|
||||
|
|
@ -5212,6 +5363,7 @@ async def update_messaging_platform(
|
|||
)
|
||||
trimmed = value.strip()
|
||||
if trimmed:
|
||||
_validate_messaging_env_value(platform_id, key, trimmed)
|
||||
save_env_value(key, trimmed)
|
||||
|
||||
if body.enabled is not None:
|
||||
|
|
@ -5413,13 +5565,53 @@ def _claude_code_only_status() -> Dict[str, Any]:
|
|||
return {"logged_in": False, "source": None}
|
||||
|
||||
|
||||
# Provider catalog. The order matters — it's how we render the UI list.
|
||||
# ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard
|
||||
# fallback while Phase 2 (in-browser flows) isn't built yet.
|
||||
# ``flow`` describes the OAuth shape so the future modal can pick the
|
||||
# right UI: ``pkce`` = open URL + paste callback code, ``device_code`` =
|
||||
# show code + verification URL + poll, ``external`` = read-only (delegated
|
||||
# to a third-party CLI like Claude Code or Qwen).
|
||||
def _gemini_cli_status() -> Dict[str, Any]:
|
||||
"""Status for the google-gemini-cli OAuth provider (Code Assist login)."""
|
||||
try:
|
||||
from hermes_cli import auth as hauth
|
||||
raw = hauth.get_gemini_oauth_auth_status()
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": raw.get("source") or "google_oauth",
|
||||
"source_label": raw.get("email") or raw.get("auth_file") or "Google Code Assist",
|
||||
"token_preview": _truncate_token(raw.get("api_key")),
|
||||
"expires_at": None,
|
||||
"has_refresh_token": True,
|
||||
}
|
||||
|
||||
|
||||
def _copilot_acp_status() -> Dict[str, Any]:
|
||||
"""Status for copilot-acp — credentials are owned by the Copilot CLI.
|
||||
|
||||
There is no cheap programmatic credential probe for the ACP subprocess, so
|
||||
this is a read-only "managed by the Copilot CLI" card (like claude-code):
|
||||
Hermes never claims a login state it can't verify.
|
||||
"""
|
||||
return {
|
||||
"logged_in": False,
|
||||
"source": "copilot_cli",
|
||||
"source_label": "Managed by the GitHub Copilot CLI",
|
||||
"token_preview": None,
|
||||
"expires_at": None,
|
||||
"has_refresh_token": False,
|
||||
}
|
||||
|
||||
|
||||
# Explicit, hand-tuned OAuth/account provider cards. These carry the bits that
|
||||
# can't be derived from the unified provider catalog: the OAuth ``flow`` shape,
|
||||
# the per-provider ``status_fn``, the ``cli_command`` fallback, and curated
|
||||
# display order. They are the OVERRIDE BASE for ``_build_oauth_catalog()``,
|
||||
# which unions them with every accounts-tab provider in ``provider_catalog()``
|
||||
# so newly-added OAuth/external providers appear automatically (no hand edit).
|
||||
# This tuple also still includes two entries that are NOT catalog providers but
|
||||
# must show on the Accounts tab: the api-key Anthropic PKCE card and the
|
||||
# synthetic ``claude-code`` subscription row.
|
||||
# ``flow`` describes the OAuth shape so the modal can pick the right UI:
|
||||
# ``pkce`` = open URL + paste callback code, ``device_code`` = show code +
|
||||
# verification URL + poll, ``external`` = read-only (delegated to a third-party
|
||||
# CLI like Claude Code or Qwen), ``loopback`` = 127.0.0.1 callback listener.
|
||||
_OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "nous",
|
||||
|
|
@ -5469,6 +5661,22 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
|||
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
|
||||
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
|
||||
},
|
||||
{
|
||||
"id": "google-gemini-cli",
|
||||
"name": "Google Gemini (OAuth + Code Assist)",
|
||||
"flow": "external",
|
||||
"cli_command": "hermes auth add google-gemini-cli",
|
||||
"docs_url": "https://ai.google.dev/gemini-api/docs",
|
||||
"status_fn": _gemini_cli_status,
|
||||
},
|
||||
{
|
||||
"id": "copilot-acp",
|
||||
"name": "GitHub Copilot (ACP)",
|
||||
"flow": "external",
|
||||
"cli_command": "copilot /login",
|
||||
"docs_url": "https://docs.github.com/en/copilot",
|
||||
"status_fn": _copilot_acp_status,
|
||||
},
|
||||
# ── Anthropic / Claude entries sit at the bottom: the API-key path
|
||||
# first, then the subscription OAuth path (which only works with extra
|
||||
# usage credits on top of a Claude Max plan — see disclaimer in name).
|
||||
|
|
@ -5555,6 +5763,31 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
|||
"has_refresh_token": True,
|
||||
"last_refresh": raw.get("last_refresh"),
|
||||
}
|
||||
# No hand-written branch for this provider id: fall through to the
|
||||
# canonical slug-driven dispatcher so accounts-tab providers derived
|
||||
# from the unified catalog (which carry status_fn=None) still reflect
|
||||
# real login state instead of rendering permanently logged-out. This
|
||||
# closes the membership-auto-extends-but-status-doesn't gap: add an
|
||||
# OAuth/account provider plugin and its card shows the right state.
|
||||
raw = hauth.get_auth_status(provider_id)
|
||||
if isinstance(raw, dict) and "logged_in" in raw:
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": raw.get("source") or raw.get("provider") or provider_id,
|
||||
"source_label": (
|
||||
raw.get("source_label")
|
||||
or raw.get("auth_store")
|
||||
or raw.get("auth_store_path")
|
||||
or raw.get("base_url")
|
||||
or raw.get("name")
|
||||
or ""
|
||||
),
|
||||
"token_preview": _truncate_token(
|
||||
raw.get("access_token") or raw.get("api_key")
|
||||
),
|
||||
"expires_at": raw.get("expires_at") or raw.get("access_expires_at"),
|
||||
"has_refresh_token": bool(raw.get("has_refresh_token")),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {"logged_in": False}
|
||||
|
|
@ -5598,6 +5831,56 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str,
|
|||
return None
|
||||
|
||||
|
||||
def _build_oauth_catalog() -> list[Dict[str, Any]]:
|
||||
"""Build the Accounts-tab provider list.
|
||||
|
||||
MEMBERSHIP is the union of:
|
||||
1. ``_OAUTH_PROVIDER_CATALOG`` — the explicit, hand-tuned cards that carry
|
||||
bespoke flow / status_fn / cli_command (including the api-key Anthropic
|
||||
PKCE card and the synthetic claude-code subscription row, which are not
|
||||
catalog providers), and
|
||||
2. every accounts-tab provider in the unified ``provider_catalog()`` (the
|
||||
``hermes model`` universe) — so any OAuth/external provider added as a
|
||||
plugin appears automatically, with sensible defaults, even if no
|
||||
explicit card was written for it.
|
||||
|
||||
The explicit catalog wins on metadata; the unified catalog guarantees we
|
||||
never silently drop a provider the CLI picker offers. Order: explicit cards
|
||||
first (their curated order), then any catalog-only providers appended in
|
||||
``hermes model`` order.
|
||||
"""
|
||||
rows: list[Dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
# 1. Explicit hand-tuned cards (authoritative metadata + curated order).
|
||||
for entry in _OAUTH_PROVIDER_CATALOG:
|
||||
if entry["id"] in seen:
|
||||
continue
|
||||
seen.add(entry["id"])
|
||||
rows.append(dict(entry))
|
||||
|
||||
# 2. Catalog accounts-providers not already covered — keeps the Accounts tab
|
||||
# in lockstep with the `hermes model` universe (zero-edit for new plugins).
|
||||
try:
|
||||
from hermes_cli.provider_catalog import provider_catalog
|
||||
for d in provider_catalog():
|
||||
if d.tab != "accounts" or d.slug in seen:
|
||||
continue
|
||||
seen.add(d.slug)
|
||||
rows.append({
|
||||
"id": d.slug,
|
||||
"name": d.label,
|
||||
"flow": "external",
|
||||
"cli_command": f"hermes auth add {d.slug}",
|
||||
"docs_url": d.signup_url or "",
|
||||
"status_fn": None,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
@app.get("/api/providers/oauth")
|
||||
async def list_oauth_providers(profile: Optional[str] = None):
|
||||
"""Enumerate every OAuth-capable LLM provider with current status.
|
||||
|
|
@ -5617,10 +5900,14 @@ async def list_oauth_providers(profile: Optional[str] = None):
|
|||
token_preview last N chars of the token, never the full token
|
||||
expires_at ISO timestamp string or null
|
||||
has_refresh_token bool
|
||||
|
||||
Membership is derived from the unified provider_catalog() so this stays in
|
||||
sync with the `hermes model` picker; _OAUTH_OVERRIDES supplies per-provider
|
||||
flow/status/cli metadata.
|
||||
"""
|
||||
with _profile_scope(profile):
|
||||
providers = []
|
||||
for p in _OAUTH_PROVIDER_CATALOG:
|
||||
for p in _build_oauth_catalog():
|
||||
status = _resolve_provider_status(p["id"], p.get("status_fn"))
|
||||
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
|
||||
providers.append({
|
||||
|
|
@ -5647,7 +5934,7 @@ async def disconnect_oauth_provider(
|
|||
_require_token(request)
|
||||
|
||||
with _profile_scope(profile):
|
||||
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
|
||||
catalog_by_id = {p["id"]: p for p in _build_oauth_catalog()}
|
||||
provider = catalog_by_id.get(provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -10914,6 +11201,7 @@ def _resolve_chat_argv(
|
|||
# the dashboard PTY path.
|
||||
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
|
||||
env.setdefault("HERMES_TUI_INLINE", "1")
|
||||
env["HERMES_TUI_DASHBOARD"] = "1"
|
||||
|
||||
if profile_dir is not None:
|
||||
env["HERMES_HOME"] = str(profile_dir)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue