Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7102e4c77 | ||
|
|
78624c3a5a | ||
|
|
ae999fa827 |
@@ -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)
|
||||
|
||||
|
||||
@@ -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(' ')}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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(' ')}`
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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(" ")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user