{"repo":"NousResearch/hermes-agent","public_id":"10526","display_label":"Duplicate model picker rows for Kimi provider","summary":"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.","subsystem":"hermes_cli","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12306},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-20T17:17:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22476190476190475},"fix_edges":[{"issue_number":10526,"pr_number":12306,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":49439,"pr_number":68141,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":10526,"title":"Bug: Duplicate \"Kimi For Coding\" entries in TUI model picker","url":null,"state":"closed"},{"kind":"issue","number":49439,"title":"Bug: /models picker shows duplicate Kimi sections (kimi + kimi-coding)","url":null,"state":"closed"},{"kind":"issue","number":56839,"title":"Duplicate Kimi provider rows in /model picker when multiple Hermes slugs map to the same models.dev ID","url":null,"state":"closed"},{"kind":"pr","number":10634,"title":"fix(tui): disambiguate duplicate display names in model picker","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10706","number":10706}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":12306,"title":"fix(tui): disambiguate /model picker rows when provider display names collide","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["ui-tui/src/__tests__/providers.test.ts","ui-tui/src/components/modelPicker.tsx","ui-tui/src/domain/providers.ts"],"fix_hunks":[{"file":"ui-tui/src/__tests__/providers.test.ts","hunks":["@@ -0,0 +1,62 @@\n+import { describe, expect, it } from 'vitest'\n+\n+import { providerDisplayNames } from '../domain/providers.js'\n+\n+describe('providerDisplayNames', () => {\n+  it('returns bare names when all are unique', () => {\n+    expect(providerDisplayNames([{ name: 'Anthropic', slug: 'anthropic' }, { name: 'OpenAI', slug: 'openai' }])).toEqual(\n+      ['Anthropic', 'OpenAI']\n+    )\n+  })\n+\n+  it('appends slug to every collision so the disambiguation is symmetric', () => {\n+    expect(\n+      providerDisplayNames([\n+        { name: 'Kimi For Coding', slug: 'kimi-coding' },\n+        { name: 'Kimi For Coding', slug: 'kimi-coding-cn' }\n+      ])\n+    ).toEqual(['Kimi For Coding (kimi-coding)', 'Kimi For Coding (kimi-coding-cn)'])\n+  })\n+\n+  it('only disambiguates the colliding group', () => {\n+    expect(\n+      providerDisplayNames([\n+        { name: 'Anthropic', slug: 'anthropic' },\n+        { name: 'Foo', slug: 'foo-a' },\n+        { name: 'Foo', slug: 'foo-b' }\n+      ])\n+    ).toEqual(['Anthropic', 'Foo (foo-a)', 'Foo (foo-b)'])\n+  })\n+\n+  it('falls back to plain name if slug is empty', () => {\n+    expect(\n+      providerDisplayNames([\n+        { name: 'Foo', slug: '' },\n+        { name: 'Foo', slug: '' }\n+      ])\n+    ).toEqual(['Foo', 'Foo'])\n+  })\n+\n+  it('skips disambiguation when slug equals name', () => {\n+    expect(\n+      providerDisplayNames([\n+        { name: 'foo', slug: 'foo' },\n+        { name: 'foo', slug: 'foo' }\n+      ])\n+    ).toEqual(['foo', 'foo'])\n+  })\n+\n+  it('handles empty input', () => {\n+    expect(providerDisplayNames([])).toEqual([])\n+  })\n+\n+  it('preserves order', () => {\n+    const input = [\n+      { name: 'Z', slug: 'z' },\n+      { name: 'A', slug: 'a1' },\n+      { name: 'A', slug: 'a2' }\n+    ]\n+\n+    expect(providerDisplayNames(input)).toEqual(['Z', 'A (a1)', 'A (a2)'])\n+  })\n+})"]},{"file":"ui-tui/src/components/modelPicker.tsx","hunks":["@@ -1,6 +1,7 @@\n import { Box, Text, useInput } from '@hermes/ink'\n-import { useEffect, useState } from 'react'\n+import { useEffect, useMemo, useState } from 'react'\n \n+import { providerDisplayNames } from '../domain/providers.js'\n import type { GatewayClient } from '../gatewayClient.js'\n import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'\n import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'","@@ -59,6 +60,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke\n \n   const provider = providers[providerIdx]\n   const models = provider?.models ?? []\n+  const names = useMemo(() => providerDisplayNames(providers), [providers])\n \n   useInput((ch, key) => {\n     if (key.escape) {","@@ -160,7 +162,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke\n \n   if (stage === 'provider') {\n     const rows = providers.map(\n-      p => `${p.is_current ? '*' : ' '} ${p.name} · ${p.total_models ?? p.models?.length ?? 0} models`\n+      (p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models`\n     )\n \n     const { items, off } = visibleItems(rows, providerIdx)","@@ -201,7 +203,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke\n         Select Model\n       </Text>\n \n-      <Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text>\n+      <Text color={t.color.dim}>{names[providerIdx] || '(unknown provider)'}</Text>\n       {!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null}\n       {provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}\n       {off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}"]},{"file":"ui-tui/src/domain/providers.ts","hunks":["@@ -0,0 +1,17 @@\n+export const providerDisplayNames = (providers: readonly { name: string; slug: string }[]): string[] => {\n+  const counts = new Map<string, number>()\n+\n+  for (const p of providers) {\n+    counts.set(p.name, (counts.get(p.name) ?? 0) + 1)\n+  }\n+\n+  return providers.map(p => {\n+    const dup = (counts.get(p.name) ?? 0) > 1\n+\n+    if (!dup || !p.slug || p.slug === p.name) {\n+      return p.name\n+    }\n+\n+    return `${p.name} (${p.slug})`\n+  })\n+}\n"]}]},{"kind":"pr","number":49455,"title":"fix(model): collapse kimi alias/canonical to one /model picker row","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68141","number":68141}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":68141,"title":"fix(picker): dedupe Kimi alias/canonical rows + split distinct CN profile (salvage of #49455, #53100, #53392)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.py"],"fix_hunks":[{"file":"contributors/emails/[redacted:email]","hunks":["@@ -0,0 +1 @@\n+Almurat123"]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -1720,7 +1720,6 @@ def list_authenticated_providers(\n \n     results: List[dict] = []\n     seen_slugs: set = set()  # lowercase-normalized to catch case variants (#9545)\n-    seen_mdev_ids: set = set()  # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn)\n     _current_provider_norm = str(current_provider or \"\").strip().lower()\n     _current_base_url_norm = str(current_base_url or \"\").strip().rstrip(\"/\").lower()\n ","@@ -1849,6 +1848,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n \n     # --- 1. Check Hermes-mapped providers ---\n     from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS\n+    from hermes_cli.models import _PROVIDER_ALIASES as _CANON_ALIASES\n     from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE\n     for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():\n         # Skip vendor names that are merely aliases routing through an","@@ -1866,10 +1866,30 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n             and _alias_target in _AGG_PROVIDERS\n         ):\n             continue\n-        # Skip aliases that map to the same models.dev provider (e.g.\n-        # kimi-coding and kimi-coding-cn both → kimi-for-coding).\n-        # The first one with valid credentials wins (#10526).\n-        if mdev_id in seen_mdev_ids:\n+        # Resolve the canonical provider profile name.  Skip hermes_ids\n+        # that are mere aliases resolving to a different canonical profile\n+        # (e.g. \"kimi\" and \"moonshot\" both → \"kimi-coding\").  Only process\n+        # entries whose hermes_id matches the canonical profile name so\n+        # distinct profiles (e.g. kimi-coding, kimi-coding-cn) each get\n+        # their own picker row.\n+        _canonical = hermes_id\n+        try:\n+            from providers import get_provider_profile as _gpp\n+            _prof = _gpp(hermes_id)\n+            if _prof is not None:\n+                _canonical = _prof.name\n+        except Exception:\n+            pass\n+        if _canonical != hermes_id:\n+            continue\n+\n+        # Skip duplicates: another entry with the same slug was already\n+        # emitted (e.g. two PROVIDER_TO_MODELS_DEV entries routing to the\n+        # same hermes_id).  Distinct canonical profiles that share a\n+        # models.dev ID (e.g. kimi-coding and kimi-coding-cn → kimi-for-coding)\n+        # are both allowed through since they have different slugs.\n+        slug = hermes_id\n+        if slug.lower() in seen_slugs:\n             continue\n         if hermes_id.lower() in _excluded or mdev_id.lower() in _excluded:\n             continue","@@ -1940,21 +1960,23 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n         else:\n             top = model_ids[:max_models] if max_models is not None else model_ids\n \n-        slug = hermes_id\n         pinfo = _mdev_pinfo(mdev_id)\n-        display_name = pinfo.name if pinfo else mdev_id\n+        display_name = pconfig.name if pconfig and pconfig.name else (pinfo.name if pinfo else mdev_id)\n \n         results.append({\n             \"slug\": slug,\n             \"name\": display_name,\n-            \"is_current\": slug == current_provider or mdev_id == current_provider,\n+            \"is_current\": (\n+                slug == current_provider\n+                or hermes_id == current_provider\n+                or mdev_id == current_provider\n+            ),\n             \"is_user_defined\": False,\n             \"models\": top,\n             \"total_models\": total,\n             \"source\": \"built-in\",\n         })\n         seen_slugs.add(slug.lower())\n-        seen_mdev_ids.add(mdev_id)\n         _record_builtin_endpoint(slug)\n \n     # --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) ---"]},{"file":"hermes_cli/providers.py","hunks":["@@ -765,6 +765,36 @@ def resolve_provider_full(\n         if user_pdef is not None:\n             return user_pdef\n \n+    # 0.5 Exact Hermes provider IDs must win over LOSSY alias collapsing.\n+    # Example: kimi-coding-cn should stay distinct from kimi-coding instead of\n+    # normalizing through the shared models.dev alias \"kimi-for-coding\".\n+    # A collapse is lossy only when MULTIPLE distinct registry providers\n+    # normalize to the same canonical name — resolving through the alias\n+    # would then lose which one the caller meant. Single-entry rewrites\n+    # (e.g. \"copilot\" → \"github-copilot\") are correct routing and must keep\n+    # resolving through the built-in chain below so overlay transports apply.\n+    if canonical != raw:\n+        try:\n+            from hermes_cli.auth import PROVIDER_REGISTRY as _AUTH_PROVIDER_REGISTRY\n+            _pcfg = _AUTH_PROVIDER_REGISTRY.get(raw)\n+            if _pcfg is not None:\n+                _collapsed_siblings = [\n+                    _rid\n+                    for _rid in _AUTH_PROVIDER_REGISTRY\n+                    if normalize_provider(_rid) == canonical\n+                ]\n+                if len(_collapsed_siblings) > 1:\n+                    return ProviderDef(\n+                        id=_pcfg.id,\n+                        name=_pcfg.name,\n+                        transport=\"openai_chat\",\n+                        api_key_env_vars=[redacted:secret-assignment] or ()),\n+                        base_url=_pcfg.inference_base_url or \"\",\n+                        source=\"hermes-auth-registry\",\n+                    )\n+        except Exception:\n+            pass\n+\n     # 1. Built-in (models.dev + overlays)\n     pdef = get_provider(canonical)\n     if pdef is not None:"]},{"file":"tests/hermes_cli/test_kimi_cn_provider_listing.py","hunks":["@@ -0,0 +1,176 @@\n+\"\"\"Test that kimi-coding and kimi-coding-cn both appear in the /model picker.\n+\n+Both providers share the same models.dev ID (kimi-for-coding) but are distinct\n+profiles with different API keys, base URLs, and endpoints.  The /model picker\n+must show both so users can pick the right endpoint for their key type.\n+\n+Regression: the original ``seen_mdev_ids`` dedup by mdev_id alone would skip\n+kimi-coding-cn after kimi-coding was emitted because both map to\n+``kimi-for-coding`` (#10526).  The fix deduplicates by\n+``(mdev_id, canonical_profile_name)`` instead, allowing distinct profiles\n+through.\n+\"\"\"\n+\n+import os\n+from unittest.mock import patch\n+\n+from hermes_cli.model_switch import (\n+    list_authenticated_providers,\n+    parse_model_flags,\n+    switch_model,\n+)\n+from hermes_cli.providers import resolve_provider_full\n+\n+\n+# -- Only KIMI_CN_API_KEY set ------------------------------------------------\n+\n+\n[redacted:email](os.environ, {\"KIMI_CN_API_KEY\": \"sk-cn-fake\"}, clear=False)\n+def test_kimi_cn_appears_when_only_cn_key_set():\n+    \"\"\"kimi-coding-cn should appear when only KIMI_CN_API_KEY is set.\"\"\"\n+    providers = list_authenticated_providers(current_provider=\"kimi-coding-cn\")\n+\n+    # kimi-coding-cn must be listed (it has credentials)\n+    cn = next((p for p in providers if p[\"slug\"] == \"kimi-coding-cn\"), None)\n+    assert cn is not None, (\n+        \"kimi-coding-cn should appear when KIMI_CN_API_KEY is set\"\n+    )\n+    assert cn[\"is_current\"] is True\n+    assert cn[\"total_models\"] > 0\n+\n+    # kimi-coding must NOT appear (no KIMI_API_KEY)\n+    intl = next((p for p in providers if p[\"slug\"] == \"kimi-coding\"), None)\n+    assert intl is None, (\n+        \"kimi-coding should NOT appear when only KIMI_CN_API_KEY is set\"\n+    )\n+\n+\n+# -- Only KIMI_API_KEY set ---------------------------------------------------\n+\n+\n[redacted:email](os.environ, {\"KIMI_API_KEY\": \"sk-intl-fake\"}, clear=False)\n+def test_kimi_intl_appears_when_only_intl_key_set():\n+    \"\"\"kimi-coding (international) should appear when only KIMI_API_KEY is set.\"\"\"\n+    providers = list_authenticated_providers(current_provider=\"kimi-coding\")\n+\n+    intl = next((p for p in providers if p[\"slug\"] == \"kimi-coding\"), None)\n+    assert intl is not None, (\n+        \"kimi-coding should appear when KIMI_API_KEY is set\"\n+    )\n+    assert intl[\"is_current\"] is True\n+\n+    # kimi-coding-cn must NOT appear (no KIMI_CN_API_KEY)\n+    cn = next((p for p in providers if p[\"slug\"] == \"kimi-coding-cn\"), None)\n+    assert cn is None, (\n+        \"kimi-coding-cn should NOT appear when only KIMI_API_KEY is set\"\n+    )\n+\n+\n+# -- Both keys set -----------------------------------------------------------\n+\n[redacted:email](os.environ, {\n+    \"KIMI_API_KEY\": \"sk-intl-fake\",\n+    \"KIMI_CN_API_KEY\": \"sk-cn-fake\",\n+}, clear=False)\n+def test_both_kimi_providers_appear_when_both_keys_set():\n+    \"\"\"Both kimi-coding and kimi-coding-cn should appear when both keys set.\n+\n+    They are distinct profiles with different env vars and endpoints.  The\n+    existing aliases (kimi, moonshot → kimi-coding; kimi-cn, moonshot-cn →\n+    kimi-coding-cn) must NOT create additional rows.\n+    \"\"\"\n+    providers = list_authenticated_providers(current_provider=\"kimi-coding\")\n+\n+    # Both profile slugs must appear\n+    intl = next((p for p in providers if p[\"slug\"] == \"kimi-coding\"), None)\n+    assert intl is not None, \"kimi-coding should appear when KIMI_API_KEY is set\"\n+    assert intl[\"is_current\"] is True\n+\n+    cn = next((p for p in providers if p[\"slug\"] == \"kimi-coding-cn\"), None)\n+    assert cn is not None, (\n+        \"kimi-coding-cn should appear when KIMI_CN_API_KEY is set\"\n+    )\n+    assert cn[\"is_current\"] is False  # `current_provider` is kimi-coding\n+\n+    # Exactly 2 Kimi entries — no duplicates for aliases (kimi, moonshot,\n+    # moonshot-cn, kimi-cn)\n+    kimi_slugs = [p[\"slug\"] for p in providers if \"kimi\" in p[\"slug\"] or \"moonshot\" in p[\"slug\"]]\n+    assert len(kimi_slugs) == 2, (\n+        f\"Expected exactly 2 Kimi entries (kimi-coding, kimi-coding-cn), \"\n+        f\"got {kimi_slugs}\"\n+    )\n+\n+\n+# -- Both aliases deduped correctly ------------------------------------------\n+\n[redacted:email](os.environ, {\n+    \"KIMI_API_KEY\": \"sk-intl-fake\",\n+    \"KIMI_CN_API_KEY\": \"sk-cn-fake\",\n+}, clear=False)\n+def test_kimi_aliases_not_listed_separately():\n+    \"\"\"Alias hermes_ids (kimi, moonshot) must NOT create phantom picker rows.\n+\n+    They resolve to the same canonical profile (kimi-coding) and should be\n+    deduped.  Only the canonical slug (kimi-coding) should appear.\n+    \"\"\"\n+    providers = list_authenticated_providers(current_provider=\"kimi-coding-cn\")\n+\n+    slugs = {p[\"slug\"] for p in providers}\n+    # These alias slugs must NOT appear\n+    for bad_slug in (\"kimi\", \"moonshot\", \"moonshot-cn\", \"kimi-cn\"):\n+        assert bad_slug not in slugs, (\n+            f\"Alias slug '{bad_slug}' must not appear in picker (resolved to \"\n+            f\"canonical profile)\"\n+        )\n+\n+\n[redacted:email](os.environ, {\n+    \"KIMI_API_KEY\": \"sk-intl-fake\",\n+    \"KIMI_CN_API_KEY\": \"sk-cn-fake\",\n+}, clear=False)\n+def test_resolve_provider_full_preserves_kimi_cn_provider_identity():\n+    \"\"\"Explicit kimi-coding-cn must not collapse to shared models.dev alias.\n+\n+    Regression: resolve_provider_full('kimi-coding-cn') used normalize_provider(),\n+    which mapped both kimi-coding and kimi-coding-cn to the models.dev alias\n+    'kimi-for-coding'. That silently rewired CN users to the international\n+    endpoint and KIMI_API_KEY.\n+    \"\"\"\n+    pdef = resolve_provider_full(\"kimi-coding-cn\", None, None)\n+    assert pdef is not None\n+    assert pdef.id == \"kimi-coding-cn\"\n+    assert pdef.base_url == \"https://api.moonshot.cn/v1\"\n+    assert pdef.api_key_env_vars == (\"KIMI_CN_API_KEY\",)\n+\n+\n[redacted:email](os.environ, {\n+    \"KIMI_API_KEY\": \"sk-intl-fake\",\n+    \"KIMI_CN_API_KEY\": \"sk-cn-fake\",\n+}, clear=False)\n+def test_switch_model_with_explicit_kimi_cn_provider_stays_on_cn_endpoint():\n+    \"\"\"/model ... --provider kimi-coding-cn must stay on moonshot.cn.\n+\n+    This hits the real switch path used by gateway /model: parse flags first,\n+    then call switch_model() with explicit_provider. The result must not rewrite\n+    the target provider/base_url back to the international Kimi endpoint.\n+    \"\"\"\n+    model_input, explicit_provider, *_ = parse_model_flags(\n+        \"kimi-k2.6 —provider kimi-coding-cn\"\n+    )\n+    result = switch_model(\n+        raw_input=model_input,\n+        current_provider=\"deepseek\",\n+        current_model=\"deepseek-v4-flash\",\n+        current_base_url=\"https://api.deepseek.com/v1\",\n+        current_api_key=\"***\",\n+        is_global=False,\n+        explicit_provider=explicit_provider,\n+        user_providers={},\n+        custom_providers=None,\n+    )\n+\n+    assert result.success is True\n+    assert result.target_provider == \"kimi-coding-cn\"\n+    assert result.new_model == \"kimi-k2.6\"\n+    assert result.base_url == \"https://api.moonshot.cn/v1\"\n+    assert result.api_key == \"sk-cn-fake\""]},{"file":"tests/hermes_cli/test_list_picker_providers.py","hunks":["@@ -299,3 +299,103 @@ def test_current_custom_endpoint_passthrough_marks_current_row(monkeypatch):\n     assert row[\"slug\"] == \"custom:ollama\"\n     assert row[\"is_current\"] is True\n     assert row[\"models\"] == [\"glm-5.1\", \"qwen3\"]\n+\n+\n+# ---------------------------------------------------------------------------\n+# list_authenticated_providers: alias/canonical de-dup for Kimi (#49439)\n+# ---------------------------------------------------------------------------\n+#\n+# A single Kimi credential used to surface TWO picker rows: the alias slug\n+# \"kimi\" (emitted by the PROVIDER_TO_MODELS_DEV pass) plus its canonical\n+# \"kimi-coding\" (re-emitted by the CANONICAL_PROVIDERS cross-check pass),\n+# both backed by the same kimi-for-coding models.dev provider. The picker\n+# must list each authenticated credential exactly once, under the CANONICAL\n+# slug (\"kimi-coding\") — matching list_authenticated_providers' other alias\n+# rows and the overlay slug-resolution contract (see\n+# test_overlay_slug_resolution.py).\n+\n+\n+def _stub_kimi_discovery(monkeypatch, *, canonical):\n+    \"\"\"Isolate list_authenticated_providers to the Kimi alias family.\n+\n+    Restricts the models.dev map / catalog / overlays / canonical list to\n+    just the Kimi entries and stubs the model-id fetch so discovery stays\n+    offline and deterministic. ``canonical`` is the CANONICAL_PROVIDERS list\n+    the 2b cross-check pass should iterate.\n+    \"\"\"\n+    import agent.models_dev as md\n+    import hermes_cli.models as hm\n+\n+    kimi_map = {\n+        \"kimi\": \"kimi-for-coding\",\n+        \"kimi-coding\": \"kimi-for-coding\",\n+        \"moonshot\": \"kimi-for-coding\",\n+        \"kimi-coding-cn\": \"kimi-for-coding\",\n+    }\n+    monkeypatch.setattr(md, \"PROVIDER_TO_MODELS_DEV\", kimi_map)\n+    monkeypatch.setattr(\n+        md, \"fetch_models_dev\",\n+        lambda *a, **k: {\n+            \"kimi-for-coding\": {\"name\": \"Kimi For Coding\", \"env\": [\"KIMI_API_KEY\"]},\n+        },\n+    )\n+\n+    class _PInfo:\n+        name = \"Kimi For Coding\"\n+\n+    monkeypatch.setattr(md, \"get_provider_info\", lambda _pid: _PInfo())\n+    monkeypatch.setattr(\"hermes_cli.providers.HERMES_OVERLAYS\", {})\n+    monkeypatch.setattr(hm, \"CANONICAL_PROVIDERS\", canonical)\n+    monkeypatch.setattr(hm, \"cached_provider_model_ids\",\n+                        lambda *a, **k: [\"kimi-k2.6\", \"kimi-k2.5\"])\n+    monkeypatch.setattr(hm, \"clear_provider_models_cache\", lambda *a, **k: None)\n+\n+\n+def test_single_kimi_credential_yields_one_canonical_row(monkeypatch):\n+    \"\"\"One Kimi key yields a single row under the canonical 'kimi-coding' slug.\"\"\"\n+    import hermes_cli.models as hm\n+\n+    _stub_kimi_discovery(\n+        monkeypatch,\n+        canonical=[hm.ProviderEntry(\"kimi-coding\", \"Kimi / Kimi Coding Plan\", \"desc\")],\n+    )\n+    monkeypatch.setenv(\"KIMI_API_KEY\", \"sk-test-kimi\")\n+\n+    rows = model_switch.list_authenticated_providers(max_models=10)\n+    slugs = [r[\"slug\"] for r in rows]\n+\n+    # Exactly one Kimi / kimi-for-coding-backed row, under the canonical slug —\n+    # not both the alias (\"kimi\") and its canonical (\"kimi-coding\").\n+    kimi_rows = [s for s in slugs if s in {\"kimi\", \"kimi-coding\"}]\n+    assert kimi_rows == [\"kimi-coding\"], (\n+        f\"expected a single canonical Kimi row, got: {slugs}\"\n+    )\n+    assert slugs.count(\"kimi-coding\") == 1\n+    assert \"kimi\" not in slugs\n+\n+\n+def test_distinct_kimi_china_credential_still_listed(monkeypatch):\n+    \"\"\"A separate China (kimi-coding-cn) credential remains its own row.\n+\n+    Negative-control guard: the de-dup must collapse only the alias/canonical\n+    pair that share a credential, not legitimately distinct providers.\n+    \"\"\"\n+    import hermes_cli.models as hm\n+\n+    _stub_kimi_discovery(\n+        monkeypatch,\n+        canonical=[\n+            hm.ProviderEntry(\"kimi-coding\", \"Kimi / Kimi Coding Plan\", \"desc\"),\n+            hm.ProviderEntry(\"kimi-coding-cn\", \"Kimi / Moonshot (China)\", \"desc\"),\n+        ],\n+    )\n+    monkeypatch.setenv(\"KIMI_API_KEY\", \"sk-test-kimi\")\n+    monkeypatch.setenv(\"KIMI_CN_API_KEY\", \"sk-test-kimi-cn\")\n+\n+    rows = model_switch.list_authenticated_providers(max_models=10)\n+    slugs = [r[\"slug\"] for r in rows]\n+\n+    assert \"kimi-coding\" in slugs       # canonical global row\n+    assert slugs.count(\"kimi-coding\") == 1\n+    assert \"kimi\" not in slugs          # alias collapsed into the canonical row\n+    assert \"kimi-coding-cn\" in slugs    # distinct China endpoint preserved\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}