Duplicate model picker rows for Kimi provider
NousResearch/hermes-agent · cluster 10526 · hermes cli
Data as of
Nothing open on GitHub. Fix merged in #12306. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #12306. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple issues and fixes address duplicate or ambiguous Kimi entries appearing in the TUI/CLI model picker, caused by alias and canonical slugs mapping to the same provider identity.
Open issues0
Open PRs0
Fixes on record
-
#12306 fix(tui): disambiguate /model picker rows when provider display names collide
files:
ui-tui/src/__tests__/providers.test.ts,ui-tui/src/components/modelPicker.tsx,ui-tui/src/domain/providers.tsShow the diff
-
ui-tui/src/__tests__/providers.test.ts@@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { providerDisplayNames } from '../domain/providers.js' + +describe('providerDisplayNames', () => { + it('returns bare names when all are unique', () => { + expect(providerDisplayNames([{ name: 'Anthropic', slug: 'anthropic' }, { name: 'OpenAI', slug: 'openai' }])).toEqual( + ['Anthropic', 'OpenAI'] + ) + }) + + it('appends slug to every collision so the disambiguation is symmetric', () => { + expect( + providerDisplayNames([ + { name: 'Kimi For Coding', slug: 'kimi-coding' }, + { name: 'Kimi For Coding', slug: 'kimi-coding-cn' } + ]) + ).toEqual(['Kimi For Coding (kimi-coding)', 'Kimi For Coding (kimi-coding-cn)']) + }) + + it('only disambiguates the colliding group', () => { + expect( + providerDisplayNames([ + { name: 'Anthropic', slug: 'anthropic' }, + { name: 'Foo', slug: 'foo-a' }, + { name: 'Foo', slug: 'foo-b' } + ]) + ).toEqual(['Anthropic', 'Foo (foo-a)', 'Foo (foo-b)']) + }) + + it('falls back to plain name if slug is empty', () => { + expect( + providerDisplayNames([ + { name: 'Foo', slug: '' }, + { name: 'Foo', slug: '' } + ]) + ).toEqual(['Foo', 'Foo']) + }) + + it('skips disambiguation when slug equals name', () => { + expect( + providerDisplayNames([ + { name: 'foo', slug: 'foo' }, + { name: 'foo', slug: 'foo' } + ]) + ).toEqual(['foo', 'foo']) + }) + + it('handles empty input', () => { + expect(providerDisplayNames([])).toEqual([]) + }) + + it('preserves order', () => { + const input = [ + { name: 'Z', slug: 'z' }, + { name: 'A', slug: 'a1' }, + { name: 'A', slug: 'a2' } + ] + + expect(providerDisplayNames(input)).toEqual(['Z', 'A (a1)', 'A (a2)']) + }) +}) -
ui-tui/src/components/modelPicker.tsx@@ -1,6 +1,7 @@ import { Box, Text, useInput } from '@hermes/ink' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { providerDisplayNames } from '../domain/providers.js' import type { GatewayClient } from '../gatewayClient.js' import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' @@ -59,6 +60,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const provider = providers[providerIdx] const models = provider?.models ?? [] + const names = useMemo(() => providerDisplayNames(providers), [providers]) useInput((ch, key) => { if (key.escape) { @@ -160,7 +162,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke if (stage === 'provider') { const rows = providers.map( - p => `${p.is_current ? '*' : ' '} ${p.name} · ${p.total_models ?? p.models?.length ?? 0} models` + (p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models` ) const { items, off } = visibleItems(rows, providerIdx) @@ -201,7 +203,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke Select Model </Text> - <Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text> + <Text color={t.color.dim}>{names[providerIdx] || '(unknown provider)'}</Text> {!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null} {provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null} {off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>} -
ui-tui/src/domain/providers.ts@@ -0,0 +1,17 @@ +export const providerDisplayNames = (providers: readonly { name: string; slug: string }[]): string[] => { + const counts = new Map<string, number>() + + for (const p of providers) { + counts.set(p.name, (counts.get(p.name) ?? 0) + 1) + } + + return providers.map(p => { + const dup = (counts.get(p.name) ?? 0) > 1 + + if (!dup || !p.slug || p.slug === p.name) { + return p.name + } + + return `${p.name} (${p.slug})` + }) +}
-
-
#68141 fix(picker): dedupe Kimi alias/canonical rows + split distinct CN profile (salvage of #49455, #53100, #53392)
files:
contributors/emails/[redacted:email],hermes_cli/model_switch.py,hermes_cli/providers.py,tests/hermes_cli/test_kimi_cn_provider_listing.py,tests/hermes_cli/test_list_picker_providers.pyShow the diff
-
contributors/emails/[redacted:email]@@ -0,0 +1 @@ +Almurat123 -
hermes_cli/model_switch.py@@ -1720,7 +1720,6 @@ def list_authenticated_providers( results: List[dict] = [] seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) - seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn) _current_provider_norm = str(current_provider or "").strip().lower() _current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower() @@ -1849,6 +1848,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: # --- 1. Check Hermes-mapped providers --- from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS + from hermes_cli.models import _PROVIDER_ALIASES as _CANON_ALIASES from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items(): # Skip vendor names that are merely aliases routing through an @@ -1866,10 +1866,30 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: and _alias_target in _AGG_PROVIDERS ): continue - # Skip aliases that map to the same models.dev provider (e.g. - # kimi-coding and kimi-coding-cn both → kimi-for-coding). - # The first one with valid credentials wins (#10526). - if mdev_id in seen_mdev_ids: + # Resolve the canonical provider profile name. Skip hermes_ids + # that are mere aliases resolving to a different canonical profile + # (e.g. "kimi" and "moonshot" both → "kimi-coding"). Only process + # entries whose hermes_id matches the canonical profile name so + # distinct profiles (e.g. kimi-coding, kimi-coding-cn) each get + # their own picker row. + _canonical = hermes_id + try: + from providers import get_provider_profile as _gpp + _prof = _gpp(hermes_id) + if _prof is not None: + _canonical = _prof.name + except Exception: + pass + if _canonical != hermes_id: + continue + + # Skip duplicates: another entry with the same slug was already + # emitted (e.g. two PROVIDER_TO_MODELS_DEV entries routing to the + # same hermes_id). Distinct canonical profiles that share a + # models.dev ID (e.g. kimi-coding and kimi-coding-cn → kimi-for-coding) + # are both allowed through since they have different slugs. + slug = hermes_id + if slug.lower() in seen_slugs: continue if hermes_id.lower() in _excluded or mdev_id.lower() in _excluded: continue @@ -1940,21 +1960,23 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: else: top = model_ids[:max_models] if max_models is not None else model_ids - slug = hermes_id pinfo = _mdev_pinfo(mdev_id) - display_name = pinfo.name if pinfo else mdev_id + display_name = pconfig.name if pconfig and pconfig.name else (pinfo.name if pinfo else mdev_id) results.append({ "slug": slug, "name": display_name, - "is_current": slug == current_provider or mdev_id == current_provider, + "is_current": ( + slug == current_provider + or hermes_id == current_provider + or mdev_id == current_provider + ), "is_user_defined": False, "models": top, "total_models": total, "source": "built-in", }) seen_slugs.add(slug.lower()) - seen_mdev_ids.add(mdev_id) _record_builtin_endpoint(slug) # --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) --- -
hermes_cli/providers.py@@ -765,6 +765,36 @@ def resolve_provider_full( if user_pdef is not None: return user_pdef + # 0.5 Exact Hermes provider IDs must win over LOSSY alias collapsing. + # Example: kimi-coding-cn should stay distinct from kimi-coding instead of + # normalizing through the shared models.dev alias "kimi-for-coding". + # A collapse is lossy only when MULTIPLE distinct registry providers + # normalize to the same canonical name — resolving through the alias + # would then lose which one the caller meant. Single-entry rewrites + # (e.g. "copilot" → "github-copilot") are correct routing and must keep + # resolving through the built-in chain below so overlay transports apply. + if canonical != raw: + try: + from hermes_cli.auth import PROVIDER_REGISTRY as _AUTH_PROVIDER_REGISTRY + _pcfg = _AUTH_PROVIDER_REGISTRY.get(raw) + if _pcfg is not None: + _collapsed_siblings = [ + _rid + for _rid in _AUTH_PROVIDER_REGISTRY + if normalize_provider(_rid) == canonical + ] + if len(_collapsed_siblings) > 1: + return ProviderDef( + id=_pcfg.id, + name=_pcfg.name, + transport="openai_chat", + api_key_env_vars=[redacted:secret-assignment] or ()), + base_url=_pcfg.inference_base_url or "", + source="hermes-auth-registry", + ) + except Exception: + pass + # 1. Built-in (models.dev + overlays) pdef = get_provider(canonical) if pdef is not None: -
tests/hermes_cli/test_kimi_cn_provider_listing.py@@ -0,0 +1,176 @@ +"""Test that kimi-coding and kimi-coding-cn both appear in the /model picker. + +Both providers share the same models.dev ID (kimi-for-coding) but are distinct +profiles with different API keys, base URLs, and endpoints. The /model picker +must show both so users can pick the right endpoint for their key type. + +Regression: the original ``seen_mdev_ids`` dedup by mdev_id alone would skip +kimi-coding-cn after kimi-coding was emitted because both map to +``kimi-for-coding`` (#10526). The fix deduplicates by +``(mdev_id, canonical_profile_name)`` instead, allowing distinct profiles +through. +""" + +import os +from unittest.mock import patch + +from hermes_cli.model_switch import ( + list_authenticated_providers, + parse_model_flags, + switch_model, +) +from hermes_cli.providers import resolve_provider_full + + +# -- Only KIMI_CN_API_KEY set ------------------------------------------------ + + [redacted:email](os.environ, {"KIMI_CN_API_KEY": "sk-cn-fake"}, clear=False) +def test_kimi_cn_appears_when_only_cn_key_set(): + """kimi-coding-cn should appear when only KIMI_CN_API_KEY is set.""" + providers = list_authenticated_providers(current_provider="kimi-coding-cn") + + # kimi-coding-cn must be listed (it has credentials) + cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None) + assert cn is not None, ( + "kimi-coding-cn should appear when KIMI_CN_API_KEY is set" + ) + assert cn["is_current"] is True + assert cn["total_models"] > 0 + + # kimi-coding must NOT appear (no KIMI_API_KEY) + intl = next((p for p in providers if p["slug"] == "kimi-coding"), None) + assert intl is None, ( + "kimi-coding should NOT appear when only KIMI_CN_API_KEY is set" + ) + + +# -- Only KIMI_API_KEY set --------------------------------------------------- + + [redacted:email](os.environ, {"KIMI_API_KEY": "sk-intl-fake"}, clear=False) +def test_kimi_intl_appears_when_only_intl_key_set(): + """kimi-coding (international) should appear when only KIMI_API_KEY is set.""" + providers = list_authenticated_providers(current_provider="kimi-coding") + + intl = next((p for p in providers if p["slug"] == "kimi-coding"), None) + assert intl is not None, ( + "kimi-coding should appear when KIMI_API_KEY is set" + ) + assert intl["is_current"] is True + + # kimi-coding-cn must NOT appear (no KIMI_CN_API_KEY) + cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None) + assert cn is None, ( + "kimi-coding-cn should NOT appear when only KIMI_API_KEY is set" + ) + + +# -- Both keys set ----------------------------------------------------------- + [redacted:email](os.environ, { + "KIMI_API_KEY": "sk-intl-fake", + "KIMI_CN_API_KEY": "sk-cn-fake", +}, clear=False) +def test_both_kimi_providers_appear_when_both_keys_set(): + """Both kimi-coding and kimi-coding-cn should appear when both keys set. + + They are distinct profiles with different env vars and endpoints. The + existing aliases (kimi, moonshot → kimi-coding; kimi-cn, moonshot-cn → + kimi-coding-cn) must NOT create additional rows. + """ + providers = list_authenticated_providers(current_provider="kimi-coding") + + # Both profile slugs must appear + intl = next((p for p in providers if p["slug"] == "kimi-coding"), None) + assert intl is not None, "kimi-coding should appear when KIMI_API_KEY is set" + assert intl["is_current"] is True + + cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None) + assert cn is not None, ( + "kimi-coding-cn should appear when KIMI_CN_API_KEY is set" + ) + assert cn["is_current"] is False # `current_provider` is kimi-coding + + # Exactly 2 Kimi entries — no duplicates for aliases (kimi, moonshot, + # moonshot-cn, kimi-cn) + kimi_slugs = [p["slug"] for p in providers if "kimi" in p["slug"] or "moonshot" in p["slug"]] + assert len(kimi_slugs) == 2, ( + f"Expected exactly 2 Kimi entries (kimi-coding, kimi-coding-cn), " + f"got {kimi_slugs}" + ) + + +# -- Both aliases deduped correctly ------------------------------------------ + [redacted:email](os.environ, { + "KIMI_API_KEY": "sk-intl-fake", + "KIMI_CN_API_KEY": "sk-cn-fake", +}, clear=False) +def test_kimi_aliases_not_listed_separately(): + """Alias hermes_ids (kimi, moonshot) must NOT create phantom picker rows. + + They resolve to the same canonical profile (kimi-coding) and should be + deduped. Only the canonical slug (kimi-coding) should appear. + """ + providers = list_authenticated_providers(current_provider="kimi-coding-cn") + + slugs = {p["slug"] for p in providers} + # These alias slugs must NOT appear + for bad_slug in ("kimi", "moonshot", "moonshot-cn", "kimi-cn"): + assert bad_slug not in slugs, ( + f"Alias slug '{bad_slug}' must not appear in picker (resolved to " + f"canonical profile)" + ) + + [redacted:email](os.environ, { + "KIMI_API_KEY": "sk-intl-fake", + "KIMI_CN_API_KEY": "sk-cn-fake", +}, clear=False) +def test_resolve_provider_full_preserves_kimi_cn_provider_identity(): + """Explicit kimi-coding-cn must not collapse to shared models.dev alias. + + Regression: resolve_provider_full('kimi-coding-cn') used normalize_provider(), + which mapped both kimi-coding and kimi-coding-cn to the models.dev alias + 'kimi-for-coding'. That silently rewired CN users to the international + endpoint and KIMI_API_KEY. + """ + pdef = resolve_provider_full("kimi-coding-cn", None, None) + assert pdef is not None + assert pdef.id == "kimi-coding-cn" + assert pdef.base_url == "https://api.moonshot.cn/v1" + assert pdef.api_key_env_vars == ("KIMI_CN_API_KEY",) + + [redacted:email](os.environ, { + "KIMI_API_KEY": "sk-intl-fake", + "KIMI_CN_API_KEY": "sk-cn-fake", +}, clear=False) +def test_switch_model_with_explicit_kimi_cn_provider_stays_on_cn_endpoint(): + """/model ... --provider kimi-coding-cn must stay on moonshot.cn. + + This hits the real switch path used by gateway /model: parse flags first, + then call switch_model() with explicit_provider. The result must not rewrite + the target provider/base_url back to the international Kimi endpoint. + """ + model_input, explicit_provider, *_ = parse_model_flags( + "kimi-k2.6 —provider kimi-coding-cn" + ) + result = switch_model( + raw_input=model_input, + current_provider="deepseek", + current_model="deepseek-v4-flash", + current_base_url="https://api.deepseek.com/v1", + current_api_key="***", + is_global=False, + explicit_provider=explicit_provider, + user_providers={}, + custom_providers=None, + ) + + assert result.success is True + assert result.target_provider == "kimi-coding-cn" + assert result.new_model == "kimi-k2.6" + assert result.base_url == "https://api.moonshot.cn/v1" + assert result.api_key == "sk-cn-fake" -
tests/hermes_cli/test_list_picker_providers.py@@ -299,3 +299,103 @@ def test_current_custom_endpoint_passthrough_marks_current_row(monkeypatch): assert row["slug"] == "custom:ollama" assert row["is_current"] is True assert row["models"] == ["glm-5.1", "qwen3"] + + +# --------------------------------------------------------------------------- +# list_authenticated_providers: alias/canonical de-dup for Kimi (#49439) +# --------------------------------------------------------------------------- +# +# A single Kimi credential used to surface TWO picker rows: the alias slug +# "kimi" (emitted by the PROVIDER_TO_MODELS_DEV pass) plus its canonical +# "kimi-coding" (re-emitted by the CANONICAL_PROVIDERS cross-check pass), +# both backed by the same kimi-for-coding models.dev provider. The picker +# must list each authenticated credential exactly once, under the CANONICAL +# slug ("kimi-coding") — matching list_authenticated_providers' other alias +# rows and the overlay slug-resolution contract (see +# test_overlay_slug_resolution.py). + + +def _stub_kimi_discovery(monkeypatch, *, canonical): + """Isolate list_authenticated_providers to the Kimi alias family. + + Restricts the models.dev map / catalog / overlays / canonical list to + just the Kimi entries and stubs the model-id fetch so discovery stays + offline and deterministic. ``canonical`` is the CANONICAL_PROVIDERS list + the 2b cross-check pass should iterate. + """ + import agent.models_dev as md + import hermes_cli.models as hm + + kimi_map = { + "kimi": "kimi-for-coding", + "kimi-coding": "kimi-for-coding", + "moonshot": "kimi-for-coding", + "kimi-coding-cn": "kimi-for-coding", + } + monkeypatch.setattr(md, "PROVIDER_TO_MODELS_DEV", kimi_map) + monkeypatch.setattr( + md, "fetch_models_dev", + lambda *a, **k: { + "kimi-for-coding": {"name": "Kimi For Coding", "env": ["KIMI_API_KEY"]}, + }, + ) + + class _PInfo: + name = "Kimi For Coding" + + monkeypatch.setattr(md, "get_provider_info", lambda _pid: _PInfo()) + monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {}) + monkeypatch.setattr(hm, "CANONICAL_PROVIDERS", canonical) + monkeypatch.setattr(hm, "cached_provider_model_ids", + lambda *a, **k: ["kimi-k2.6", "kimi-k2.5"]) + monkeypatch.setattr(hm, "clear_provider_models_cache", lambda *a, **k: None) + + +def test_single_kimi_credential_yields_one_canonical_row(monkeypatch): + """One Kimi key yields a single row under the canonical 'kimi-coding' slug.""" + import hermes_cli.models as hm + + _stub_kimi_discovery( + monkeypatch, + canonical=[hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc")], + ) + monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi") + + rows = model_switch.list_authenticated_providers(max_models=10) + slugs = [r["slug"] for r in rows] + + # Exactly one Kimi / kimi-for-coding-backed row, under the canonical slug — + # not both the alias ("kimi") and its canonical ("kimi-coding"). + kimi_rows = [s for s in slugs if s in {"kimi", "kimi-coding"}] + assert kimi_rows == ["kimi-coding"], ( + f"expected a single canonical Kimi row, got: {slugs}" + ) + assert slugs.count("kimi-coding") == 1 + assert "kimi" not in slugs + + +def test_distinct_kimi_china_credential_still_listed(monkeypatch): + """A separate China (kimi-coding-cn) credential remains its own row. + + Negative-control guard: the de-dup must collapse only the alias/canonical + pair that share a credential, not legitimately distinct providers. + """ + import hermes_cli.models as hm + + _stub_kimi_discovery( + monkeypatch, + canonical=[ + hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc"), + hm.ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "desc"), + ], + ) + monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi") + monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test-kimi-cn") + + rows = model_switch.list_authenticated_providers(max_models=10) + slugs = [r["slug"] for r in rows] + + assert "kimi-coding" in slugs # canonical global row + assert slugs.count("kimi-coding") == 1 + assert "kimi" not in slugs # alias collapsed into the canonical row + assert "kimi-coding-cn" in slugs # distinct China endpoint preserved
-
2 PRs closed without merging
From the close comments: duplicate 1 · merged via another change 1
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.22: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab