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

0open issues
0open PRs
7items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #12306 fix(tui): disambiguate /model picker rows when provider display names collide

    merged to main

    files: ui-tui/src/__tests__/providers.test.ts, ui-tui/src/components/modelPicker.tsx, ui-tui/src/domain/providers.ts

    Show 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)

    merged to main

    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.py

    Show 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

  • #10634 fix(tui): disambiguate duplicate display names in model picker · duplicate → #10706
  • #49455 fix(model): collapse kimi alias/canonical to one /model picker row · merged via another change → #68141
Closed issues · 3
  • Closed#10526Bug: Duplicate "Kimi For Coding" entries in TUI model picker
  • Closed#49439Bug: /models picker shows duplicate Kimi sections (kimi + kimi-coding)
  • Closed#56839Duplicate Kimi provider rows in /model picker when multiple Hermes slugs map to the same models.dev ID
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.00 means 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

At a glance

Subsystem
hermes cli
Merged PRs
#12306, #68141
Closed issues
3
Attempts
2 PRs closed without merging
Fix link checked
20 Jul 2026

For your agent

The same facts, typed.