Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # hermes_cli/main.py
This commit is contained in:
+143
-4
@@ -102,8 +102,16 @@ def looks_like_table_row(row: str) -> bool:
|
||||
return stripped.count("|") >= 2
|
||||
|
||||
|
||||
def _render_block(rows: List[List[str]]) -> List[str]:
|
||||
"""Render ``rows`` (header + body, divider implied) at uniform widths."""
|
||||
def _render_block(rows: List[List[str]], available_width: int | None = None) -> List[str]:
|
||||
"""Render ``rows`` (header + body, divider implied) at uniform widths.
|
||||
|
||||
If ``available_width`` is given and the rebuilt horizontal table
|
||||
would exceed it, fall back to a vertical key-value rendering so
|
||||
rows do not soft-wrap mid-cell — terminal soft-wrap destroys
|
||||
column alignment visually even when the underlying bytes are
|
||||
perfectly padded, which is exactly the "tables look broken"
|
||||
user report this code path is meant to address.
|
||||
"""
|
||||
|
||||
ncols = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (ncols - len(r)) for r in rows]
|
||||
@@ -113,6 +121,13 @@ def _render_block(rows: List[List[str]]) -> List[str]:
|
||||
for c in range(ncols)
|
||||
]
|
||||
|
||||
# Total horizontal width for the rendered row:
|
||||
# `| ` + cell + ` ` for each column, plus the final closing `|`.
|
||||
horizontal_width = sum(widths) + 3 * ncols + 1
|
||||
|
||||
if available_width is not None and horizontal_width > max(available_width, 20):
|
||||
return _render_vertical(rows, ncols, available_width)
|
||||
|
||||
def _row(cells: List[str]) -> str:
|
||||
return (
|
||||
"| "
|
||||
@@ -127,11 +142,135 @@ def _render_block(rows: List[List[str]]) -> List[str]:
|
||||
return out
|
||||
|
||||
|
||||
def realign_markdown_tables(text: str) -> str:
|
||||
def _wrap_to_width(text: str, width: int) -> List[str]:
|
||||
"""Soft-wrap ``text`` at word boundaries to fit ``width`` display cells.
|
||||
|
||||
Falls back to hard-breaking the longest word if a single token is
|
||||
wider than ``width``. Empty input yields a single empty string so
|
||||
the caller's row count stays predictable.
|
||||
"""
|
||||
|
||||
if width <= 0 or not text:
|
||||
return [text]
|
||||
|
||||
words = text.split()
|
||||
if not words:
|
||||
return [""]
|
||||
|
||||
lines: List[str] = []
|
||||
current = ""
|
||||
current_w = 0
|
||||
|
||||
def _hard_break(word: str, w: int) -> List[str]:
|
||||
out: List[str] = []
|
||||
buf = ""
|
||||
bw = 0
|
||||
for ch in word:
|
||||
cw = _disp_width(ch) or 1
|
||||
if bw + cw > w and buf:
|
||||
out.append(buf)
|
||||
buf = ch
|
||||
bw = cw
|
||||
else:
|
||||
buf += ch
|
||||
bw += cw
|
||||
if buf:
|
||||
out.append(buf)
|
||||
return out
|
||||
|
||||
for word in words:
|
||||
ww = _disp_width(word)
|
||||
if not current:
|
||||
if ww <= width:
|
||||
current = word
|
||||
current_w = ww
|
||||
else:
|
||||
pieces = _hard_break(word, width)
|
||||
lines.extend(pieces[:-1])
|
||||
current = pieces[-1] if pieces else ""
|
||||
current_w = _disp_width(current)
|
||||
continue
|
||||
if current_w + 1 + ww <= width:
|
||||
current += " " + word
|
||||
current_w += 1 + ww
|
||||
else:
|
||||
lines.append(current)
|
||||
if ww <= width:
|
||||
current = word
|
||||
current_w = ww
|
||||
else:
|
||||
pieces = _hard_break(word, width)
|
||||
lines.extend(pieces[:-1])
|
||||
current = pieces[-1] if pieces else ""
|
||||
current_w = _disp_width(current)
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines or [""]
|
||||
|
||||
|
||||
def _render_vertical(
|
||||
rows: List[List[str]], ncols: int, available_width: int
|
||||
) -> List[str]:
|
||||
"""Render a too-wide table as vertical ``Header: value`` rows.
|
||||
|
||||
Mirrors Claude Code's narrow-terminal fallback in
|
||||
``MarkdownTable.tsx``: each body row becomes a small block of
|
||||
``Header: cell-value`` lines (continuation lines indented two
|
||||
spaces) separated by a thin ``─`` divider between rows. Keeps
|
||||
every line narrower than ``available_width`` so the terminal does
|
||||
not soft-wrap mid-cell.
|
||||
"""
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
headers = rows[0] + [""] * (ncols - len(rows[0]))
|
||||
body = rows[1:]
|
||||
|
||||
labels = [h or f"Column {i + 1}" for i, h in enumerate(headers)]
|
||||
|
||||
sep_width = max(20, min(40, available_width - 2)) if available_width else 30
|
||||
separator = "─" * sep_width
|
||||
indent = " "
|
||||
indent_w = _disp_width(indent)
|
||||
|
||||
out: List[str] = []
|
||||
for ri, row in enumerate(body):
|
||||
if ri > 0:
|
||||
out.append(separator)
|
||||
for ci in range(ncols):
|
||||
label = labels[ci]
|
||||
value = row[ci] if ci < len(row) else ""
|
||||
label_w = _disp_width(label)
|
||||
first_budget = max(10, available_width - label_w - 2)
|
||||
cont_budget = max(10, available_width - indent_w)
|
||||
if not value:
|
||||
out.append(f"{label}:")
|
||||
continue
|
||||
wrapped = _wrap_to_width(value, first_budget)
|
||||
out.append(f"{label}: {wrapped[0]}")
|
||||
if len(wrapped) > 1:
|
||||
# Re-flow continuation text at the wider continuation
|
||||
# budget — words split across the narrower first-line
|
||||
# budget should re-pack greedily for the rest.
|
||||
cont_text = " ".join(wrapped[1:])
|
||||
for cl in _wrap_to_width(cont_text, cont_budget):
|
||||
if cl.strip():
|
||||
out.append(f"{indent}{cl}")
|
||||
return out
|
||||
|
||||
|
||||
def realign_markdown_tables(text: str, available_width: int | None = None) -> str:
|
||||
"""Rewrite every ``| ... |`` + divider block with wcwidth-aware padding.
|
||||
|
||||
Lines that are not part of a recognised table are returned verbatim,
|
||||
so this is safe to apply to arbitrary assistant prose.
|
||||
|
||||
If ``available_width`` is given (terminal cells available for the
|
||||
rendered table), tables wider than that are rendered as vertical
|
||||
key-value pairs instead of a horizontal pipe-bordered grid. This
|
||||
avoids the terminal soft-wrapping mid-cell, which destroys column
|
||||
alignment visually even when the bytes are perfectly padded.
|
||||
"""
|
||||
|
||||
if "|" not in text:
|
||||
@@ -161,7 +300,7 @@ def realign_markdown_tables(text: str) -> str:
|
||||
j += 1
|
||||
|
||||
if any(c for c in header) or body:
|
||||
out.extend(_render_block([header] + body))
|
||||
out.extend(_render_block([header] + body, available_width))
|
||||
i = j
|
||||
continue
|
||||
out.append(line)
|
||||
|
||||
@@ -1354,16 +1354,48 @@ def _preserve_windows_dot_segments_for_markdown(text: str) -> str:
|
||||
return _WINDOWS_PATH_WITH_DOT_SEGMENT_RE.sub(_protect, text)
|
||||
|
||||
|
||||
def _terminal_width_for_streaming() -> int:
|
||||
"""Display cells available inside the streamed response box.
|
||||
|
||||
The streaming path indents every line by ``_STREAM_PAD`` (4 cells)
|
||||
inside an open response panel. The realigner uses this number as
|
||||
its budget when deciding whether to keep a horizontal table or
|
||||
fall back to vertical key-value rendering. We subtract a small
|
||||
safety margin so terminal-resize races don't push a borderline
|
||||
table into mid-cell soft-wrap.
|
||||
"""
|
||||
|
||||
try:
|
||||
cols = shutil.get_terminal_size((80, 24)).columns
|
||||
except Exception:
|
||||
cols = 80
|
||||
return max(20, cols - len(_STREAM_PAD) - 2)
|
||||
|
||||
|
||||
def _render_final_assistant_content(text: str, mode: str = "render"):
|
||||
"""Render final assistant content as markdown, stripped text, or raw text."""
|
||||
from rich.markdown import Markdown
|
||||
|
||||
# Estimate the cells available to the rendered table. The Panel
|
||||
# used by the background-task / final-response path has 4 cells of
|
||||
# left+right padding plus 1 cell of border on each side, plus the
|
||||
# _STREAM_PAD indent that streamed content uses. Subtract a small
|
||||
# safety margin so resize races don't push a borderline table into
|
||||
# soft-wrap.
|
||||
try:
|
||||
cols = shutil.get_terminal_size((80, 24)).columns
|
||||
except Exception:
|
||||
cols = 80
|
||||
panel_width = max(20, cols - 12)
|
||||
|
||||
normalized_mode = str(mode or "render").strip().lower()
|
||||
if normalized_mode == "strip":
|
||||
# Strip first — inline markdown inside cells (`code`, **bold**, ~~strike~~)
|
||||
# changes cell display width — then re-align so the column padding
|
||||
# reflects the final visible text, not the marker-decorated source.
|
||||
return _RichText(realign_markdown_tables(_strip_markdown_syntax(text)))
|
||||
return _RichText(
|
||||
realign_markdown_tables(_strip_markdown_syntax(text), panel_width)
|
||||
)
|
||||
if normalized_mode == "raw":
|
||||
return _rich_text_from_ansi(text or "")
|
||||
|
||||
@@ -1374,7 +1406,7 @@ def _render_final_assistant_content(text: str, mode: str = "render"):
|
||||
# (narrow panels, etc.) at least see consistent input.
|
||||
plain = _rich_text_from_ansi(text or "").plain
|
||||
plain = _preserve_windows_dot_segments_for_markdown(plain)
|
||||
plain = realign_markdown_tables(plain)
|
||||
plain = realign_markdown_tables(plain, panel_width)
|
||||
return Markdown(plain)
|
||||
|
||||
|
||||
@@ -3662,7 +3694,7 @@ class HermesCLI:
|
||||
joined = "\n".join(buf)
|
||||
if self.final_response_markdown == "strip":
|
||||
joined = _strip_markdown_syntax(joined)
|
||||
block = realign_markdown_tables(joined)
|
||||
block = realign_markdown_tables(joined, _terminal_width_for_streaming())
|
||||
for ln in block.split("\n"):
|
||||
_emit_one(ln)
|
||||
|
||||
@@ -3726,7 +3758,7 @@ class HermesCLI:
|
||||
self._in_stream_table = False
|
||||
if self.final_response_markdown == "strip":
|
||||
joined = _strip_markdown_syntax(joined)
|
||||
block = realign_markdown_tables(joined)
|
||||
block = realign_markdown_tables(joined, _terminal_width_for_streaming())
|
||||
for ln in block.split("\n"):
|
||||
_cprint(f"{_STREAM_PAD}{_tc}{ln}{_RST}" if _tc else f"{_STREAM_PAD}{ln}")
|
||||
|
||||
|
||||
@@ -5251,6 +5251,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
from hermes_cli.models import (
|
||||
get_curated_nous_model_ids, get_pricing_for_provider,
|
||||
check_nous_free_tier, partition_nous_models_by_tier,
|
||||
union_with_portal_free_recommendations,
|
||||
)
|
||||
model_ids = get_curated_nous_model_ids()
|
||||
|
||||
@@ -5260,6 +5261,15 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
pricing = get_pricing_for_provider("nous")
|
||||
free_tier = check_nous_free_tier()
|
||||
if free_tier:
|
||||
# The Portal's freeRecommendedModels endpoint is the
|
||||
# source of truth for what's free *right now*. Augment
|
||||
# the curated list with anything new the Portal flags
|
||||
# as free so users on older Hermes builds still see
|
||||
# newly-launched free models without a CLI release.
|
||||
_portal_for_recs = auth_state.get("portal_base_url", "")
|
||||
model_ids, pricing = union_with_portal_free_recommendations(
|
||||
model_ids, pricing, _portal_for_recs,
|
||||
)
|
||||
model_ids, unavailable_models = partition_nous_models_by_tier(
|
||||
model_ids, pricing, free_tier=True,
|
||||
)
|
||||
|
||||
+113
-121
@@ -897,6 +897,11 @@ to avoid false-positive reinstalls on every launch.
|
||||
def _tui_need_npm_install(root: Path) -> bool:
|
||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
||||
|
||||
Prebuilt bundle mode: when ``dist/entry.js`` exists and there is no
|
||||
``package-lock.json`` (nix install layout only ships ``dist/`` +
|
||||
``package.json``), skip reinstall entirely — the bundle is self-contained
|
||||
and there is nothing to install.
|
||||
|
||||
Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
|
||||
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
|
||||
rewrites can bump the root lockfile's timestamp even when installed deps
|
||||
@@ -914,10 +919,16 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
we'd rather not force a reinstall for them. Falls back to mtime
|
||||
comparison if either lockfile is unparseable.
|
||||
"""
|
||||
lock = root / "package-lock.json"
|
||||
entry = root / "dist" / "entry.js"
|
||||
# Prebuilt self-contained bundle (nix / packaged release): no lockfile
|
||||
# shipped, dist/entry.js is the single runtime artefact.
|
||||
if entry.is_file() and not lock.is_file():
|
||||
return False
|
||||
|
||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
if not ink.is_file():
|
||||
return True
|
||||
lock = root / "package-lock.json"
|
||||
if not lock.is_file():
|
||||
return False
|
||||
marker = root / "node_modules" / ".package-lock.json"
|
||||
@@ -956,63 +967,6 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _find_bundled_tui(tui_dir: Path) -> Optional[Path]:
|
||||
"""Directory whose dist/entry.js we should run: HERMES_TUI_DIR first, else repo ui-tui."""
|
||||
env = os.environ.get("HERMES_TUI_DIR")
|
||||
if env:
|
||||
p = Path(env)
|
||||
if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p):
|
||||
return p
|
||||
if (tui_dir / "dist" / "entry.js").exists() and not _tui_need_npm_install(tui_dir):
|
||||
return tui_dir
|
||||
return None
|
||||
|
||||
|
||||
def _tui_build_needed(tui_dir: Path) -> bool:
|
||||
if _hermes_ink_bundle_stale(tui_dir):
|
||||
return True
|
||||
entry = tui_dir / "dist" / "entry.js"
|
||||
if not entry.exists():
|
||||
return True
|
||||
dist_m = entry.stat().st_mtime
|
||||
skip = frozenset({"node_modules", "dist"})
|
||||
for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True):
|
||||
dirnames[:] = [d for d in dirnames if d not in skip]
|
||||
for fn in filenames:
|
||||
if fn.endswith((".ts", ".tsx")):
|
||||
if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m:
|
||||
return True
|
||||
for meta in (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.build.json",
|
||||
):
|
||||
mp = tui_dir / meta
|
||||
if mp.exists() and mp.stat().st_mtime > dist_m:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _hermes_ink_bundle_stale(tui_dir: Path) -> bool:
|
||||
ink_root = tui_dir / "packages" / "hermes-ink"
|
||||
bundle = ink_root / "dist" / "ink-bundle.js"
|
||||
if not bundle.exists():
|
||||
return True
|
||||
bm = bundle.stat().st_mtime
|
||||
skip = frozenset({"node_modules", "dist"})
|
||||
for dirpath, dirnames, filenames in os.walk(ink_root, topdown=True):
|
||||
dirnames[:] = [d for d in dirnames if d not in skip]
|
||||
for fn in filenames:
|
||||
if fn.endswith((".ts", ".tsx")):
|
||||
if os.path.getmtime(os.path.join(dirpath, fn)) > bm:
|
||||
return True
|
||||
mp = ink_root / "package.json"
|
||||
if mp.exists() and mp.stat().st_mtime > bm:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_tui_node() -> None:
|
||||
"""Make sure `node` + `npm` are on PATH for the TUI.
|
||||
|
||||
@@ -1071,7 +1025,7 @@ def _ensure_tui_node() -> None:
|
||||
|
||||
|
||||
def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale)."""
|
||||
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild)."""
|
||||
_ensure_tui_node()
|
||||
|
||||
def _node_bin(bin: str) -> str:
|
||||
@@ -1085,23 +1039,31 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
sys.exit(1)
|
||||
return path
|
||||
|
||||
# pre-built dist + node_modules (nix / full HERMES_TUI_DIR) skips npm.
|
||||
# Footgun: --dev against a prebuilt bundle that has no source/node_modules.
|
||||
ext_dir = os.environ.get("HERMES_TUI_DIR")
|
||||
if tui_dev and ext_dir:
|
||||
print(
|
||||
f"Error: --dev is incompatible with HERMES_TUI_DIR={ext_dir}\n"
|
||||
f"The prebuilt TUI has no source code to hot-reload.\n"
|
||||
f"Unset HERMES_TUI_DIR (e.g. `unset HERMES_TUI_DIR`) to use --dev from a checkout.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Prebuilt bundle (nix / packaged release): just run it.
|
||||
if not tui_dev:
|
||||
ext_dir = os.environ.get("HERMES_TUI_DIR")
|
||||
if ext_dir:
|
||||
p = Path(ext_dir)
|
||||
if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p):
|
||||
if (p / "dist" / "entry.js").is_file():
|
||||
node = _node_bin("node")
|
||||
return [node, str(p / "dist" / "entry.js")], p
|
||||
|
||||
npm = _node_bin("npm")
|
||||
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx (no build).
|
||||
if _tui_need_npm_install(tui_dir):
|
||||
npm = _node_bin("npm")
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
print("Installing TUI dependencies…")
|
||||
# Capture stdout as well as stderr — some npm errors (notably EACCES on a
|
||||
# root-owned node_modules in containers) are emitted on stdout, and a
|
||||
# bare "npm install failed." with no preview defeats debugging. We keep
|
||||
# the failure-only print path so a successful install stays silent.
|
||||
result = subprocess.run(
|
||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||
cwd=str(tui_dir),
|
||||
@@ -1119,47 +1081,30 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
sys.exit(1)
|
||||
|
||||
if tui_dev:
|
||||
if _hermes_ink_bundle_stale(tui_dir):
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build", "--prefix", "packages/hermes-ink"],
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("@hermes/ink build failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
|
||||
if tsx.exists():
|
||||
return [str(tsx), "src/entry.tsx"], tui_dir
|
||||
npm = _node_bin("npm")
|
||||
return [npm, "start"], tui_dir
|
||||
|
||||
if _tui_build_needed(tui_dir):
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("TUI build failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
|
||||
root = _find_bundled_tui(tui_dir)
|
||||
if not root:
|
||||
print("TUI build did not produce dist/entry.js")
|
||||
# Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs.
|
||||
npm = _node_bin("npm")
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("TUI build failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
|
||||
node = _node_bin("node")
|
||||
return [node, str(root / "dist" / "entry.js")], root
|
||||
return [node, str(tui_dir / "dist" / "entry.js")], tui_dir
|
||||
|
||||
|
||||
def _normalize_tui_toolsets(toolsets: object) -> list[str]:
|
||||
@@ -2661,6 +2606,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
get_pricing_for_provider,
|
||||
check_nous_free_tier,
|
||||
partition_nous_models_by_tier,
|
||||
union_with_portal_free_recommendations,
|
||||
)
|
||||
|
||||
model_ids = get_curated_nous_model_ids()
|
||||
@@ -2701,19 +2647,8 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
# Check if user is on free tier
|
||||
free_tier = check_nous_free_tier()
|
||||
|
||||
# For free users: partition models into selectable/unavailable based on
|
||||
# whether they are free per the Portal-reported pricing.
|
||||
unavailable_models: list[str] = []
|
||||
if free_tier:
|
||||
model_ids, unavailable_models = partition_nous_models_by_tier(
|
||||
model_ids, pricing, free_tier=True
|
||||
)
|
||||
|
||||
if not model_ids and not unavailable_models:
|
||||
print("No models available for Nous Portal after filtering.")
|
||||
return
|
||||
|
||||
# Resolve portal URL for upgrade links (may differ on staging)
|
||||
# Resolve portal URL early — needed both for upgrade links and for the
|
||||
# freeRecommendedModels endpoint below.
|
||||
_nous_portal_url = ""
|
||||
try:
|
||||
_nous_state = get_provider_auth_state("nous")
|
||||
@@ -2722,6 +2657,24 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# For free users: partition models into selectable/unavailable based on
|
||||
# whether they are free per the Portal-reported pricing. First augment
|
||||
# with the Portal's freeRecommendedModels list so newly-launched free
|
||||
# models show up even if this CLI build's hardcoded curated list and
|
||||
# docs-hosted manifest haven't caught up yet.
|
||||
unavailable_models: list[str] = []
|
||||
if free_tier:
|
||||
model_ids, pricing = union_with_portal_free_recommendations(
|
||||
model_ids, pricing, _nous_portal_url,
|
||||
)
|
||||
model_ids, unavailable_models = partition_nous_models_by_tier(
|
||||
model_ids, pricing, free_tier=True
|
||||
)
|
||||
|
||||
if not model_ids and not unavailable_models:
|
||||
print("No models available for Nous Portal after filtering.")
|
||||
return
|
||||
|
||||
if free_tier and not model_ids:
|
||||
print("No free models currently available.")
|
||||
if unavailable_models:
|
||||
@@ -5508,10 +5461,11 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
|
||||
|
||||
Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI.
|
||||
The dashboard source lives under ``apps/dashboard/``, but the Vite build
|
||||
still outputs to ``hermes_cli/web_dist/`` so Python packaging can continue
|
||||
serving the same static asset directory. Uses the Vite manifest as the
|
||||
sentinel because it is written last and therefore has the newest mtime of
|
||||
any build output.
|
||||
still outputs to ``hermes_cli/web_dist/`` (per vite.config.ts
|
||||
outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``, so Python
|
||||
packaging can continue serving the same static asset directory. Uses the
|
||||
Vite manifest as the sentinel because it is written last and therefore
|
||||
has the newest mtime of any build output.
|
||||
"""
|
||||
project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent
|
||||
dist_dir = project_root / "hermes_cli" / "web_dist"
|
||||
@@ -7880,6 +7834,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except Exception as e:
|
||||
logger.debug("FHS PATH guard check failed: %s", e)
|
||||
|
||||
# Refresh the cua-driver binary used by the Computer Use toolset.
|
||||
# The upstream installer is gated on macOS and on the binary already
|
||||
# being on PATH, so this is a no-op for users who don't have it.
|
||||
# Tying the refresh to ``hermes update`` gives users a predictable
|
||||
# cadence (matches when they pull new agent code) without adding
|
||||
# startup latency or a per-launch GitHub API call.
|
||||
try:
|
||||
if sys.platform == "darwin" and shutil.which("cua-driver"):
|
||||
from hermes_cli.tools_config import install_cua_driver
|
||||
|
||||
print()
|
||||
print("→ Refreshing cua-driver (Computer Use)...")
|
||||
install_cua_driver(upgrade=True)
|
||||
except Exception as e:
|
||||
logger.debug("cua-driver refresh failed: %s", e)
|
||||
|
||||
# Write exit code *before* the gateway restart attempt.
|
||||
# When running as ``hermes update --gateway`` (spawned by the gateway's
|
||||
# /update command), this process lives inside the gateway's systemd
|
||||
@@ -10886,10 +10856,19 @@ Examples:
|
||||
)
|
||||
computer_use_sub = computer_use_parser.add_subparsers(dest="computer_use_action")
|
||||
|
||||
computer_use_sub.add_parser(
|
||||
computer_use_install = computer_use_sub.add_parser(
|
||||
"install",
|
||||
help="Install or repair the cua-driver binary (macOS)",
|
||||
)
|
||||
computer_use_install.add_argument(
|
||||
"--upgrade",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Re-run the upstream installer even if cua-driver is already on "
|
||||
"PATH. The upstream install.sh always pulls the latest release, "
|
||||
"so this performs an in-place upgrade."
|
||||
),
|
||||
)
|
||||
computer_use_sub.add_parser(
|
||||
"status",
|
||||
help="Print whether cua-driver is installed and on PATH",
|
||||
@@ -10898,14 +10877,27 @@ Examples:
|
||||
def cmd_computer_use(args):
|
||||
action = getattr(args, "computer_use_action", None)
|
||||
if action == "install":
|
||||
from hermes_cli.tools_config import _run_post_setup
|
||||
_run_post_setup("cua_driver")
|
||||
from hermes_cli.tools_config import install_cua_driver
|
||||
install_cua_driver(upgrade=bool(getattr(args, "upgrade", False)))
|
||||
return
|
||||
if action == "status":
|
||||
import shutil
|
||||
import subprocess
|
||||
path = shutil.which("cua-driver")
|
||||
if path:
|
||||
print(f"cua-driver: installed at {path}")
|
||||
version = ""
|
||||
try:
|
||||
version = subprocess.run(
|
||||
["cua-driver", "--version"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
).stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
if version:
|
||||
print(f"cua-driver: installed at {path} ({version})")
|
||||
else:
|
||||
print(f"cua-driver: installed at {path}")
|
||||
print(" Refresh to latest: hermes computer-use install --upgrade")
|
||||
return
|
||||
print("cua-driver: not installed")
|
||||
print(" Run: hermes computer-use install")
|
||||
|
||||
+80
-2
@@ -556,6 +556,71 @@ def partition_nous_models_by_tier(
|
||||
return (selectable, unavailable)
|
||||
|
||||
|
||||
def union_with_portal_free_recommendations(
|
||||
curated_ids: list[str],
|
||||
pricing: dict[str, dict[str, str]],
|
||||
portal_base_url: str = "",
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> tuple[list[str], dict[str, dict[str, str]]]:
|
||||
"""Augment curated list + pricing with the Portal's ``freeRecommendedModels``.
|
||||
|
||||
The Portal's ``/api/nous/recommended-models`` endpoint advertises which
|
||||
models are free *right now* — independent of what the in-repo
|
||||
``_PROVIDER_MODELS["nous"]`` list happens to contain or whether the
|
||||
docs-hosted catalog manifest has been rebuilt since the last release.
|
||||
|
||||
For free-tier users this is the source of truth: any model the Portal
|
||||
flags as free should be selectable, even if the user is running an
|
||||
older Hermes that doesn't ship that model in its hardcoded curated
|
||||
list. This function returns an augmented ``(model_ids, pricing)``
|
||||
pair where:
|
||||
|
||||
* Portal free recommendations missing from ``curated_ids`` are
|
||||
appended at the front (so the picker shows them first).
|
||||
* ``pricing`` gets a synthetic ``{"prompt": "0", "completion": "0"}``
|
||||
entry for any free recommendation missing from the live pricing
|
||||
map, so :func:`partition_nous_models_by_tier` keeps it.
|
||||
|
||||
Failures (network, parse, missing field) are silent and degrade to
|
||||
returning the inputs unchanged.
|
||||
"""
|
||||
try:
|
||||
payload = fetch_nous_recommended_models(
|
||||
portal_base_url, force_refresh=force_refresh
|
||||
)
|
||||
except Exception:
|
||||
return (list(curated_ids), dict(pricing))
|
||||
|
||||
free_block = payload.get("freeRecommendedModels") if isinstance(payload, dict) else None
|
||||
if not isinstance(free_block, list) or not free_block:
|
||||
return (list(curated_ids), dict(pricing))
|
||||
|
||||
portal_free_ids: list[str] = []
|
||||
for entry in free_block:
|
||||
name = _extract_model_name(entry)
|
||||
if name:
|
||||
portal_free_ids.append(name)
|
||||
if not portal_free_ids:
|
||||
return (list(curated_ids), dict(pricing))
|
||||
|
||||
augmented_pricing = dict(pricing)
|
||||
free_synthetic = {"prompt": "0", "completion": "0"}
|
||||
for mid in portal_free_ids:
|
||||
if mid not in augmented_pricing:
|
||||
augmented_pricing[mid] = dict(free_synthetic)
|
||||
|
||||
augmented_ids = list(curated_ids)
|
||||
seen = set(augmented_ids)
|
||||
# Prepend Portal free recommendations that aren't already curated, so
|
||||
# they appear first in the picker.
|
||||
new_ones = [mid for mid in portal_free_ids if mid not in seen]
|
||||
if new_ones:
|
||||
augmented_ids = new_ones + augmented_ids
|
||||
|
||||
return (augmented_ids, augmented_pricing)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTL cache for free-tier detection — avoids repeated API calls within a
|
||||
# session while still picking up upgrades quickly.
|
||||
@@ -1338,8 +1403,21 @@ def _resolve_openrouter_api_key() -> str:
|
||||
return os.getenv("OPENROUTER_API_KEY", "").strip()
|
||||
|
||||
|
||||
_DEFAULT_NOUS_INFERENCE_BASE = "https://inference-api.nousresearch.com"
|
||||
|
||||
|
||||
def _resolve_nous_pricing_credentials() -> tuple[str, str]:
|
||||
"""Return ``(api_key, base_url)`` for Nous Portal pricing, or empty strings."""
|
||||
"""Return ``(api_key, base_url)`` for Nous Portal pricing.
|
||||
|
||||
The Nous inference ``/v1/models`` endpoint exposes pricing without
|
||||
authentication, so the api_key is best-effort: when runtime credential
|
||||
resolution fails (expired refresh token, missing auth.json, etc.) we
|
||||
still return the default inference base URL so the picker keeps
|
||||
working with anonymous pricing data. Free-tier users in particular
|
||||
need this — pricing drives the free/paid partition, and silently
|
||||
returning empty pricing because of an auth blip makes the picker
|
||||
look broken ("No free models currently available").
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import resolve_nous_runtime_credentials
|
||||
creds = resolve_nous_runtime_credentials()
|
||||
@@ -1347,7 +1425,7 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]:
|
||||
return (creds.get("api_key", ""), creds.get("base_url", ""))
|
||||
except Exception:
|
||||
pass
|
||||
return ("", "")
|
||||
return ("", _DEFAULT_NOUS_INFERENCE_BASE)
|
||||
|
||||
|
||||
def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
|
||||
|
||||
+127
-45
@@ -591,6 +591,132 @@ def _pip_install(
|
||||
)
|
||||
|
||||
|
||||
def install_cua_driver(upgrade: bool = False) -> bool:
|
||||
"""Install or refresh the cua-driver binary used by Computer Use.
|
||||
|
||||
The upstream installer always pulls the latest release tag, so re-running
|
||||
it is the canonical way to upgrade. We expose two modes:
|
||||
|
||||
* ``upgrade=False`` — original post-setup behaviour: skip if already
|
||||
installed, install otherwise. Used by the toolset enable flow where
|
||||
we don't want to surprise the user with a network fetch.
|
||||
* ``upgrade=True`` — always re-run the installer (or call ``cua-driver
|
||||
update`` if the binary supports it). Used by ``hermes update`` and
|
||||
by ``hermes computer-use install --upgrade``.
|
||||
|
||||
Returns True iff cua-driver is installed (or successfully refreshed)
|
||||
when the function returns. macOS-only — silently returns False on
|
||||
other platforms.
|
||||
"""
|
||||
import platform as _plat
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if _plat.system() != "Darwin":
|
||||
if upgrade:
|
||||
# Silent on non-macOS — `hermes update` calls this for every
|
||||
# user; only macOS users with cua-driver care.
|
||||
return False
|
||||
_print_warning(" Computer Use (cua-driver) is macOS-only; skipping.")
|
||||
return False
|
||||
|
||||
binary = shutil.which("cua-driver")
|
||||
|
||||
# Not installed → fresh install path (only when caller asked for it).
|
||||
if not binary and not upgrade:
|
||||
if not shutil.which("curl"):
|
||||
_print_warning(" curl not found — install manually:")
|
||||
_print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md")
|
||||
return False
|
||||
return _run_cua_driver_installer(label="Installing")
|
||||
|
||||
# Already installed and caller didn't ask to upgrade → just confirm.
|
||||
if binary and not upgrade:
|
||||
try:
|
||||
version = subprocess.run(
|
||||
["cua-driver", "--version"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
).stdout.strip()
|
||||
_print_success(f" cua-driver already installed: {version or 'unknown version'}")
|
||||
except Exception:
|
||||
_print_success(" cua-driver already installed.")
|
||||
_print_info(" Grant macOS permissions if not done yet:")
|
||||
_print_info(" System Settings > Privacy & Security > Accessibility")
|
||||
_print_info(" System Settings > Privacy & Security > Screen Recording")
|
||||
return True
|
||||
|
||||
# upgrade=True path — refresh to the latest upstream release.
|
||||
if not shutil.which("curl"):
|
||||
_print_warning(" curl not found — cannot refresh cua-driver.")
|
||||
return bool(binary)
|
||||
|
||||
if binary:
|
||||
# Show before/after version when we have a baseline. Best-effort.
|
||||
try:
|
||||
before = subprocess.run(
|
||||
["cua-driver", "--version"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
).stdout.strip()
|
||||
except Exception:
|
||||
before = ""
|
||||
else:
|
||||
before = ""
|
||||
|
||||
ok = _run_cua_driver_installer(label="Refreshing", verbose=False)
|
||||
if ok and before:
|
||||
try:
|
||||
after = subprocess.run(
|
||||
["cua-driver", "--version"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
).stdout.strip()
|
||||
if after and after != before:
|
||||
_print_success(f" cua-driver upgraded: {before} → {after}")
|
||||
elif after:
|
||||
_print_info(f" cua-driver up to date: {after}")
|
||||
except Exception:
|
||||
pass
|
||||
return ok
|
||||
|
||||
|
||||
def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -> bool:
|
||||
"""Run the upstream cua-driver install.sh. Returns True on success.
|
||||
|
||||
The script is idempotent: it always downloads the latest release, so
|
||||
re-running it on an already-installed system performs an upgrade.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
install_cmd = (
|
||||
"/bin/bash -c \"$(curl -fsSL "
|
||||
"https://raw.githubusercontent.com/trycua/cua/main/"
|
||||
"libs/cua-driver/scripts/install.sh)\""
|
||||
)
|
||||
if verbose:
|
||||
_print_info(f" {label} cua-driver (macOS background computer-use)...")
|
||||
else:
|
||||
_print_info(f" {label} cua-driver...")
|
||||
try:
|
||||
result = subprocess.run(install_cmd, shell=True, timeout=300)
|
||||
if result.returncode == 0 and shutil.which("cua-driver"):
|
||||
if verbose:
|
||||
_print_success(" cua-driver installed.")
|
||||
_print_info(" IMPORTANT — grant macOS permissions now:")
|
||||
_print_info(" System Settings > Privacy & Security > Accessibility")
|
||||
_print_info(" System Settings > Privacy & Security > Screen Recording")
|
||||
_print_info(" Both must allow the terminal / Hermes process.")
|
||||
return True
|
||||
_print_warning(f" cua-driver {label.lower()} did not complete. Re-run manually:")
|
||||
_print_info(f" {install_cmd}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(f" cua-driver {label.lower()} timed out. Re-run manually.")
|
||||
return False
|
||||
except Exception as e:
|
||||
_print_warning(f" cua-driver {label.lower()} failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _run_post_setup(post_setup_key: str):
|
||||
"""Run post-setup hooks for tools that need extra installation steps."""
|
||||
import shutil
|
||||
@@ -729,51 +855,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_info(" docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
|
||||
|
||||
elif post_setup_key == "cua_driver":
|
||||
# cua-driver provides macOS background computer-use (SkyLight SPIs).
|
||||
# Install via upstream curl script if the binary isn't on $PATH yet.
|
||||
import platform as _plat
|
||||
import subprocess
|
||||
if _plat.system() != "Darwin":
|
||||
_print_warning(" Computer Use (cua-driver) is macOS-only; skipping.")
|
||||
return
|
||||
if shutil.which("cua-driver"):
|
||||
try:
|
||||
version = subprocess.run(
|
||||
["cua-driver", "--version"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
).stdout.strip()
|
||||
_print_success(f" cua-driver already installed: {version or 'unknown version'}")
|
||||
except Exception:
|
||||
_print_success(" cua-driver already installed.")
|
||||
_print_info(" Grant macOS permissions if not done yet:")
|
||||
_print_info(" System Settings > Privacy & Security > Accessibility")
|
||||
_print_info(" System Settings > Privacy & Security > Screen Recording")
|
||||
return
|
||||
if not shutil.which("curl"):
|
||||
_print_warning(" curl not found — install manually:")
|
||||
_print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md")
|
||||
return
|
||||
_print_info(" Installing cua-driver (macOS background computer-use)...")
|
||||
try:
|
||||
install_cmd = (
|
||||
"/bin/bash -c \"$(curl -fsSL "
|
||||
"https://raw.githubusercontent.com/trycua/cua/main/"
|
||||
"libs/cua-driver/scripts/install.sh)\""
|
||||
)
|
||||
result = subprocess.run(install_cmd, shell=True, timeout=300)
|
||||
if result.returncode == 0 and shutil.which("cua-driver"):
|
||||
_print_success(" cua-driver installed.")
|
||||
_print_info(" IMPORTANT — grant macOS permissions now:")
|
||||
_print_info(" System Settings > Privacy & Security > Accessibility")
|
||||
_print_info(" System Settings > Privacy & Security > Screen Recording")
|
||||
_print_info(" Both must allow the terminal / Hermes process.")
|
||||
else:
|
||||
_print_warning(" cua-driver install did not complete. Re-run manually:")
|
||||
_print_info(f" {install_cmd}")
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(" cua-driver install timed out. Re-run manually.")
|
||||
except Exception as e:
|
||||
_print_warning(f" cua-driver install failed: {e}")
|
||||
install_cua_driver(upgrade=False)
|
||||
|
||||
elif post_setup_key == "kittentts":
|
||||
try:
|
||||
|
||||
+170
-3
@@ -2211,7 +2211,12 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "minimax-oauth",
|
||||
"name": "MiniMax (OAuth)",
|
||||
"flow": "pkce",
|
||||
# MiniMax's flow is structurally device-code (verification URI +
|
||||
# user code, backend polls the token endpoint) with a PKCE
|
||||
# extension for code-binding. The dashboard renders the same UX
|
||||
# as Nous's device-code flow; the PKCE bit is a security
|
||||
# extension that doesn't change the operator experience.
|
||||
"flow": "device_code",
|
||||
"cli_command": "hermes auth add minimax-oauth",
|
||||
"docs_url": "https://www.minimax.io",
|
||||
"status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
|
||||
@@ -2574,7 +2579,7 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
"""Initiate a device-code flow (Nous or OpenAI Codex).
|
||||
"""Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax).
|
||||
|
||||
Calls the provider's device-auth endpoint via the existing CLI helpers,
|
||||
then spawns a background poller. Returns the user-facing display fields
|
||||
@@ -2653,6 +2658,82 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
"poll_interval": int(s.get("interval") or 5),
|
||||
}
|
||||
|
||||
if provider_id == "minimax-oauth":
|
||||
# MiniMax uses a device-code-style flow (verification URI + user
|
||||
# code + background poll) with a PKCE extension on top. From the
|
||||
# operator's perspective it's identical to Nous's device-code
|
||||
# flow; the PKCE bit (verifier + challenge from
|
||||
# _minimax_pkce_pair) is a security extension that binds the
|
||||
# token exchange to the original session.
|
||||
from hermes_cli.auth import (
|
||||
_minimax_pkce_pair,
|
||||
_minimax_request_user_code,
|
||||
MINIMAX_OAUTH_CLIENT_ID,
|
||||
MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
)
|
||||
import httpx
|
||||
verifier, challenge, state = _minimax_pkce_pair()
|
||||
portal_base_url = (
|
||||
os.getenv("MINIMAX_PORTAL_BASE_URL") or MINIMAX_OAUTH_GLOBAL_BASE
|
||||
).rstrip("/")
|
||||
def _do_minimax_request():
|
||||
with httpx.Client(
|
||||
timeout=httpx.Timeout(15.0),
|
||||
headers={"Accept": "application/json"},
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
return _minimax_request_user_code(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
code_challenge=challenge,
|
||||
state=state,
|
||||
)
|
||||
device_data = await asyncio.get_event_loop().run_in_executor(
|
||||
None, _do_minimax_request
|
||||
)
|
||||
sid, sess = _new_oauth_session("minimax-oauth", "device_code")
|
||||
# The CLI flow names this `interval_ms` because MiniMax's
|
||||
# `interval` field is in milliseconds (defensive default 2000ms
|
||||
# in _minimax_poll_token).
|
||||
interval_raw = device_data.get("interval")
|
||||
sess["interval_ms"] = (
|
||||
int(interval_raw) if interval_raw is not None else None
|
||||
)
|
||||
sess["user_code"] = str(device_data["user_code"])
|
||||
sess["code_verifier"] = verifier
|
||||
sess["state"] = state
|
||||
sess["portal_base_url"] = portal_base_url
|
||||
sess["client_id"] = MINIMAX_OAUTH_CLIENT_ID
|
||||
sess["region"] = "global"
|
||||
# `expired_in` from MiniMax is overloaded — could be a unix-ms
|
||||
# timestamp OR a seconds-from-now duration. Mirror the heuristic
|
||||
# in _minimax_poll_token. Stash the raw value for the poller;
|
||||
# compute a derived expires_at + UI-friendly expires_in seconds.
|
||||
expired_in_raw = int(device_data["expired_in"])
|
||||
sess["expired_in_raw"] = expired_in_raw
|
||||
if expired_in_raw > 1_000_000_000_000: # likely unix-ms
|
||||
expires_at_ts = expired_in_raw / 1000.0
|
||||
expires_in_seconds = max(0, int(expires_at_ts - time.time()))
|
||||
else:
|
||||
expires_at_ts = time.time() + expired_in_raw
|
||||
expires_in_seconds = expired_in_raw
|
||||
sess["expires_at"] = expires_at_ts
|
||||
threading.Thread(
|
||||
target=_minimax_poller,
|
||||
args=(sid,),
|
||||
daemon=True,
|
||||
name=f"oauth-poll-{sid[:6]}",
|
||||
).start()
|
||||
return {
|
||||
"session_id": sid,
|
||||
"flow": "device_code",
|
||||
"user_code": str(device_data["user_code"]),
|
||||
"verification_url": str(device_data["verification_uri"]),
|
||||
"expires_in": expires_in_seconds,
|
||||
"poll_interval": max(2, (sess["interval_ms"] or 2000) // 1000),
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
|
||||
|
||||
|
||||
@@ -2714,6 +2795,86 @@ def _nous_poller(session_id: str) -> None:
|
||||
sess["error_message"] = str(e)
|
||||
|
||||
|
||||
def _minimax_poller(session_id: str) -> None:
|
||||
"""Background poller that drives a MiniMax OAuth flow to completion.
|
||||
|
||||
Mirrors `_nous_poller` but calls the MiniMax-specific token endpoint,
|
||||
which uses a PKCE-style ``code_verifier`` + ``user_code`` rather than
|
||||
the ``device_code`` field used by Nous. On success, builds the same
|
||||
auth_state dict that ``_minimax_oauth_login`` (the CLI flow) builds
|
||||
and persists via ``_minimax_save_auth_state`` — so the dashboard
|
||||
path leaves the system in the same state as
|
||||
``hermes auth add minimax-oauth``.
|
||||
"""
|
||||
from hermes_cli.auth import (
|
||||
_minimax_poll_token,
|
||||
_minimax_save_auth_state,
|
||||
MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
MINIMAX_OAUTH_SCOPE,
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
import httpx
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess:
|
||||
return
|
||||
portal_base_url = sess["portal_base_url"]
|
||||
client_id = sess["client_id"]
|
||||
user_code = sess["user_code"]
|
||||
code_verifier = sess["code_verifier"]
|
||||
interval_ms = sess.get("interval_ms")
|
||||
expired_in_raw = sess["expired_in_raw"]
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=httpx.Timeout(15.0),
|
||||
headers={"Accept": "application/json"},
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
token_data = _minimax_poll_token(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=client_id,
|
||||
user_code=user_code,
|
||||
code_verifier=code_verifier,
|
||||
expired_in=expired_in_raw,
|
||||
interval_ms=interval_ms,
|
||||
)
|
||||
# Build the auth_state dict in the same shape as the CLI flow's
|
||||
# `_minimax_oauth_login` so `_minimax_save_auth_state` writes
|
||||
# the canonical record. Region is fixed to "global" for the
|
||||
# dashboard path; cn-region operators can still use the CLI
|
||||
# flow which supports `--region cn`.
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_in_s = int(token_data["expired_in"])
|
||||
expires_at_ts = now.timestamp() + expires_in_s
|
||||
auth_state = {
|
||||
"provider": "minimax-oauth",
|
||||
"region": sess.get("region", "global"),
|
||||
"portal_base_url": portal_base_url,
|
||||
"inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
"client_id": client_id,
|
||||
"scope": MINIMAX_OAUTH_SCOPE,
|
||||
"token_type": token_data.get("token_type", "Bearer"),
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"resource_url": token_data.get("resource_url"),
|
||||
"obtained_at": now.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(
|
||||
expires_at_ts, tz=timezone.utc
|
||||
).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
}
|
||||
_minimax_save_auth_state(auth_state)
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: minimax login completed (session=%s)", session_id)
|
||||
except Exception as e:
|
||||
_log.warning("minimax device-code poll failed (session=%s): %s", session_id, e)
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "error"
|
||||
sess["error_message"] = str(e)
|
||||
|
||||
|
||||
def _codex_full_login_worker(session_id: str) -> None:
|
||||
"""Run the complete OpenAI Codex device-code flow.
|
||||
|
||||
@@ -2866,7 +3027,13 @@ async def start_oauth_login(provider_id: str, request: Request):
|
||||
detail=f"{provider_id} uses an external CLI; run `{catalog_entry['cli_command']}` manually",
|
||||
)
|
||||
try:
|
||||
if catalog_entry["flow"] == "pkce":
|
||||
# The pkce branch is gated on provider_id == "anthropic" because
|
||||
# `_start_anthropic_pkce()` is hardcoded to the Anthropic flow.
|
||||
# Routing any other future pkce-flagged provider through it would
|
||||
# silently launch the Anthropic OAuth flow (the bug fixed in this
|
||||
# change for MiniMax). New PKCE providers must add their own
|
||||
# start function and an explicit branch here.
|
||||
if catalog_entry["flow"] == "pkce" and provider_id == "anthropic":
|
||||
return _start_anthropic_pkce()
|
||||
if catalog_entry["flow"] == "device_code":
|
||||
return await _start_device_code_flow(provider_id)
|
||||
|
||||
+1
-2
@@ -154,8 +154,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1)
|
||||
echo "PASS: compiled entry.js present"
|
||||
|
||||
test -d ${hermes-agent}/ui-tui/node_modules || (echo "FAIL: node_modules missing"; exit 1)
|
||||
echo "PASS: node_modules present"
|
||||
# self-contained bundle; no runtime node_modules expected
|
||||
|
||||
grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \
|
||||
(echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1)
|
||||
|
||||
+3
-9
@@ -4,7 +4,7 @@ let
|
||||
src = ../ui-tui;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-MLcLhjTF6dgdvNBtJWzo8Nh19eNh/ZitD2b07nm61Tc=";
|
||||
hash = "sha256-9r1EYQ600gNXOnNXwakorpEk7hS/FPxZVbB2JksrhYs=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
@@ -24,16 +24,10 @@ pkgs.buildNpmPackage (npm // {
|
||||
|
||||
mkdir -p $out/lib/hermes-tui
|
||||
|
||||
# Single self-contained bundle built by scripts/build.mjs (esbuild).
|
||||
cp -r dist $out/lib/hermes-tui/dist
|
||||
|
||||
# runtime node_modules
|
||||
cp -r node_modules $out/lib/hermes-tui/node_modules
|
||||
|
||||
# @hermes/ink is a file: dependency, we need to copy it in fr
|
||||
rm -f $out/lib/hermes-tui/node_modules/@hermes/ink
|
||||
cp -r packages/hermes-ink $out/lib/hermes-tui/node_modules/@hermes/ink
|
||||
|
||||
# package.json needed for "type": "module" resolution
|
||||
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
|
||||
cp package.json $out/lib/hermes-tui/
|
||||
|
||||
runHook postInstall
|
||||
|
||||
@@ -15,7 +15,7 @@ session-picker flow.
|
||||
Environment overrides:
|
||||
HERMES_PERF_LOG (default ~/.hermes/perf.log)
|
||||
HERMES_PERF_NODE (default node from $PATH)
|
||||
HERMES_TUI_DIR (default /home/bb/hermes-agent/ui-tui)
|
||||
HERMES_TUI_DIR (default: <repo>/ui-tui relative to this script)
|
||||
|
||||
Exit code is 0 if the harness ran and parsed results, 2 if the TUI crashed
|
||||
or produced no perf data (suggests HERMES_DEV_PERF wiring is broken).
|
||||
@@ -44,7 +44,10 @@ except ImportError:
|
||||
val = (os.environ.get("HERMES_HOME") or "").strip()
|
||||
return Path(val) if val else Path.home() / ".hermes"
|
||||
|
||||
DEFAULT_TUI_DIR = Path(os.environ.get("HERMES_TUI_DIR", "/home/bb/hermes-agent/ui-tui"))
|
||||
DEFAULT_TUI_DIR = Path(
|
||||
os.environ.get("HERMES_TUI_DIR")
|
||||
or str(Path(__file__).resolve().parent.parent / "ui-tui")
|
||||
)
|
||||
DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(get_hermes_home() / "perf.log")))
|
||||
DEFAULT_STATE_DB = get_hermes_home() / "state.db"
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ AUTHOR_MAP = {
|
||||
"421774554@qq.com": "wuli666",
|
||||
"harish.kukreja@gmail.com": "counterposition",
|
||||
"1046611633@qq.com": "zhengyn0001",
|
||||
"ahmed@abadr.net": "ahmedbadr3",
|
||||
"cleo@edaphic.xyz": "curiouscleo",
|
||||
"hirokazu.ogawa@kwansei.ac.jp": "hrkzogw",
|
||||
"datapod.k@gmail.com": "dandacompany",
|
||||
|
||||
@@ -156,6 +156,108 @@ def test_passes_non_table_lines_through_around_a_table():
|
||||
assert all(o == offsets[0] for o in offsets)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vertical fallback for tables wider than the terminal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_overflow_falls_back_to_vertical_when_table_wider_than_terminal():
|
||||
"""A horizontal table that would exceed the available width must
|
||||
drop to vertical key-value rendering so the terminal does not
|
||||
soft-wrap mid-cell (which destroys column alignment visually)."""
|
||||
|
||||
src = dedent(
|
||||
"""\
|
||||
| Item | Description | Notes |
|
||||
|------|-------------|-------|
|
||||
| a | short | ok |
|
||||
| b | this is a much longer description that stretches the column wider than the others by a lot | fine |
|
||||
| c | tiny | - |
|
||||
"""
|
||||
)
|
||||
|
||||
out = realign_markdown_tables(src, available_width=100)
|
||||
|
||||
# No horizontal pipe-bordered rows: vertical mode emits "Header: value"
|
||||
# lines and a ─ separator instead.
|
||||
assert "|" not in out
|
||||
assert "Item: a" in out
|
||||
assert "Description: short" in out
|
||||
assert "Notes: ok" in out
|
||||
# Body rows separated by ─ rule
|
||||
assert "──" in out
|
||||
|
||||
# Every emitted line fits the available width.
|
||||
for line in out.split("\n"):
|
||||
assert wcswidth(line) <= 100, f"line wider than budget: {line!r}"
|
||||
|
||||
|
||||
def test_horizontal_kept_when_table_fits():
|
||||
"""A table that fits the terminal must keep the horizontal
|
||||
pipe-bordered rendering — vertical fallback only kicks in when
|
||||
soft-wrap is unavoidable."""
|
||||
|
||||
src = dedent(
|
||||
"""\
|
||||
| Name | Age |
|
||||
|------|-----|
|
||||
| Alice | 30 |
|
||||
| Bob | 25 |
|
||||
"""
|
||||
)
|
||||
|
||||
out = realign_markdown_tables(src, available_width=100)
|
||||
|
||||
# Pipe-bordered rendering survives.
|
||||
body_rows = [ln for ln in out.split("\n") if ln.strip().startswith("|")]
|
||||
assert len(body_rows) == 4
|
||||
offsets = [_column_offsets(r) for r in body_rows]
|
||||
assert all(o == offsets[0] for o in offsets)
|
||||
|
||||
|
||||
def test_vertical_fallback_wraps_long_cell_text_with_indent():
|
||||
src = dedent(
|
||||
"""\
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| x | this value is long enough that wrapping the value to fit a narrow terminal width is required even in vertical mode |
|
||||
"""
|
||||
)
|
||||
|
||||
out = realign_markdown_tables(src, available_width=60)
|
||||
|
||||
lines = out.split("\n")
|
||||
assert lines[0].startswith("Key: x")
|
||||
# First "Value:" line + at least one continuation indented by 2 spaces.
|
||||
value_idx = next(i for i, l in enumerate(lines) if l.startswith("Value:"))
|
||||
assert lines[value_idx + 1].startswith(" ")
|
||||
# Every line still fits the budget.
|
||||
for line in lines:
|
||||
assert wcswidth(line) <= 60
|
||||
|
||||
|
||||
def test_overflow_falls_back_to_vertical_for_cjk_too():
|
||||
"""CJK content can also push a table over the terminal budget;
|
||||
the vertical fallback should kick in regardless of script."""
|
||||
|
||||
src = dedent(
|
||||
"""\
|
||||
| 模型 | 描述 | 备注 |
|
||||
|------|------|------|
|
||||
| 千问 | 一个相当长的描述用于把列宽撑得超过可用终端宽度从而触发竖排回退 | 通过 |
|
||||
| 文心 | 短 | × |
|
||||
"""
|
||||
)
|
||||
|
||||
out = realign_markdown_tables(src, available_width=50)
|
||||
|
||||
assert "|" not in out
|
||||
assert "模型: 千问" in out
|
||||
assert "模型: 文心" in out
|
||||
for line in out.split("\n"):
|
||||
assert wcswidth(line) <= 50, f"line wider than budget: {line!r}"
|
||||
|
||||
|
||||
def test_handles_ragged_rows_by_padding_short_rows():
|
||||
src = dedent(
|
||||
"""\
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for ``install_cua_driver`` upgrade semantics.
|
||||
|
||||
The cua-driver upstream installer always pulls the latest release tag, so
|
||||
re-running it is the canonical upgrade path. ``install_cua_driver(upgrade=True)``
|
||||
must:
|
||||
|
||||
* Be macOS-only — no-op silently on Linux/Windows so ``hermes update`` can
|
||||
call it unconditionally without warning every non-macOS user.
|
||||
* Re-run the installer even when the binary is already on PATH (this is the
|
||||
fix for the "we only pulled cua-driver once on enable" complaint).
|
||||
* Preserve original ``upgrade=False`` behaviour for the toolset-enable flow:
|
||||
skip if installed, install otherwise, warn on non-macOS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestInstallCuaDriverUpgrade:
|
||||
def test_upgrade_on_non_macos_is_silent_noop(self):
|
||||
"""``hermes update`` calls install_cua_driver(upgrade=True) for every
|
||||
user. On Linux/Windows it must return False without printing the
|
||||
"macOS-only; skipping" warning that the toolset-enable path emits."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch.object(tools_config, "_print_warning") as warn, \
|
||||
patch("platform.system", return_value="Linux"):
|
||||
assert tools_config.install_cua_driver(upgrade=True) is False
|
||||
warn.assert_not_called()
|
||||
|
||||
def test_non_upgrade_on_non_macos_warns(self):
|
||||
"""The toolset-enable path (upgrade=False) should still warn loudly
|
||||
when the user tries to enable Computer Use on a non-macOS host."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch.object(tools_config, "_print_warning") as warn, \
|
||||
patch("platform.system", return_value="Linux"):
|
||||
assert tools_config.install_cua_driver(upgrade=False) is False
|
||||
warn.assert_called()
|
||||
|
||||
def test_upgrade_on_macos_with_binary_runs_installer(self):
|
||||
"""When cua-driver is already on PATH and upgrade=True, we must
|
||||
re-run the upstream installer (this is the fix for the bug report).
|
||||
"""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/local/bin/" + n
|
||||
if n in ("cua-driver", "curl") else None), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer",
|
||||
return_value=True) as runner, \
|
||||
patch("subprocess.run"):
|
||||
assert tools_config.install_cua_driver(upgrade=True) is True
|
||||
runner.assert_called_once()
|
||||
# Refresh path uses non-verbose mode so we don't re-print the
|
||||
# "grant macOS permissions" block on every `hermes update`.
|
||||
kwargs = runner.call_args.kwargs
|
||||
assert kwargs.get("verbose") is False
|
||||
|
||||
def test_upgrade_on_macos_without_binary_runs_installer(self):
|
||||
"""upgrade=True with cua-driver missing must still trigger an
|
||||
install — equivalent to a fresh install. (Don't silently no-op.)"""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer",
|
||||
return_value=True) as runner:
|
||||
assert tools_config.install_cua_driver(upgrade=True) is True
|
||||
runner.assert_called_once()
|
||||
|
||||
def test_non_upgrade_on_macos_with_binary_skips_install(self):
|
||||
"""Original toolset-enable behaviour: cua-driver already installed
|
||||
+ upgrade=False → confirm and return without re-running installer.
|
||||
This is the behaviour that ``hermes tools`` (re)enable depends on,
|
||||
so the new helper must not regress it."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/local/bin/" + n
|
||||
if n in ("cua-driver", "curl") else None), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer") as runner, \
|
||||
patch("subprocess.run"):
|
||||
assert tools_config.install_cua_driver(upgrade=False) is True
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_non_upgrade_on_macos_without_binary_runs_installer(self):
|
||||
"""Original fresh-install path must still work."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer",
|
||||
return_value=True) as runner:
|
||||
assert tools_config.install_cua_driver(upgrade=False) is True
|
||||
runner.assert_called_once()
|
||||
|
||||
def test_upgrade_without_curl_does_not_crash(self):
|
||||
"""If curl isn't on PATH we can't refresh — must warn and return
|
||||
the current install state, not raise."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
# cua-driver present, curl missing.
|
||||
def _which(name):
|
||||
return "/usr/local/bin/cua-driver" if name == "cua-driver" else None
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which", side_effect=_which), \
|
||||
patch.object(tools_config, "_print_warning"):
|
||||
assert tools_config.install_cua_driver(upgrade=True) is True
|
||||
@@ -328,3 +328,58 @@ class TestIntegrationWithModelsModule:
|
||||
"anthropic/claude-opus-4.7",
|
||||
"moonshotai/kimi-k2.6",
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Drift guard — prevent the in-repo curated lists from going out of sync with
|
||||
# the docs-hosted manifest at website/static/api/model-catalog.json.
|
||||
#
|
||||
# History: qwen/qwen3.6-plus was added to _PROVIDER_MODELS["nous"] in commit
|
||||
# 9dd6e5510 but website/static/api/model-catalog.json was not regenerated for
|
||||
# weeks, so free-tier users on a new install fetched a stale manifest and the
|
||||
# free-tier picker showed "No free models currently available." even though
|
||||
# the Portal was serving qwen/qwen3.6-plus as free. CI must catch this.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestManifestMatchesInRepoLists:
|
||||
"""Fail if the on-disk manifest is out of date relative to in-repo lists."""
|
||||
|
||||
@staticmethod
|
||||
def _strip_volatile(catalog: dict) -> dict:
|
||||
"""Drop fields that always change (timestamps) for diff comparison."""
|
||||
out = dict(catalog)
|
||||
out.pop("updated_at", None)
|
||||
return out
|
||||
|
||||
def test_in_repo_lists_match_manifest(self):
|
||||
"""``scripts/build_model_catalog.py`` output must match the committed file.
|
||||
|
||||
If this fails, run ``python scripts/build_model_catalog.py`` and
|
||||
commit the regenerated ``website/static/api/model-catalog.json``.
|
||||
"""
|
||||
# Resolve the repo root from this test file's location.
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
manifest_path = repo_root / "website" / "static" / "api" / "model-catalog.json"
|
||||
|
||||
if not manifest_path.exists():
|
||||
pytest.skip(f"manifest missing at {manifest_path}")
|
||||
|
||||
# Build expected catalog using the same script CI would.
|
||||
import importlib.util
|
||||
script_path = repo_root / "scripts" / "build_model_catalog.py"
|
||||
spec = importlib.util.spec_from_file_location("_build_model_catalog", script_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(mod)
|
||||
expected = mod.build_catalog()
|
||||
|
||||
with open(manifest_path, encoding="utf-8") as fh:
|
||||
actual = json.load(fh)
|
||||
|
||||
assert self._strip_volatile(actual) == self._strip_volatile(expected), (
|
||||
"website/static/api/model-catalog.json is out of sync with "
|
||||
"_PROVIDER_MODELS['nous'] / OPENROUTER_MODELS. "
|
||||
"Run: python scripts/build_model_catalog.py && "
|
||||
"git add website/static/api/model-catalog.json"
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from hermes_cli.models import (
|
||||
OPENROUTER_MODELS, fetch_openrouter_models, model_ids, detect_provider_for_model,
|
||||
is_nous_free_tier, partition_nous_models_by_tier,
|
||||
check_nous_free_tier, _FREE_TIER_CACHE_TTL,
|
||||
union_with_portal_free_recommendations,
|
||||
)
|
||||
import hermes_cli.models as _models_mod
|
||||
|
||||
@@ -383,6 +384,128 @@ class TestPartitionNousModelsByTier:
|
||||
assert unav == models
|
||||
|
||||
|
||||
class TestUnionWithPortalFreeRecommendations:
|
||||
"""Tests for union_with_portal_free_recommendations.
|
||||
|
||||
The Portal's freeRecommendedModels endpoint is the source of truth for
|
||||
what's free *right now* — the in-repo curated list and docs-hosted
|
||||
manifest can lag. This helper guarantees the picker still surfaces
|
||||
Portal-flagged free models even when the rest of the catalog is stale.
|
||||
"""
|
||||
|
||||
_PAID = {"prompt": "0.000003", "completion": "0.000015"}
|
||||
_FREE = {"prompt": "0", "completion": "0"}
|
||||
|
||||
def _payload(self, free_models: list[str]) -> dict:
|
||||
return {
|
||||
"freeRecommendedModels": [
|
||||
{"modelName": mid, "displayName": mid} for mid in free_models
|
||||
],
|
||||
}
|
||||
|
||||
def test_adds_portal_free_model_missing_from_curated(self):
|
||||
"""A Portal-advertised free model not in curated is prepended + priced free."""
|
||||
curated = ["anthropic/claude-opus-4.6"]
|
||||
pricing = {"anthropic/claude-opus-4.6": self._PAID}
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_nous_recommended_models",
|
||||
return_value=self._payload(["qwen/qwen3.6-plus"]),
|
||||
):
|
||||
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
||||
|
||||
assert ids[0] == "qwen/qwen3.6-plus" # prepended
|
||||
assert "anthropic/claude-opus-4.6" in ids
|
||||
# Synthetic free pricing entry created
|
||||
assert p["qwen/qwen3.6-plus"] == self._FREE
|
||||
# Existing pricing untouched
|
||||
assert p["anthropic/claude-opus-4.6"] == self._PAID
|
||||
|
||||
def test_does_not_duplicate_curated_entries(self):
|
||||
"""A Portal free model already in curated is not duplicated."""
|
||||
curated = ["qwen/qwen3.6-plus", "anthropic/claude-opus-4.6"]
|
||||
pricing = {
|
||||
"qwen/qwen3.6-plus": self._FREE,
|
||||
"anthropic/claude-opus-4.6": self._PAID,
|
||||
}
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_nous_recommended_models",
|
||||
return_value=self._payload(["qwen/qwen3.6-plus"]),
|
||||
):
|
||||
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
||||
|
||||
assert ids == curated
|
||||
assert p == pricing
|
||||
|
||||
def test_then_partition_keeps_portal_free_model(self):
|
||||
"""End-to-end: Portal-flagged free model survives partition."""
|
||||
# Simulate the broken-state-before-this-fix: in-repo curated list
|
||||
# contains qwen/qwen3.6-plus (because new builds shipped it) but
|
||||
# live pricing endpoint hasn't published its zero-cost entry yet.
|
||||
# The Portal's freeRecommendedModels still flags it as free.
|
||||
curated = ["qwen/qwen3.6-plus", "anthropic/claude-opus-4.6"]
|
||||
pricing = {"anthropic/claude-opus-4.6": self._PAID} # qwen missing!
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_nous_recommended_models",
|
||||
return_value=self._payload(["qwen/qwen3.6-plus"]),
|
||||
):
|
||||
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
||||
sel, unav = partition_nous_models_by_tier(ids, p, free_tier=True)
|
||||
assert "qwen/qwen3.6-plus" in sel
|
||||
assert "anthropic/claude-opus-4.6" in unav
|
||||
|
||||
def test_empty_payload_returns_inputs_unchanged(self):
|
||||
"""Empty Portal response leaves curated + pricing untouched."""
|
||||
curated = ["a", "b"]
|
||||
pricing = {"a": self._PAID}
|
||||
with patch("hermes_cli.models.fetch_nous_recommended_models", return_value={}):
|
||||
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
||||
assert ids == curated
|
||||
assert p == pricing
|
||||
|
||||
def test_missing_freeRecommendedModels_key(self):
|
||||
"""Portal payload without freeRecommendedModels degrades gracefully."""
|
||||
curated = ["a"]
|
||||
pricing = {"a": self._PAID}
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_nous_recommended_models",
|
||||
return_value={"paidRecommendedModels": [{"modelName": "x"}]},
|
||||
):
|
||||
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
||||
assert ids == curated
|
||||
assert p == pricing
|
||||
|
||||
def test_fetch_failure_returns_inputs(self):
|
||||
"""Network failures don't blow up the picker."""
|
||||
curated = ["a"]
|
||||
pricing = {"a": self._PAID}
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_nous_recommended_models",
|
||||
side_effect=RuntimeError("network down"),
|
||||
):
|
||||
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
||||
assert ids == curated
|
||||
assert p == pricing
|
||||
|
||||
def test_invalid_entries_skipped(self):
|
||||
"""Non-dict / missing-modelName entries are filtered out."""
|
||||
curated = ["a"]
|
||||
pricing = {"a": self._PAID}
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_nous_recommended_models",
|
||||
return_value={
|
||||
"freeRecommendedModels": [
|
||||
"not-a-dict",
|
||||
{"displayName": "no-modelName"},
|
||||
{"modelName": ""},
|
||||
{"modelName": "qwen/qwen3.6-plus"},
|
||||
]
|
||||
},
|
||||
):
|
||||
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
||||
assert ids == ["qwen/qwen3.6-plus", "a"]
|
||||
assert p["qwen/qwen3.6-plus"] == self._FREE
|
||||
|
||||
|
||||
class TestCheckNousFreeTierCache:
|
||||
"""Tests for the TTL cache on check_nous_free_tier()."""
|
||||
|
||||
|
||||
@@ -25,12 +25,6 @@ def _touch_tui_entry(root: Path) -> None:
|
||||
entry.write_text("console.log('tui')")
|
||||
|
||||
|
||||
def _touch_ink_bundle(root: Path) -> None:
|
||||
bundle = root / "packages" / "hermes-ink" / "dist" / "ink-bundle.js"
|
||||
bundle.parent.mkdir(parents=True, exist_ok=True)
|
||||
bundle.write_text("export {}")
|
||||
|
||||
|
||||
def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None:
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
@@ -122,17 +116,7 @@ def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod)
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
|
||||
|
||||
def test_build_needed_when_local_ink_bundle_missing(tmp_path: Path, main_mod) -> None:
|
||||
def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
|
||||
"""dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install."""
|
||||
_touch_tui_entry(tmp_path)
|
||||
_touch_ink(tmp_path)
|
||||
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
assert main_mod._tui_build_needed(tmp_path) is True
|
||||
|
||||
|
||||
def test_build_not_needed_when_entry_and_ink_bundle_present(tmp_path: Path, main_mod) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
_touch_ink(tmp_path)
|
||||
_touch_ink_bundle(tmp_path)
|
||||
|
||||
assert main_mod._tui_build_needed(tmp_path) is False
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Regression tests for the OAuth dispatcher in hermes_cli.web_server.
|
||||
|
||||
Bug history (2026-05-09): the `_OAUTH_PROVIDER_CATALOG` had two entries
|
||||
flagged ``flow: "pkce"`` — anthropic and minimax-oauth — and the
|
||||
dispatcher ``start_oauth_login`` hardcoded ``_start_anthropic_pkce()``
|
||||
for any pkce-flagged provider. So clicking "Login" next to MiniMax in
|
||||
the dashboard's Keys tab silently launched the Anthropic/Claude OAuth
|
||||
flow.
|
||||
|
||||
The fix:
|
||||
1. Catalog entry for minimax-oauth changed from ``flow: "pkce"`` to
|
||||
``flow: "device_code"`` (the actual UX is verification URI + user
|
||||
code + background poll, with PKCE as a security extension).
|
||||
2. New MiniMax branch added to ``_start_device_code_flow``.
|
||||
3. Dispatcher tightened: pkce branch now requires
|
||||
``provider_id == "anthropic"``, so any future PKCE provider added
|
||||
without an explicit branch gets a clean ``400 Unsupported flow``
|
||||
instead of silently launching Anthropic OAuth.
|
||||
|
||||
These tests pin the corrected behavior.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli.web_server import _SESSION_TOKEN, app
|
||||
|
||||
client = TestClient(app)
|
||||
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
|
||||
|
||||
|
||||
def test_minimax_login_does_not_launch_anthropic_flow():
|
||||
"""Click 'Login' on MiniMax → MUST NOT return claude.ai auth_url."""
|
||||
fake_user_code_resp = {
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://api.minimax.io/oauth/verify",
|
||||
# `expired_in` < 1e12 so the heuristic treats it as seconds.
|
||||
"expired_in": 600,
|
||||
"interval": 2000,
|
||||
"state": "stub-state",
|
||||
}
|
||||
with patch(
|
||||
"hermes_cli.auth._minimax_request_user_code",
|
||||
return_value=fake_user_code_resp,
|
||||
), patch(
|
||||
"hermes_cli.auth._minimax_pkce_pair",
|
||||
return_value=("verifier-stub", "challenge-stub", "stub-state"),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/providers/oauth/minimax-oauth/start",
|
||||
headers=HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
# The bug used to return Anthropic's auth_url — make sure the response
|
||||
# references neither the auth_url field nor anything Claude-related.
|
||||
assert "auth_url" not in body
|
||||
assert "claude.ai" not in str(body).lower()
|
||||
|
||||
# And the response IS the device-code shape pointing at MiniMax.
|
||||
assert body["flow"] == "device_code"
|
||||
assert "minimax" in body["verification_url"].lower()
|
||||
assert body["user_code"] == "ABCD-1234"
|
||||
assert body["expires_in"] == 600
|
||||
|
||||
|
||||
def test_anthropic_pkce_branch_still_works():
|
||||
"""Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path."""
|
||||
fake_anthropic_response = {
|
||||
"session_id": "stub-session",
|
||||
"flow": "pkce",
|
||||
"auth_url": "https://claude.ai/oauth/authorize?code=true&...",
|
||||
"expires_in": 600,
|
||||
}
|
||||
with patch(
|
||||
"hermes_cli.web_server._start_anthropic_pkce",
|
||||
return_value=fake_anthropic_response,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/providers/oauth/anthropic/start",
|
||||
headers=HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["flow"] == "pkce"
|
||||
assert "claude.ai" in body["auth_url"]
|
||||
|
||||
|
||||
def test_unknown_pkce_provider_rejected_cleanly():
|
||||
"""A future PKCE provider without an explicit branch must NOT silently route to Anthropic.
|
||||
|
||||
Simulates a hypothetical catalog entry with ``flow: "pkce"`` and an
|
||||
id other than "anthropic". The dispatcher should fall through past
|
||||
the pkce branch (now gated on provider_id) and the device_code
|
||||
branch, then hit "Unsupported flow" — proving the bug class is
|
||||
structurally prevented.
|
||||
"""
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
# Inject a hypothetical catalog entry that's pkce-flagged but isn't
|
||||
# anthropic. This shape mirrors what would happen if a developer
|
||||
# added a new provider entry without remembering to wire up its
|
||||
# start function.
|
||||
fake_entry = {
|
||||
"id": "hypothetical-pkce-provider",
|
||||
"name": "Hypothetical PKCE Provider",
|
||||
"flow": "pkce",
|
||||
"cli_command": "hermes auth add hypothetical-pkce-provider",
|
||||
"docs_url": "https://example.com",
|
||||
"status_fn": None,
|
||||
}
|
||||
original_catalog = ws._OAUTH_PROVIDER_CATALOG
|
||||
try:
|
||||
ws._OAUTH_PROVIDER_CATALOG = original_catalog + (fake_entry,)
|
||||
resp = client.post(
|
||||
"/api/providers/oauth/hypothetical-pkce-provider/start",
|
||||
headers=HEADERS,
|
||||
)
|
||||
finally:
|
||||
ws._OAUTH_PROVIDER_CATALOG = original_catalog
|
||||
|
||||
# Either 400 "Unsupported flow" (the explicit fall-through) or any
|
||||
# 4xx — what we MUST NOT see is a 200 with claude.ai in the body.
|
||||
assert resp.status_code >= 400, resp.text
|
||||
assert "claude.ai" not in resp.text.lower()
|
||||
+1
-1
@@ -41,7 +41,7 @@ From the repo root, the normal path is:
|
||||
hermes --tui
|
||||
```
|
||||
|
||||
The CLI expects `ui-tui/node_modules` to exist. If the TUI deps are missing:
|
||||
The CLI expects `ui-tui/dist/entry.js` to exist, or the whole source code available in which to run `npm install` and `npm run dev`.
|
||||
|
||||
```bash
|
||||
cd ui-tui
|
||||
|
||||
Generated
+1
@@ -26,6 +26,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"esbuild": "~0.27.0",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-perfectionist": "^5",
|
||||
"eslint-plugin-react": "^7",
|
||||
|
||||
+2
-2
@@ -6,8 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx",
|
||||
"start": "tsx src/entry.tsx",
|
||||
"build": "npm run build --prefix packages/hermes-ink && tsc -p tsconfig.build.json && npm run build:compile && chmod +x dist/entry.js",
|
||||
"build:compile": "babel dist --out-dir dist --config-file ./babel.compiler.config.cjs --extensions .js --keep-file-extension",
|
||||
"build": "node scripts/build.mjs",
|
||||
"type-check": "tsc --noEmit -p tsconfig.json",
|
||||
"lint": "eslint src/ packages/",
|
||||
"lint:fix": "eslint src/ packages/ --fix",
|
||||
@@ -35,6 +34,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"esbuild": "~0.27.0",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-perfectionist": "^5",
|
||||
"eslint-plugin-react": "^7",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
// Bundles src/entry.tsx into a single self-contained dist/entry.js.
|
||||
// No runtime node_modules needed.
|
||||
import { build } from 'esbuild'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '..')
|
||||
const out = resolve(root, 'dist/entry.js')
|
||||
|
||||
// `react-devtools-core` is only imported when DEV=true at runtime (Ink dev
|
||||
// mode). Stub it out so the bundle doesn't carry the dep.
|
||||
const stubDevtools = {
|
||||
name: 'stub-react-devtools-core',
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /^react-devtools-core$/ }, args => ({
|
||||
path: args.path,
|
||||
namespace: 'stub-devtools'
|
||||
}))
|
||||
b.onLoad({ filter: /.*/, namespace: 'stub-devtools' }, () => ({
|
||||
contents: 'export default { initialize() {}, connectToDevTools() {} }',
|
||||
loader: 'js'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
await build({
|
||||
entryPoints: [resolve(root, 'src/entry.tsx')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node20',
|
||||
outfile: out,
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'react',
|
||||
// Skip the prebuilt @hermes/ink bundle — esbuild's __esm helper doesn't
|
||||
// await nested async init, which breaks lazy-initialized exports like
|
||||
// `render`. Bundling from source sidesteps that.
|
||||
alias: { '@hermes/ink': resolve(root, 'packages/hermes-ink/src/entry-exports.ts') },
|
||||
plugins: [stubDevtools],
|
||||
// Some transitive deps use CommonJS `require(...)` at runtime. ESM bundles
|
||||
// don't get a `require` binding automatically, so we inject one.
|
||||
banner: {
|
||||
js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);"
|
||||
},
|
||||
logLevel: 'info'
|
||||
})
|
||||
|
||||
// esbuild preserves the shebang from src/entry.tsx into the bundle, but Nix's
|
||||
// patchShebangs phase mangles `/usr/bin/env -S node --foo --bar` (it strips
|
||||
// the `node` token, leaving a broken interpreter). The hermes_cli launcher
|
||||
// always invokes this file as `node dist/entry.js` anyway, so the shebang is
|
||||
// redundant — strip it.
|
||||
const body = readFileSync(out, 'utf8')
|
||||
if (body.startsWith('#!')) {
|
||||
writeFileSync(out, body.slice(body.indexOf('\n') + 1))
|
||||
}
|
||||
|
||||
console.log(`built ${out}`)
|
||||
@@ -976,7 +976,8 @@ Subcommands:
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `install` | Run the upstream cua-driver installer (macOS only). |
|
||||
| `status` | Print whether `cua-driver` is on `$PATH`. |
|
||||
| `install --upgrade` | Re-run the installer even if cua-driver is already on PATH. The upstream script always pulls the latest release, so this performs an in-place upgrade. |
|
||||
| `status` | Print whether `cua-driver` is on `$PATH` and which version is installed. |
|
||||
|
||||
`hermes computer-use install` is the stable entry point for installing the
|
||||
[cua-driver](https://github.com/trycua/cua) binary used by the
|
||||
@@ -985,6 +986,11 @@ Subcommands:
|
||||
to use for re-running the install if the toolset toggle didn't trigger
|
||||
it (for example, on returning-user setups).
|
||||
|
||||
`hermes update` automatically re-runs the upstream installer at the end
|
||||
of the update if cua-driver is on PATH, so most users will not need to
|
||||
call `--upgrade` manually. Use it when upstream ships a fix you want
|
||||
right now without waiting for the next Hermes update.
|
||||
|
||||
## `hermes sessions`
|
||||
|
||||
```bash
|
||||
|
||||
@@ -57,6 +57,23 @@ After installing, regardless of which path you took:
|
||||
```
|
||||
or add `computer_use` to your enabled toolsets in `~/.hermes/config.yaml`.
|
||||
|
||||
## Keeping cua-driver up to date
|
||||
|
||||
The cua-driver project ships fixes regularly (e.g. v0.1.6 fixed a Safari
|
||||
window-focus bug for UTM workflows). Hermes refreshes the binary in two
|
||||
places so you don't get stuck on a stale release:
|
||||
|
||||
- **`hermes update`** — when you update Hermes itself, if `cua-driver` is
|
||||
on PATH the upstream installer re-runs at the end of the update.
|
||||
No-op for non-macOS users and for users without cua-driver installed.
|
||||
- **`hermes computer-use install --upgrade`** — manual force-refresh.
|
||||
Re-runs the upstream installer regardless of whether cua-driver is
|
||||
already installed. Use this when you want the latest fix without
|
||||
waiting for the next agent update.
|
||||
|
||||
`hermes computer-use status` shows the installed version next to the
|
||||
binary path.
|
||||
|
||||
## Quick example
|
||||
|
||||
User prompt: *"Find my latest email from Stripe and summarise what they want me to do."*
|
||||
|
||||
@@ -66,7 +66,7 @@ export HERMES_TUI_DIR=/path/to/prebuilt/ui-tui
|
||||
hermes --tui
|
||||
```
|
||||
|
||||
The directory must contain `dist/entry.js` and an up-to-date `node_modules`.
|
||||
The directory must contain `dist/entry.js`.
|
||||
|
||||
## Keybindings
|
||||
|
||||
|
||||
Reference in New Issue
Block a user