Compare commits

...
Author SHA1 Message Date
Teknium c7102e4c77 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.
2026-07-20 10:12:47 -07:00
HexLab98 78624c3a5a test(models): cover kimi search alias for Kimi Coding k3
Assert the picker haystack keeps ordinary ids unchanged, surfaces wire
id k3 for "kimi"/"k3" queries, and accept search_labels in curses mocks.
2026-07-20 10:12:47 -07:00
HexLab98 ae999fa827 fix(models): match bare Kimi Coding k3 when searching kimi
Kimi Coding discovers the flagship as wire id `k3`. Picker search used
only that id, so typing "kimi" hid it next to every other kimi-* model.
Add picker-only search aliases without changing the wire id.
2026-07-20 10:12:47 -07:00
15 changed files with 354 additions and 12 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { useI18n } from '@/i18n'
import { requestModelOptions } from '@/lib/model-options'
import { currentPickerSelection } from '@/lib/model-status-label'
import { modelSearchText } from '@/lib/model-search-text'
import { normalize } from '@/lib/text'
import type { ModelOptionProvider, ModelPricing } from '@/types/hermes'
@@ -171,7 +172,7 @@ function ModelResults({
const matches = (provider: ModelOptionProvider, model: string) =>
!q ||
model.toLowerCase().includes(q) ||
modelSearchText(model).toLowerCase().includes(q) ||
provider.name.toLowerCase().includes(q) ||
provider.slug.toLowerCase().includes(q)
+28
View File
@@ -0,0 +1,28 @@
/**
* Extra tokens used only for model-picker search ranking.
*
* Wire IDs stay unchanged — some providers report short or brand-less ids
* (Kimi Coding's flagship is literally `k3`) that users still search for by
* the familiar `kimi-…` naming of sibling models.
*
* Keep in sync with ui-tui/src/lib/model-search-text.ts,
* web/src/lib/model-search-text.ts, and hermes_cli/model_search.py.
*/
const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = {
k3: ['kimi-k3', 'kimi']
}
/** Haystack for fuzzy/substring model search; never changes the wire id. */
export function modelSearchText(model: string): string {
const id = model.trim()
if (!id) {
return model
}
const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()]
if (!aliases?.length) {
return id
}
return `${id} ${aliases.join(' ')}`
}
+17
View File
@@ -6939,6 +6939,22 @@ def _prompt_model_selection(
desc_lines.append(f" ── {unavailable_footer} ──")
description = "\n".join(desc_lines) if desc_lines else None
# Search haystacks keep pricing labels visible while adding aliases
# for brand-less wire ids (e.g. Kimi Coding `k3` ↔ query "kimi").
from hermes_cli.model_search import model_search_text
model_search_labels = []
for mid in ordered:
label = _label(mid)
haystack = model_search_text(mid)
# model_search_text always starts with the wire id; only append when
# aliases add tokens beyond the bare id already in the label.
model_search_labels.append(
label if haystack == mid else f"{label} {haystack}"
)
model_search_labels.append("Enter custom model name")
model_search_labels.append("Skip (keep current)")
idx = curses_radiolist(
"Select default model:",
choices,
@@ -6946,6 +6962,7 @@ def _prompt_model_selection(
cancel_returns=-1,
description=description,
searchable=True,
search_labels=model_search_labels,
)
if idx < 0:
return None
+8 -1
View File
@@ -627,6 +627,7 @@ def curses_radiolist(
cancel_returns: int | None = None,
description: str | None = None,
searchable: bool = False,
search_labels: List[str] | None = None,
) -> int:
"""Curses single-select radio list. Returns the selected index.
@@ -641,6 +642,8 @@ def curses_radiolist(
searchable: When true, ``/`` opens a type-to-filter prompt. The
returned value is always the original item index, not a filtered
row position.
search_labels: Optional haystacks for type-to-filter (length must
match ``items``). Defaults to the display labels when omitted.
"""
if cancel_returns is None:
cancel_returns = selected
@@ -709,7 +712,11 @@ def curses_radiolist(
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
cancel_value=cancel_returns,
searchable=searchable,
search_labels=list(items) if searchable else None,
search_labels=(
list(search_labels)
if searchable and search_labels is not None
else (list(items) if searchable else None)
),
)
+50
View File
@@ -0,0 +1,50 @@
"""Picker-only search aliases for model ids.
Wire IDs stay unchanged. Some providers report short or brand-less ids
(Kimi Coding's flagship is literally ``k3``) that users still search for by
the familiar ``kimi-`` naming of sibling models.
Keep in sync with ``ui-tui/src/lib/model-search-text.ts`` and
``web/src/lib/model-search-text.ts``.
"""
from __future__ import annotations
# Lowercased wire id → extra tokens appended to the search haystack only.
_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.
Never changes the wire id passed to the provider.
"""
mid = (model or "").strip()
if not mid:
return model or ""
aliases = _MODEL_SEARCH_ALIASES.get(mid.lower())
if not aliases:
return mid
return f"{mid} {' '.join(aliases)}"
+21 -3
View File
@@ -2385,6 +2385,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.
@@ -2650,11 +2668,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
+28
View File
@@ -0,0 +1,28 @@
"""Picker search aliases for brand-less wire model ids."""
from hermes_cli.curses_ui import _filter_indices
from hermes_cli.model_search import model_search_text
def test_model_search_text_keeps_ordinary_ids():
assert model_search_text("kimi-k2.6") == "kimi-k2.6"
assert model_search_text("glm-5.2") == "glm-5.2"
def test_model_search_text_adds_kimi_aliases_for_k3():
assert model_search_text("k3") == "k3 kimi-k3 kimi"
assert model_search_text("K3") == "K3 kimi-k3 kimi"
def test_filter_indices_surfaces_k3_for_kimi_query():
models = ["kimi-k2.6", "kimi-k2.5", "k3", "kimi-for-coding"]
haystacks = [model_search_text(m) for m in models]
ranked = [models[i] for i in _filter_indices(haystacks, "kimi")]
assert "k3" in ranked
def test_filter_indices_still_finds_k3_by_wire_id():
models = ["kimi-k2.6", "k3", "kimi-for-coding"]
haystacks = [model_search_text(m) for m in models]
ranked = [models[i] for i in _filter_indices(haystacks, "k3")]
assert ranked == ["k3"]
@@ -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
@@ -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."""
@@ -13,9 +13,19 @@ def test_prompt_model_selection_uses_curses_radiolist():
seen = {}
def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False):
def _fake(
title,
items,
*,
selected=0,
cancel_returns=None,
description=None,
searchable=False,
search_labels=None,
):
seen["title"] = title
seen["items"] = items
seen["search_labels"] = search_labels
return 1 # pick second model
with patch("hermes_cli.curses_ui.curses_radiolist", side_effect=_fake), \
@@ -27,6 +37,8 @@ def test_prompt_model_selection_uses_curses_radiolist():
# Items are the models plus the custom/skip entries.
assert seen["items"][:2] == ["model-a", "model-b"]
assert "Skip (keep current)" in seen["items"]
assert seen["search_labels"] is not None
assert len(seen["search_labels"]) == len(seen["items"])
def test_prompt_model_selection_esc_cancels():
@@ -67,8 +79,18 @@ def test_model_selection_with_pricing_passes_description():
seen = {}
def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False):
def _fake(
title,
items,
*,
selected=0,
cancel_returns=None,
description=None,
searchable=False,
search_labels=None,
):
seen["description"] = description
seen["search_labels"] = search_labels
return len(items) - 1 # Skip
pricing = {
+4 -1
View File
@@ -6,6 +6,7 @@ import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'
import { fuzzyRank } from '../lib/fuzzy.js'
import { modelSearchText } from '../lib/model-search-text.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
@@ -132,7 +133,9 @@ export function ModelPicker({
return allModels
}
return fuzzyRank(allModels, filter, m => m).map(r => r.item)
// modelSearchText adds aliases for brand-less wire ids (e.g. Kimi
// Coding `k3` still matches a "kimi" query).
return fuzzyRank(allModels, filter, modelSearchText).map(r => r.item)
}, [allModels, filter, stage])
const models = filteredModels
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { fuzzyRank } from './fuzzy.js'
import { modelSearchText } from './model-search-text.js'
describe('modelSearchText', () => {
it('keeps ordinary model ids unchanged', () => {
expect(modelSearchText('kimi-k2.6')).toBe('kimi-k2.6')
expect(modelSearchText('glm-5.2')).toBe('glm-5.2')
})
it('adds kimi aliases for the bare Kimi Coding k3 wire id', () => {
expect(modelSearchText('k3')).toBe('k3 kimi-k3 kimi')
expect(modelSearchText('K3')).toBe('K3 kimi-k3 kimi')
})
})
describe('model picker search with aliases', () => {
const models = [
'kimi-k2.6',
'kimi-k2.5',
'k3',
'kimi-for-coding',
]
it('surfaces k3 when the user searches kimi', () => {
const ranked = fuzzyRank(models, 'kimi', modelSearchText).map(r => r.item)
expect(ranked).toContain('k3')
})
it('still finds k3 by its wire id', () => {
const ranked = fuzzyRank(models, 'k3', modelSearchText).map(r => r.item)
expect(ranked).toEqual(['k3'])
})
it('does not invent k3 for unrelated queries', () => {
const ranked = fuzzyRank(models, 'glm', modelSearchText).map(r => r.item)
expect(ranked).toEqual([])
})
})
+28
View File
@@ -0,0 +1,28 @@
/**
* Extra tokens used only for model-picker search ranking.
*
* Wire IDs stay unchanged some providers report short or brand-less ids
* (Kimi Coding's flagship is literally `k3`) that users still search for by
* the familiar `kimi-…` naming of sibling models.
*
* Keep in sync with web/src/lib/model-search-text.ts and
* hermes_cli/model_search.py.
*/
const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = {
k3: ['kimi-k3', 'kimi'],
}
/** Haystack for fuzzy/substring model search; never changes the wire id. */
export function modelSearchText(model: string): string {
const id = model.trim()
if (!id) {
return model
}
const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()]
if (!aliases?.length) {
return id
}
return `${id} ${aliases.join(' ')}`
}
+6 -3
View File
@@ -12,6 +12,7 @@ import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
import { fuzzyRank } from "@/lib/fuzzy";
import { queryMatchesProviderOnly } from "@/lib/model-picker-filter";
import { modelSearchText } from "@/lib/model-search-text";
/**
* Two-stage model picker modal.
@@ -239,16 +240,18 @@ export function ModelPickerDialog(props: Props) {
);
// Fuzzy-ranked models carrying the matched character positions so the model
// list can highlight why each entry matched.
// list can highlight why each entry matched. modelSearchText adds aliases
// for brand-less wire ids (e.g. Kimi Coding `k3` ↔ search "kimi").
const filteredModels = useMemo(
() =>
fuzzyRank(
models,
queryMatchesSelectedProviderOnly ? "" : trimmedQuery,
(m) => m,
modelSearchText,
).map((r) => ({
model: r.item,
positions: r.positions,
// Positions may land in alias suffixes — keep only in-id highlights.
positions: r.positions.filter((i) => i >= 0 && i < r.item.length),
})),
[models, trimmedQuery, queryMatchesSelectedProviderOnly],
);
+28
View File
@@ -0,0 +1,28 @@
/**
* Extra tokens used only for model-picker search ranking.
*
* Wire IDs stay unchanged some providers report short or brand-less ids
* (Kimi Coding's flagship is literally `k3`) that users still search for by
* the familiar `kimi-…` naming of sibling models.
*
* Keep in sync with ui-tui/src/lib/model-search-text.ts and
* hermes_cli/model_search.py. Behavioural tests live in the TUI package.
*/
const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = {
k3: ["kimi-k3", "kimi"],
};
/** Haystack for fuzzy/substring model search; never changes the wire id. */
export function modelSearchText(model: string): string {
const id = model.trim();
if (!id) {
return model;
}
const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()];
if (!aliases?.length) {
return id;
}
return `${id} ${aliases.join(" ")}`;
}