mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
tui: friendlier model display + group same-endpoint providers in picker
Two related TUI quality-of-life fixes for users running multiple models
behind a single proxy/aggregator (e.g. Palantir Foundry, Bedrock,
self-hosted vLLM behind a single key).
1. _get_status_bar_snapshot() — friendlier model name in the status bar.
Long catalog IDs (Palantir RIDs like
``ri.language-model-service..language-model.anthropic-claude-4-7-opus``)
were truncated to ``ri.language-model-ser...`` by the existing 26-char
slash-split, leaving the user with no way to tell which model is active.
The status bar now:
* Reverse-looks up the model id in config.yaml ``model_aliases:`` /
``model.aliases:`` and shows the shortest configured alias when one
exists (so users who set up a friendly alias get it for free).
* Falls back to stripping Palantir's ``ri.<service>..<ns>.`` RID prefix
before length-truncation, so the truncated label carries the actual
model identity (``anthropic-claude-4-7-opus``) instead of the URN
scheme.
* Reverse-alias map is cached at module level (config is loaded once
per session; no need to re-resolve on every status-bar refresh).
2. list_authenticated_providers() section 3 — group ``providers:`` entries
by (api_url, key_env, api_mode), mirroring section 4's existing grouping
for ``custom_providers:`` lists.
Before: a Palantir Foundry config with two Anthropic-proxy entries
(``palantir-claude46`` + ``palantir-claude47``) produced two near-
duplicate picker rows labelled ``Palantir Claude 4.6 Opus`` and
``Palantir Claude 4.7 Opus`` — same endpoint, same PALANTIR_TOKEN,
same anthropic_messages wire protocol, differing only by model id.
After: those entries collapse into a single ``Palantir Claude`` row
with both models in the dropdown. Same-host entries with a different
``api_mode`` (e.g. an OpenAI-compat ``palantir-gpt54`` alongside the
Anthropic claude rows on the same host) keep distinct rows since
the wire protocol differs — same safety invariant section 4 already
enforced for ``custom_providers:``.
Group display name strips per-version trailing tokens (``Palantir
Claude 4.7 Opus`` → ``Palantir Claude``) only when the prefix has
≥2 words, so single-word names aren't over-trimmed.
The new code records (raw_display_name, api_url) into
_section3_emitted_pairs for every raw entry that joined the group, so
section 4's compatibility-merged ``custom_providers`` view (built by
``get_compatible_custom_providers()`` which calls
``providers_dict_to_custom_providers()`` to convert ``providers:``
into custom-provider shape) still dedupes against this grouped row.
Manual smoke test on a config with three Palantir entries
(claude-4.6, claude-4.7, gpt-5.4): before — 3 picker rows; after — 2
picker rows (1 row "Palantir Claude" with 2 models, 1 row
"Palantir GPT-5.4" with 1 model).
This commit is contained in:
parent
2684e3077f
commit
4e02320ed9
2 changed files with 169 additions and 16 deletions
64
cli.py
64
cli.py
|
|
@ -119,6 +119,52 @@ def format_duration_compact(*args, **kwargs):
|
|||
return f"{days:.1f}d"
|
||||
|
||||
|
||||
# Cached reverse map of config.yaml ``model_aliases:`` so the TUI can show
|
||||
# friendly names instead of full Palantir RIDs / long catalog IDs. Built
|
||||
# lazily on first call; cache is process-lifetime (config is read once at
|
||||
# session start, so further invalidation is unnecessary).
|
||||
_REVERSE_ALIAS_CACHE: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _reverse_alias_for_display(model_name: str) -> str:
|
||||
"""Return the shortest configured alias for ``model_name``, or ``model_name``.
|
||||
|
||||
Looks up both ``model_aliases:`` (dict-based, full DirectAlias entries)
|
||||
and ``model.aliases:`` (string-based, set via ``hermes config set``)
|
||||
from config.yaml. Multiple aliases pointing at the same model — the
|
||||
shortest wins, so ``opus47`` beats ``palantir-claude47``.
|
||||
"""
|
||||
global _REVERSE_ALIAS_CACHE
|
||||
if not model_name:
|
||||
return model_name
|
||||
if _REVERSE_ALIAS_CACHE is None:
|
||||
rmap: dict[str, str] = {}
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config() or {}
|
||||
ma = cfg.get("model_aliases")
|
||||
if isinstance(ma, dict):
|
||||
for alias, entry in ma.items():
|
||||
if isinstance(entry, dict):
|
||||
m = str(entry.get("model", "") or "").strip()
|
||||
if m and (m not in rmap or len(alias) < len(rmap[m])):
|
||||
rmap[m] = alias
|
||||
mdl = cfg.get("model", {}) or {}
|
||||
if isinstance(mdl, dict):
|
||||
simple = mdl.get("aliases")
|
||||
if isinstance(simple, dict):
|
||||
for alias, val in simple.items():
|
||||
if isinstance(val, str) and val.strip():
|
||||
v = val.strip()
|
||||
m = v.split("/", 1)[1] if "/" in v else v
|
||||
if m and (m not in rmap or len(alias) < len(rmap[m])):
|
||||
rmap[m] = alias
|
||||
except Exception:
|
||||
pass
|
||||
_REVERSE_ALIAS_CACHE = rmap
|
||||
return _REVERSE_ALIAS_CACHE.get(model_name, model_name)
|
||||
|
||||
|
||||
def format_token_count_compact(*args, **kwargs):
|
||||
value = int(args[0] if args else kwargs.get("value", 0))
|
||||
abs_value = abs(value)
|
||||
|
|
@ -4580,7 +4626,23 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
# _try_activate_fallback() switches provider/model.
|
||||
agent = getattr(self, "agent", None)
|
||||
model_name = (getattr(agent, "model", None) or self.model or "unknown")
|
||||
model_short = model_name.split("/")[-1] if "/" in model_name else model_name
|
||||
# Friendly display: prefer reverse-alias from config.yaml ``model_aliases:``
|
||||
# before slash/length truncation. This turns long Palantir RIDs like
|
||||
# ``ri.language-model-service..language-model.anthropic-claude-4-7-opus``
|
||||
# into the user's chosen short name (e.g. ``opus-4.7``) in the status bar.
|
||||
model_short = _reverse_alias_for_display(model_name)
|
||||
if model_short == model_name:
|
||||
model_short = model_name.split("/")[-1] if "/" in model_name else model_name
|
||||
# Strip Palantir RID prefixes that survived the slash split:
|
||||
# ``ri.language-model-service..language-model.anthropic-claude-4-7-opus``
|
||||
# → ``claude-4-7-opus``. The double-dot is Palantir's RID separator.
|
||||
if model_short.startswith("ri.") and ".." in model_short:
|
||||
_tail = model_short.split("..", 1)[1]
|
||||
# Drop the leading namespace token (``language-model.``).
|
||||
if "." in _tail:
|
||||
_tail = _tail.split(".", 1)[1]
|
||||
if _tail:
|
||||
model_short = _tail
|
||||
if model_short.endswith(".gguf"):
|
||||
model_short = model_short[:-5]
|
||||
if len(model_short) > 26:
|
||||
|
|
|
|||
|
|
@ -2142,37 +2142,115 @@ def list_authenticated_providers(
|
|||
# and one "custom:openrouter" from section 4, both labelled identically.
|
||||
_section3_emitted_pairs: set = set()
|
||||
if user_providers and isinstance(user_providers, dict):
|
||||
# Group ``providers:`` entries by (api_url, key_env, api_mode) so that
|
||||
# multiple keyed providers pointing at the same endpoint with the
|
||||
# same credential and wire-protocol collapse into one picker row.
|
||||
# Mirrors section-4's grouping for ``custom_providers:`` lists.
|
||||
# Concrete case: a Palantir Foundry Anthropic-proxy with two
|
||||
# configured models (claude-4.6 + claude-4.7) — both share the same
|
||||
# api/key_env/api_mode and used to produce two near-duplicate rows
|
||||
# labelled "Palantir Claude 4.6 Opus" and "Palantir Claude 4.7 Opus";
|
||||
# now they appear as a single "Palantir Claude" row with both models
|
||||
# in the dropdown. Same-host entries with different ``key_env`` or
|
||||
# ``api_mode`` (e.g. an OpenAI-compat gpt-5.4 alongside the Anthropic
|
||||
# claude-4.7 on the same Palantir host) keep distinct rows since
|
||||
# the wire protocol differs.
|
||||
from collections import OrderedDict as _OD3
|
||||
|
||||
ep_groups: "_OD3[tuple, dict]" = _OD3()
|
||||
for ep_name, ep_cfg in user_providers.items():
|
||||
if not isinstance(ep_cfg, dict):
|
||||
continue
|
||||
# Skip if this slug was already emitted (e.g. canonical provider
|
||||
# with the same name) or will be picked up by section 4.
|
||||
if ep_name.lower() in seen_slugs:
|
||||
continue
|
||||
display_name = ep_cfg.get("name", "") or ep_name
|
||||
# ``base_url`` is Hermes's canonical write key (matches
|
||||
# custom_providers and _save_custom_provider); ``api`` / ``url``
|
||||
# remain as fallbacks for hand-edited / legacy configs.
|
||||
api_url = (
|
||||
ep_cfg.get("base_url", "")
|
||||
or ep_cfg.get("api", "")
|
||||
or ep_cfg.get("url", "")
|
||||
or ""
|
||||
)
|
||||
key_env = str(ep_cfg.get("key_env", "") or "").strip()
|
||||
inline_api_key = str(ep_cfg.get("api_key", "") or "").strip()
|
||||
api_mode = str(
|
||||
ep_cfg.get("api_mode")
|
||||
or ep_cfg.get("transport")
|
||||
or ""
|
||||
).strip().lower()
|
||||
credential_identity = (
|
||||
inline_api_key
|
||||
if inline_api_key
|
||||
else (f"env:{key_env}" if key_env else "")
|
||||
)
|
||||
api_url_norm = str(api_url).strip().rstrip("/").lower()
|
||||
# Per-provider extra_headers participate in the group identity
|
||||
# (same invariant as section 4): two entries sharing
|
||||
# (api_url, credential, api_mode) but declaring different headers
|
||||
# are distinct endpoints (e.g. different tenants behind one proxy
|
||||
# URL, routed by header) and must keep distinct picker rows.
|
||||
entry_extra_headers = _extra_headers_from_config(ep_cfg)
|
||||
headers_identity = tuple(sorted(entry_extra_headers.items()))
|
||||
group_key = (api_url_norm, credential_identity, api_mode, headers_identity)
|
||||
|
||||
# ``default_model`` is the legacy key; ``model`` matches what
|
||||
# custom_providers entries use, so accept either.
|
||||
default_model = ep_cfg.get("default_model", "") or ep_cfg.get("model", "")
|
||||
|
||||
# Build models list from both default_model and full models array
|
||||
models_list = []
|
||||
if default_model:
|
||||
models_list.append(default_model)
|
||||
# Also include the full models list from config.
|
||||
# Build models list from both default_model and full models array.
|
||||
# Hermes writes ``models:`` as a dict keyed by model id, but older
|
||||
# or hand-edited configs may use strings or ``[{id: ...}]`` rows.
|
||||
# or hand-edited configs may use strings or ``[{id: ...}]`` rows —
|
||||
# _declared_model_ids() owns that contract.
|
||||
entry_models: list = []
|
||||
if default_model:
|
||||
entry_models.append(default_model)
|
||||
for model_id in _declared_model_ids(ep_cfg.get("models", [])):
|
||||
if model_id not in models_list:
|
||||
models_list.append(model_id)
|
||||
if model_id not in entry_models:
|
||||
entry_models.append(model_id)
|
||||
|
||||
if group_key not in ep_groups:
|
||||
# Strip per-model suffix so "Palantir Claude 4.7 Opus" becomes
|
||||
# "Palantir Claude". Em dash and " - " are the separators
|
||||
# Hermes's own writer uses (mirrors section-4 grouping).
|
||||
grp_display = display_name
|
||||
for sep in ("—", " - "):
|
||||
if sep in grp_display:
|
||||
grp_display = grp_display.split(sep)[0].strip()
|
||||
break
|
||||
# Drop trailing numeric/version tokens that distinguish per-model
|
||||
# entries ("Palantir Claude 4.7 Opus" → "Palantir Claude").
|
||||
# Keeps the row label short; the model dropdown carries the
|
||||
# per-version detail. Heuristic: split at the first token whose
|
||||
# stripped form contains a digit; keep the prefix only if it
|
||||
# is at least 2 words (avoids over-trimming single-word names).
|
||||
_toks = grp_display.split()
|
||||
_cut_at = None
|
||||
for _i, _t in enumerate(_toks):
|
||||
_tl = _t.strip(".,()")
|
||||
if _tl and any(c.isdigit() for c in _tl):
|
||||
_cut_at = _i
|
||||
break
|
||||
if _cut_at is not None and _cut_at >= 2:
|
||||
grp_display = " ".join(_toks[:_cut_at]).strip()
|
||||
grp_slug = ep_name # primary slug is the first ep_name encountered
|
||||
ep_groups[group_key] = {
|
||||
"slug": grp_slug,
|
||||
"name": grp_display or display_name,
|
||||
"api_url": api_url,
|
||||
"models": [],
|
||||
"ep_cfg": ep_cfg, # used below for discover_models / api_key
|
||||
"raw_names": [],
|
||||
}
|
||||
# Aggregate models across all members of the group (preserve order).
|
||||
for _m in entry_models:
|
||||
if _m and _m not in ep_groups[group_key]["models"]:
|
||||
ep_groups[group_key]["models"].append(_m)
|
||||
ep_groups[group_key]["raw_names"].append(display_name)
|
||||
|
||||
for grp in ep_groups.values():
|
||||
ep_cfg = grp["ep_cfg"]
|
||||
ep_name = grp["slug"]
|
||||
display_name = grp["name"]
|
||||
api_url = grp["api_url"]
|
||||
models_list = list(grp["models"])
|
||||
|
||||
# Official OpenAI API rows in providers: often have base_url but no
|
||||
# explicit models: dict — avoid a misleading zero count in /model.
|
||||
|
|
@ -2240,9 +2318,22 @@ def list_authenticated_providers(
|
|||
})
|
||||
seen_slugs.add(ep_name.lower())
|
||||
seen_slugs.add(custom_provider_slug(display_name).lower())
|
||||
# Record (display_name, api_url) for each raw entry that joined
|
||||
# this group so section-4's _section3_emitted_pairs dedup can
|
||||
# match per-model custom_providers rows ("Palantir Claude 4.7 Opus")
|
||||
# even though we collapsed the group label to "Palantir Claude".
|
||||
_url_norm_for_pair = str(api_url).strip().rstrip("/").lower()
|
||||
for _raw_name in grp.get("raw_names") or [display_name]:
|
||||
_pair = (
|
||||
str(_raw_name).strip().lower(),
|
||||
_url_norm_for_pair,
|
||||
)
|
||||
if _pair[0] and _pair[1]:
|
||||
_section3_emitted_pairs.add(_pair)
|
||||
seen_slugs.add(custom_provider_slug(_raw_name).lower())
|
||||
_pair = (
|
||||
str(display_name).strip().lower(),
|
||||
str(api_url).strip().rstrip("/").lower(),
|
||||
_url_norm_for_pair,
|
||||
)
|
||||
if _pair[0] and _pair[1]:
|
||||
_section3_emitted_pairs.add(_pair)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue