feat(model-picker): group multi-endpoint providers under one row (#35227)
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path). * feat(model-picker): group multi-endpoint providers under one row The interactive provider pickers (hermes model, setup wizard, Telegram /model) listed every provider slug flat, so vendors with several endpoints (Kimi/Moonshot, MiniMax, xAI Grok, Google Gemini, OpenAI, OpenCode, GitHub Copilot) each occupied multiple top-level rows. Now related slugs fold into one top-level row that drills down to the specific endpoint. - models.py: add PROVIDER_GROUPS table + group_providers() fold (display only — CANONICAL_PROVIDERS, slugs, --provider, /model <provider:model> all unchanged and individually addressable). - hermes model (main.py): group rows drill into a member sub-picker, then dispatch to the existing _model_flow_* unchanged. setup wizard inherits it. - Telegram /model: new mpg:<group> callback expands to member mp:<slug> buttons; single authenticated member degrades to a direct button. - Grouping is the single shared fold across all three surfaces. Validation: 163 targeted tests pass; E2E confirms group->member->model resolves to the correct concrete slug for all families.
This commit is contained in:
@ -2804,21 +2804,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return slug
|
||||
|
||||
try:
|
||||
# Build provider buttons — 2 per row
|
||||
buttons: list = []
|
||||
for p in providers:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
# Compact callback data: mp:<slug> (max 64 bytes)
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
)
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
keyboard = InlineKeyboardMarkup(rows)
|
||||
# Build provider buttons — folds provider groups (display only).
|
||||
keyboard = self._build_provider_keyboard(providers)
|
||||
|
||||
provider_label = get_label(current_provider)
|
||||
text = self.format_message(
|
||||
@ -2865,6 +2852,56 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
_MODEL_PAGE_SIZE = 8
|
||||
|
||||
def _build_provider_keyboard(self, providers: list):
|
||||
"""Build the top-level provider keyboard, folding provider groups.
|
||||
|
||||
Provider families (Kimi/Moonshot, MiniMax, xAI Grok, ...) collapse to
|
||||
a single ``mpg:<gid>`` button; tapping it drills into a member
|
||||
sub-keyboard. Single providers (and groups with only one authenticated
|
||||
member) render as direct ``mp:<slug>`` buttons. Grouping mirrors the
|
||||
CLI ``hermes model`` picker via the shared ``group_providers`` fold,
|
||||
so all surfaces stay consistent.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.models import group_providers
|
||||
except Exception:
|
||||
group_providers = None
|
||||
|
||||
by_slug = {p.get("slug"): p for p in providers}
|
||||
|
||||
def _provider_button(p):
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
return InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
|
||||
buttons: list = []
|
||||
if group_providers is not None:
|
||||
for row in group_providers([p.get("slug") for p in providers]):
|
||||
if row["kind"] == "group":
|
||||
members = [by_slug[m] for m in row["members"] if m in by_slug]
|
||||
count = sum(
|
||||
m.get("total_models", len(m.get("models", []))) for m in members
|
||||
)
|
||||
label = f"{row['label']} ▸ ({count})"
|
||||
if any(m.get("is_current") for m in members):
|
||||
label = f"✓ {label}"
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mpg:{row['group_id']}")
|
||||
)
|
||||
else:
|
||||
p = by_slug.get(row["slug"])
|
||||
if p is not None:
|
||||
buttons.append(_provider_button(p))
|
||||
else:
|
||||
for p in providers:
|
||||
buttons.append(_provider_button(p))
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
def _build_model_keyboard(self, models: list, page: int) -> tuple:
|
||||
"""Build paginated model buttons. Returns (keyboard, page_info_text)."""
|
||||
page_size = self._MODEL_PAGE_SIZE
|
||||
@ -3043,10 +3080,23 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Clean up state
|
||||
self._model_picker_state.pop(chat_id, None)
|
||||
|
||||
elif data == "mb":
|
||||
# --- Back to provider list ---
|
||||
elif data.startswith("mpg:"):
|
||||
# --- Provider group selected: show member providers ---
|
||||
group_id = data[4:]
|
||||
try:
|
||||
from hermes_cli.models import PROVIDER_GROUPS
|
||||
_label, member_slugs = PROVIDER_GROUPS.get(group_id, ("", []))
|
||||
except Exception:
|
||||
_label, member_slugs = "", []
|
||||
|
||||
by_slug = {p["slug"]: p for p in state["providers"]}
|
||||
members = [by_slug[m] for m in member_slugs if m in by_slug]
|
||||
if not members:
|
||||
await query.answer(text="Group not found.")
|
||||
return
|
||||
|
||||
buttons = []
|
||||
for p in state["providers"]:
|
||||
for p in members:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
@ -3054,11 +3104,30 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
)
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
rows.append([
|
||||
InlineKeyboardButton("◀ Back", callback_data="mb"),
|
||||
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
|
||||
])
|
||||
keyboard = InlineKeyboardMarkup(rows)
|
||||
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(
|
||||
(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Provider family: *{_label or group_id}*\n\n"
|
||||
f"Select a provider:"
|
||||
)
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await query.answer()
|
||||
|
||||
elif data == "mb":
|
||||
# --- Back to provider list (folds groups) ---
|
||||
keyboard = self._build_provider_keyboard(state["providers"])
|
||||
|
||||
try:
|
||||
provider_label = get_label(state["current_provider"])
|
||||
except Exception:
|
||||
@ -3107,7 +3176,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
query_user_name = getattr(query.from_user, "first_name", None)
|
||||
|
||||
# --- Model picker callbacks ---
|
||||
if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")):
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
|
||||
chat_id = str(query.message.chat_id) if query.message else None
|
||||
if chat_id:
|
||||
await self._handle_model_picker_callback(query, data, chat_id)
|
||||
|
||||
@ -2394,7 +2394,12 @@ def select_provider_and_model(args=None):
|
||||
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
||||
active = "custom"
|
||||
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
|
||||
from hermes_cli.models import (
|
||||
CANONICAL_PROVIDERS,
|
||||
_PROVIDER_LABELS,
|
||||
group_providers,
|
||||
provider_group_for_slug,
|
||||
)
|
||||
|
||||
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
|
||||
if active and active in _custom_provider_map:
|
||||
@ -2407,8 +2412,43 @@ def select_provider_and_model(args=None):
|
||||
print(f" Active provider: {active_label}")
|
||||
print()
|
||||
|
||||
# Step 1: Provider selection — flat list from CANONICAL_PROVIDERS
|
||||
all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS]
|
||||
# Step 1: Provider selection.
|
||||
#
|
||||
# Canonical providers are folded into top-level groups (display only — see
|
||||
# PROVIDER_GROUPS in hermes_cli/models.py). A multi-member group shows one
|
||||
# row ("Kimi / Moonshot ▸"); picking it opens a member sub-picker that
|
||||
# resolves back to a concrete slug, so the dispatch chain below is
|
||||
# unchanged. Custom providers and the trailing actions stay flat.
|
||||
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
|
||||
grouped_rows = group_providers([p.slug for p in CANONICAL_PROVIDERS])
|
||||
|
||||
# The group/slug that should be pre-selected: the active provider's group
|
||||
# if it's grouped, otherwise the active slug itself.
|
||||
active_group = provider_group_for_slug(active) if active else ""
|
||||
|
||||
# ordered entries: (key, label, members)
|
||||
# members == [] → leaf row, key is a provider slug / action
|
||||
# members != [] → group row, key is "group:<gid>"
|
||||
ordered: list[tuple[str, str, list[str]]] = []
|
||||
default_idx = 0
|
||||
for row in grouped_rows:
|
||||
if row["kind"] == "group":
|
||||
gid = row["group_id"]
|
||||
label = f"{row['label']} ▸"
|
||||
key = f"group:{gid}"
|
||||
is_active = bool(active_group) and gid == active_group
|
||||
members = row["members"]
|
||||
else:
|
||||
slug = row["slug"]
|
||||
label = canonical_descs.get(slug, provider_labels.get(slug, slug))
|
||||
key = slug
|
||||
is_active = bool(active) and slug == active
|
||||
members = []
|
||||
if is_active:
|
||||
ordered.append((key, f"{label} ← currently active", members))
|
||||
default_idx = len(ordered) - 1
|
||||
else:
|
||||
ordered.append((key, label, members))
|
||||
|
||||
for key, provider_info in _custom_provider_map.items():
|
||||
name = provider_info["name"]
|
||||
@ -2416,36 +2456,49 @@ def select_provider_and_model(args=None):
|
||||
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
saved_model = provider_info.get("model", "")
|
||||
model_hint = f" — {saved_model}" if saved_model else ""
|
||||
all_providers.append((key, f"{name} ({short_url}){model_hint}"))
|
||||
|
||||
# Build the menu
|
||||
ordered = []
|
||||
default_idx = 0
|
||||
for key, label in all_providers:
|
||||
label = f"{name} ({short_url}){model_hint}"
|
||||
if active and key == active:
|
||||
ordered.append((key, f"{label} ← currently active"))
|
||||
ordered.append((key, f"{label} ← currently active", []))
|
||||
default_idx = len(ordered) - 1
|
||||
else:
|
||||
ordered.append((key, label))
|
||||
ordered.append((key, label, []))
|
||||
|
||||
ordered.append(("custom", "Custom endpoint (enter URL manually)"))
|
||||
ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
|
||||
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(
|
||||
config.get("custom_providers")
|
||||
)
|
||||
if _has_saved_custom_list:
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider"))
|
||||
ordered.append(("aux-config", "Configure auxiliary models..."))
|
||||
ordered.append(("cancel", "Leave unchanged"))
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider", []))
|
||||
ordered.append(("aux-config", "Configure auxiliary models...", []))
|
||||
ordered.append(("cancel", "Leave unchanged", []))
|
||||
|
||||
provider_idx = _prompt_provider_choice(
|
||||
[label for _, label in ordered],
|
||||
[label for _, label, _ in ordered],
|
||||
default=default_idx,
|
||||
)
|
||||
if provider_idx is None or ordered[provider_idx][0] == "cancel":
|
||||
print("No change.")
|
||||
return
|
||||
|
||||
selected_provider = ordered[provider_idx][0]
|
||||
selected_key = ordered[provider_idx][0]
|
||||
selected_members = ordered[provider_idx][2]
|
||||
|
||||
# Group row → drill into a member sub-picker. Default to the active member
|
||||
# if the active provider lives in this group.
|
||||
if selected_members:
|
||||
member_default = 0
|
||||
if active in selected_members:
|
||||
member_default = selected_members.index(active)
|
||||
member_labels = [
|
||||
canonical_descs.get(m, provider_labels.get(m, m)) for m in selected_members
|
||||
]
|
||||
member_idx = _prompt_provider_choice(member_labels, default=member_default)
|
||||
if member_idx is None:
|
||||
print("No change.")
|
||||
return
|
||||
selected_provider = selected_members[member_idx]
|
||||
else:
|
||||
selected_provider = selected_key
|
||||
|
||||
if selected_provider == "aux-config":
|
||||
_aux_config_menu()
|
||||
|
||||
@ -936,6 +936,105 @@ _PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
|
||||
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider groups — DISPLAY ONLY
|
||||
#
|
||||
# Some vendors expose several Hermes provider slugs (one per endpoint /
|
||||
# auth method: global API, China API, OAuth coding plan, ...). Listing every
|
||||
# slug as a top-level row in the interactive `hermes model` / setup wizard /
|
||||
# Telegram `/model` pickers makes that list long and noisy.
|
||||
#
|
||||
# These groups fold related slugs under one top-level row in INTERACTIVE
|
||||
# PICKERS only. They do NOT change ``CANONICAL_PROVIDERS``, slug identity,
|
||||
# the ``--provider`` flag, ``/model <provider:model>``, or any typed path —
|
||||
# every member slug remains individually addressable. Grouping is a pure
|
||||
# display affordance; ``group_providers()`` is the single fold used by all
|
||||
# three picker surfaces so they stay consistent.
|
||||
#
|
||||
# group_id -> (display_label, [member_slug, ...])
|
||||
#
|
||||
# Member order is the order shown inside the group submenu.
|
||||
# ---------------------------------------------------------------------------
|
||||
PROVIDER_GROUPS: dict[str, tuple[str, list[str]]] = {
|
||||
"kimi": ("Kimi / Moonshot", ["kimi-coding", "kimi-coding-cn"]),
|
||||
"minimax": ("MiniMax", ["minimax", "minimax-oauth", "minimax-cn"]),
|
||||
"xai": ("xAI Grok", ["xai", "xai-oauth"]),
|
||||
"google": ("Google Gemini", ["gemini", "google-gemini-cli"]),
|
||||
"openai": ("OpenAI", ["openai-codex", "openai-api"]),
|
||||
"opencode": ("OpenCode", ["opencode-zen", "opencode-go"]),
|
||||
"copilot": ("GitHub Copilot", ["copilot", "copilot-acp"]),
|
||||
}
|
||||
|
||||
# Reverse index: member slug -> group_id. Built once at import.
|
||||
_SLUG_TO_GROUP: dict[str, str] = {
|
||||
slug: gid for gid, (_label, members) in PROVIDER_GROUPS.items() for slug in members
|
||||
}
|
||||
|
||||
|
||||
def provider_group_for_slug(slug: str) -> str:
|
||||
"""Return the group_id a provider slug belongs to, or "" if ungrouped."""
|
||||
return _SLUG_TO_GROUP.get(str(slug or "").strip().lower(), "")
|
||||
|
||||
|
||||
def group_providers(slugs):
|
||||
"""Fold a flat ordered slug iterable into picker rows by provider group.
|
||||
|
||||
DISPLAY ONLY. Used by every interactive picker (``hermes model``, the
|
||||
setup wizard, the Telegram ``/model`` keyboard) so grouping is identical
|
||||
across surfaces.
|
||||
|
||||
Each returned row is a dict::
|
||||
|
||||
{"kind": "single", "slug": <slug>} # ungrouped, or
|
||||
# 1-member group
|
||||
{"kind": "group", "group_id": <gid>, "label": <label>,
|
||||
"members": [<slug>, ...]} # 2+ members
|
||||
|
||||
Rules:
|
||||
* A group row appears at the position of its FIRST present member, in
|
||||
the input order. Subsequent members fold into that row (and are not
|
||||
emitted again).
|
||||
* Member order inside a group follows ``PROVIDER_GROUPS`` declaration,
|
||||
restricted to the members actually present in ``slugs``.
|
||||
* A group reduced to a single present member degrades to a ``single``
|
||||
row — no pointless one-item submenu.
|
||||
* Slugs not in any group pass through as ``single`` rows, order
|
||||
preserved.
|
||||
* Duplicate slugs in the input are ignored after first sight.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
# Which present members each group has, in declaration order.
|
||||
group_members: dict[str, list[str]] = {}
|
||||
for gid, (_label, members) in PROVIDER_GROUPS.items():
|
||||
present = [m for m in members if m in set(slugs)]
|
||||
if present:
|
||||
group_members[gid] = present
|
||||
|
||||
rows = []
|
||||
emitted_groups: set[str] = set()
|
||||
for slug in slugs:
|
||||
s = str(slug or "").strip().lower()
|
||||
if not s or s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
gid = _SLUG_TO_GROUP.get(s, "")
|
||||
if not gid:
|
||||
rows.append({"kind": "single", "slug": s})
|
||||
continue
|
||||
if gid in emitted_groups:
|
||||
continue # already folded at the first member's position
|
||||
emitted_groups.add(gid)
|
||||
members = group_members.get(gid, [s])
|
||||
if len(members) <= 1:
|
||||
rows.append({"kind": "single", "slug": members[0]})
|
||||
else:
|
||||
label, _ = PROVIDER_GROUPS[gid]
|
||||
rows.append(
|
||||
{"kind": "group", "group_id": gid, "label": label, "members": list(members)}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
_PROVIDER_ALIASES = {
|
||||
"glm": "zai",
|
||||
"z-ai": "zai",
|
||||
|
||||
@ -146,6 +146,78 @@ class TestTelegramModelPicker:
|
||||
# State is cleaned up after a successful switch.
|
||||
assert "12345" not in adapter._model_picker_state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_group_folds_and_drills_down(self, monkeypatch):
|
||||
"""A provider family (e.g. MiniMax) collapses to one mpg: button at
|
||||
the top level; tapping it expands to its authenticated members as
|
||||
mp: buttons. A group reduced to a single authenticated member shows
|
||||
no submenu (direct mp: button).
|
||||
|
||||
Inspects callback_data by recording every InlineKeyboardButton built,
|
||||
which is robust to whether `telegram` is the real SDK or the module
|
||||
mock (the SDK markup objects don't expose a plain iterable under the
|
||||
mock)."""
|
||||
import gateway.platforms.telegram as tg
|
||||
|
||||
built: list = []
|
||||
|
||||
class _RecordingButton:
|
||||
def __init__(self, text, callback_data=None, **kw):
|
||||
self.text = text
|
||||
self.callback_data = callback_data
|
||||
built.append(callback_data)
|
||||
|
||||
class _RecordingMarkup:
|
||||
def __init__(self, rows):
|
||||
self.inline_keyboard = rows
|
||||
|
||||
monkeypatch.setattr(tg, "InlineKeyboardButton", _RecordingButton)
|
||||
monkeypatch.setattr(tg, "InlineKeyboardMarkup", _RecordingMarkup)
|
||||
|
||||
adapter = _make_adapter()
|
||||
|
||||
async def mock_send_message(**kwargs):
|
||||
return SimpleNamespace(message_id=101)
|
||||
|
||||
adapter._bot.send_message = AsyncMock(side_effect=mock_send_message)
|
||||
|
||||
providers = [
|
||||
{"slug": "minimax", "name": "MiniMax", "total_models": 2},
|
||||
{"slug": "minimax-cn", "name": "MiniMax (China)", "total_models": 3},
|
||||
{"slug": "xai", "name": "xAI", "total_models": 1}, # lone group member
|
||||
]
|
||||
|
||||
await adapter.send_model_picker(
|
||||
chat_id="12345",
|
||||
providers=providers,
|
||||
current_model="m",
|
||||
current_provider="minimax",
|
||||
session_key="s",
|
||||
on_model_selected=AsyncMock(),
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
# Top-level keyboard: MiniMax family folded into one group button;
|
||||
# xai (lone member) degraded to a direct provider button.
|
||||
assert "mpg:minimax" in built
|
||||
assert "mp:xai" in built
|
||||
assert "mp:minimax" not in built
|
||||
assert "mp:minimax-cn" not in built
|
||||
|
||||
# Drill into the MiniMax group → members appear as mp: buttons + back.
|
||||
built.clear()
|
||||
query = AsyncMock()
|
||||
query.message = MagicMock()
|
||||
query.message.chat_id = 12345
|
||||
query.answer = AsyncMock()
|
||||
query.edit_message_text = AsyncMock()
|
||||
|
||||
await adapter._handle_model_picker_callback(query, "mpg:minimax", "12345")
|
||||
|
||||
assert "mp:minimax" in built
|
||||
assert "mp:minimax-cn" in built
|
||||
assert "mb" in built # back-to-providers button present
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_without_thread_when_thread_not_found(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
118
tests/hermes_cli/test_provider_groups.py
Normal file
118
tests/hermes_cli/test_provider_groups.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""Tests for provider-group folding (display-only picker grouping).
|
||||
|
||||
These are invariant tests, not catalog snapshots: they assert how
|
||||
``group_providers`` folds a flat slug list and how member slugs relate to
|
||||
``PROVIDER_GROUPS`` / ``CANONICAL_PROVIDERS`` — not the specific set of
|
||||
vendors, which is expected to change over time.
|
||||
"""
|
||||
|
||||
from hermes_cli.models import (
|
||||
CANONICAL_PROVIDERS,
|
||||
PROVIDER_GROUPS,
|
||||
group_providers,
|
||||
provider_group_for_slug,
|
||||
)
|
||||
|
||||
|
||||
def _slugs(rows):
|
||||
"""Flatten picker rows back to the concrete slugs they expose."""
|
||||
out = []
|
||||
for r in rows:
|
||||
if r["kind"] == "single":
|
||||
out.append(r["slug"])
|
||||
else:
|
||||
out.extend(r["members"])
|
||||
return out
|
||||
|
||||
|
||||
def test_groups_reference_real_canonical_slugs():
|
||||
"""Every group member must be an actual provider slug. Guards typos and
|
||||
stale group entries after a provider is renamed/removed."""
|
||||
canonical = {p.slug for p in CANONICAL_PROVIDERS}
|
||||
for gid, (label, members) in PROVIDER_GROUPS.items():
|
||||
assert label, f"group {gid} has empty label"
|
||||
assert len(members) >= 1
|
||||
for m in members:
|
||||
assert m in canonical, f"group {gid} member {m!r} is not a canonical slug"
|
||||
|
||||
|
||||
def test_member_slugs_are_unique_across_groups():
|
||||
"""A slug may belong to at most one group."""
|
||||
seen = {}
|
||||
for gid, (_label, members) in PROVIDER_GROUPS.items():
|
||||
for m in members:
|
||||
assert m not in seen, f"{m!r} in both {seen[m]!r} and {gid!r}"
|
||||
seen[m] = gid
|
||||
|
||||
|
||||
def test_reverse_index_matches_groups():
|
||||
for gid, (_label, members) in PROVIDER_GROUPS.items():
|
||||
for m in members:
|
||||
assert provider_group_for_slug(m) == gid
|
||||
assert provider_group_for_slug("openrouter") == ""
|
||||
assert provider_group_for_slug("") == ""
|
||||
|
||||
|
||||
def test_ungrouped_providers_pass_through_in_order():
|
||||
rows = group_providers(["nous", "openrouter", "deepseek"])
|
||||
assert all(r["kind"] == "single" for r in rows)
|
||||
assert [r["slug"] for r in rows] == ["nous", "openrouter", "deepseek"]
|
||||
|
||||
|
||||
def test_multi_member_group_folds_to_one_row():
|
||||
rows = group_providers(["minimax", "minimax-oauth", "minimax-cn"])
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["kind"] == "group"
|
||||
assert row["group_id"] == "minimax"
|
||||
assert row["members"] == ["minimax", "minimax-oauth", "minimax-cn"]
|
||||
|
||||
|
||||
def test_group_appears_at_first_member_position():
|
||||
"""The group row takes the slot of its earliest-listed present member,
|
||||
and later members do not re-emit."""
|
||||
rows = group_providers(["nous", "minimax", "deepseek", "minimax-cn"])
|
||||
kinds = [(r["kind"], r.get("group_id") or r.get("slug")) for r in rows]
|
||||
assert kinds == [
|
||||
("single", "nous"),
|
||||
("group", "minimax"),
|
||||
("single", "deepseek"),
|
||||
]
|
||||
# both minimax members folded into the single group row
|
||||
assert rows[1]["members"] == ["minimax", "minimax-cn"]
|
||||
|
||||
|
||||
def test_single_present_member_degrades_to_single_row():
|
||||
"""A group with only one present member shows no submenu."""
|
||||
rows = group_providers(["xai"]) # xai-oauth absent
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["kind"] == "single"
|
||||
assert rows[0]["slug"] == "xai"
|
||||
|
||||
|
||||
def test_member_order_follows_declaration_not_input():
|
||||
"""Inside a folded group, members are ordered by PROVIDER_GROUPS, not by
|
||||
the order they appeared in the input list."""
|
||||
rows = group_providers(["minimax-cn", "minimax", "minimax-oauth"])
|
||||
assert rows[0]["members"] == ["minimax", "minimax-oauth", "minimax-cn"]
|
||||
|
||||
|
||||
def test_duplicate_slugs_ignored():
|
||||
rows = group_providers(["nous", "nous", "minimax", "minimax"])
|
||||
assert [r.get("slug") or r["group_id"] for r in rows] == ["nous", "minimax"]
|
||||
|
||||
|
||||
def test_fold_is_lossless_for_present_slugs():
|
||||
"""Every input slug (deduped) must still be reachable through the folded
|
||||
rows — grouping hides nothing."""
|
||||
flat = [p.slug for p in CANONICAL_PROVIDERS]
|
||||
rows = group_providers(flat)
|
||||
assert set(_slugs(rows)) == set(flat)
|
||||
|
||||
|
||||
def test_canonical_fold_row_count_shrinks():
|
||||
"""Folding the full canonical list produces fewer top-level rows than the
|
||||
flat list (proves grouping actually consolidates)."""
|
||||
flat = [p.slug for p in CANONICAL_PROVIDERS]
|
||||
rows = group_providers(flat)
|
||||
assert len(rows) < len(flat)
|
||||
Reference in New Issue
Block a user