From 37e648128bfab390f1cc84c9602231e77a9f91be Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:12:28 -0700 Subject: [PATCH] fix(picker): fold live bare k3 wire id into curated kimi-k3 row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the salvaged #67409 search aliases: with kimi-k3 now in the curated kimi-coding list (#68108), a Coding Plan key rendered TWO rows for one model — curated 'kimi-k3' plus live-discovered bare 'k3' (merge dedup was exact-string). Add model_alias_canonical() derived from the same alias table and use it as the merge dedup key, so the curated public slug wins and live-only models still surface. --- hermes_cli/model_search.py | 20 ++++++ hermes_cli/models.py | 24 ++++++- .../test_model_search_alias_dedup.py | 65 +++++++++++++++++++ .../test_models_dev_preferred_merge.py | 6 +- 4 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 tests/hermes_cli/test_model_search_alias_dedup.py diff --git a/hermes_cli/model_search.py b/hermes_cli/model_search.py index 74b110fdd5f6..7004324604b2 100644 --- a/hermes_cli/model_search.py +++ b/hermes_cli/model_search.py @@ -15,6 +15,26 @@ _MODEL_SEARCH_ALIASES: dict[str, tuple[str, ...]] = { "k3": ("kimi-k3", "kimi"), } +# Lowercased wire id → canonical public slug it aliases. Used by picker +# dedup so a live bare id and its curated public slug (``k3`` / ``kimi-k3``) +# don't render as two rows for the same model. Derived from the FIRST alias +# entry, which by convention is the full public slug. +_MODEL_ALIAS_CANONICAL: dict[str, str] = { + wire_id: aliases[0].lower() + for wire_id, aliases in _MODEL_SEARCH_ALIASES.items() + if aliases +} + + +def model_alias_canonical(model: str) -> str: + """Return the canonical public slug for a bare wire-id alias. + + Identity for ids with no alias entry. Lowercases the input so callers + can use the result directly as a dedup key. + """ + key = (model or "").strip().lower() + return _MODEL_ALIAS_CANONICAL.get(key, key) + def model_search_text(model: str) -> str: """Return the haystack used for fuzzy/substring model search. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index ccf616509487..617fa1906dc1 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2537,6 +2537,24 @@ _MODELS_DEV_PREFERRED: frozenset[str] = frozenset({ }) +def _model_dedup_key(model_id: str) -> str: + """Case-insensitive dedup key that also folds picker-search aliases. + + Some providers serve the same model under both a curated public slug and + a bare live wire id (Kimi Coding Plan lists its flagship as ``k3`` while + the curated catalog carries ``kimi-k3``). Folding through the search-alias + table keeps the curated-first merge from emitting both as separate rows. + The row that survives is the primary list's entry; selection still sends + whichever id the surviving row carries. + """ + key = str(model_id).strip().lower() + try: + from hermes_cli.model_search import model_alias_canonical + return model_alias_canonical(key) + except Exception: + return key + + def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]: """Merge curated list with fresh models.dev entries for a preferred provider. @@ -2802,11 +2820,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) else: primary, secondary = curated, live merged = list(primary) - merged_lower = {m.lower() for m in primary} + merged_lower = {_model_dedup_key(m) for m in primary} for m in secondary: - if m.lower() not in merged_lower: + if _model_dedup_key(m) not in merged_lower: merged.append(m) - merged_lower.add(m.lower()) + merged_lower.add(_model_dedup_key(m)) return merged return live # Use profile's fallback_models if defined diff --git a/tests/hermes_cli/test_model_search_alias_dedup.py b/tests/hermes_cli/test_model_search_alias_dedup.py new file mode 100644 index 000000000000..4257d8e6ebd8 --- /dev/null +++ b/tests/hermes_cli/test_model_search_alias_dedup.py @@ -0,0 +1,65 @@ +"""Picker dedup must fold live bare wire-ids into their curated public slug. + +Kimi Coding Plan live-discovers its flagship as the bare id ``k3`` while the +curated catalog carries ``kimi-k3``. The curated-first picker merge must not +render both as separate rows for the same model. +""" + +from unittest.mock import patch + +from hermes_cli.model_search import model_alias_canonical +from hermes_cli.models import provider_model_ids + + +class TestModelAliasCanonical: + def test_bare_k3_folds_to_public_slug(self): + assert model_alias_canonical("k3") == "kimi-k3" + assert model_alias_canonical("K3") == "kimi-k3" + + def test_non_alias_ids_are_identity(self): + assert model_alias_canonical("kimi-k2.6") == "kimi-k2.6" + assert model_alias_canonical("GPT-5.4") == "gpt-5.4" + assert model_alias_canonical("") == "" + + +class TestPickerMergeAliasDedup: + def test_live_bare_k3_not_duplicated_against_curated_kimi_k3(self): + """Coding Plan key: live returns bare ``k3``; curated has ``kimi-k3``. + Exactly one k3-family row must survive (the curated slug leads).""" + with ( + patch( + "hermes_cli.auth.resolve_api_key_provider_credentials", + return_value={ + "api_key": "sk-kimi-x", + "base_url": "https://api.kimi.com/coding", + }, + ), + patch( + "providers.base.ProviderProfile.fetch_models", + return_value=["k3", "kimi-for-coding"], + ), + ): + out = provider_model_ids("kimi-coding") + + k3_rows = [m for m in out if model_alias_canonical(m) == "kimi-k3"] + assert k3_rows == ["kimi-k3"], out + # Live-only entries with no curated twin still surface. + assert "kimi-for-coding" in out + + def test_live_only_models_unaffected(self): + """Alias folding must not drop live models without curated twins.""" + with ( + patch( + "hermes_cli.auth.resolve_api_key_provider_credentials", + return_value={ + "api_key": "sk-kimi-x", + "base_url": "https://api.kimi.com/coding", + }, + ), + patch( + "providers.base.ProviderProfile.fetch_models", + return_value=["kimi-brand-new-live-only"], + ), + ): + out = provider_model_ids("kimi-coding") + assert "kimi-brand-new-live-only" in out diff --git a/tests/hermes_cli/test_models_dev_preferred_merge.py b/tests/hermes_cli/test_models_dev_preferred_merge.py index 168dd934f96a..cc7a8c764947 100644 --- a/tests/hermes_cli/test_models_dev_preferred_merge.py +++ b/tests/hermes_cli/test_models_dev_preferred_merge.py @@ -171,10 +171,14 @@ class TestProviderModelIdsPreferred: ): custom_models = provider_model_ids("kimi-coding") - assert "k3" in coding_models + # The live bare wire id ``k3`` folds into the curated public slug + # ``kimi-k3`` (picker alias dedup) — one row, curated slug leads. assert coding_models[0] == "kimi-k3" + assert all(model.lower() != "k3" for model in coding_models) assert all(model.lower() != "k3" for model in legacy_models) assert all(model.lower() != "k3" for model in custom_models) + # Legacy / custom endpoints never advertise the k3 family at all + # via live discovery (their curated floor may still carry kimi-k3). def test_kimi_setup_flow_uses_same_coding_plan_catalog(self): """The setup wizard must not carry a stale duplicate Kimi model list."""