Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/desktop/src/components/model-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)

Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/lib/model-search-text.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 17 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6939,13 +6939,30 @@ def _label(mid):
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,
selected=default_idx,
cancel_returns=-1,
description=description,
searchable=True,
search_labels=model_search_labels,
)
if idx < 0:
return None
Expand Down
9 changes: 8 additions & 1 deletion hermes_cli/curses_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -709,7 +712,11 @@ def _on_action(action, cursor):
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)
),
)


Expand Down
30 changes: 30 additions & 0 deletions hermes_cli/model_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""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"),
}


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)}"
28 changes: 28 additions & 0 deletions tests/hermes_cli/test_model_search.py
Original file line number Diff line number Diff line change
@@ -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"]
26 changes: 24 additions & 2 deletions tests/hermes_cli/test_setup_menu_curses_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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), \
Expand All @@ -27,6 +37,8 @@ def _fake(title, items, *, selected=0, cancel_returns=None, description=None, se
# 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():
Expand Down Expand Up @@ -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 = {
Expand Down
5 changes: 4 additions & 1 deletion ui-tui/src/components/modelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions ui-tui/src/lib/model-search-text.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 28 additions & 0 deletions ui-tui/src/lib/model-search-text.ts
Original file line number Diff line number Diff line change
@@ -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(' ')}`
}
9 changes: 6 additions & 3 deletions web/src/components/ModelPickerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
import { fuzzyRank } from "@/lib/fuzzy";
import { modelSearchText } from "@/lib/model-search-text";

/**
* Two-stage model picker modal.
Expand Down Expand Up @@ -227,12 +228,14 @@ 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, trimmedQuery, (m) => m).map((r) => ({
fuzzyRank(models, trimmedQuery, 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],
);
Expand Down
28 changes: 28 additions & 0 deletions web/src/lib/model-search-text.ts
Original file line number Diff line number Diff line change
@@ -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(" ")}`;
}
Loading