fix(picker): fold live bare k3 wire id into curated kimi-k3 row

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.
This commit is contained in:
Teknium 2026-07-20 10:12:28 -07:00
parent 39902f1888
commit 37e648128b
4 changed files with 111 additions and 4 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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."""