diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 869d82bf6d..1d37900f3c 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1018,6 +1018,37 @@ 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) + # Effective base URLs of every built-in row we emit (normalized lower+rstrip). + # Section 4 uses this to hide ``custom_providers`` entries that point at the + # same endpoint as a built-in (e.g. a user-defined "my-dashscope" on + # https://coding-intl.dashscope.aliyuncs.com/v1 collides with the built-in + # alibaba-coding-plan row when DASHSCOPE_API_KEY is present). Fixes #16970. + _builtin_endpoints: set = set() + + def _norm_url(url: str) -> str: + return str(url or "").strip().rstrip("/").lower() + + def _record_builtin_endpoint(slug: str) -> None: + """Record the effective base URL for a built-in provider row. + + Prefers the live env-override (e.g. DASHSCOPE_BASE_URL) over the + static inference_base_url so the dedup matches what a user typing + that URL into custom_providers would actually hit.""" + try: + from hermes_cli.auth import PROVIDER_REGISTRY as _reg + except Exception: + return + pcfg = _reg.get(slug) + if not pcfg: + return + url = "" + if getattr(pcfg, "base_url_env_var", ""): + url = os.environ.get(pcfg.base_url_env_var, "") or "" + if not url: + url = getattr(pcfg, "inference_base_url", "") or "" + normed = _norm_url(url) + if normed: + _builtin_endpoints.add(normed) data = fetch_models_dev() @@ -1124,6 +1155,7 @@ def list_authenticated_providers( }) 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) --- from hermes_cli.providers import HERMES_OVERLAYS @@ -1238,6 +1270,7 @@ def list_authenticated_providers( }) seen_slugs.add(pid.lower()) seen_slugs.add(hermes_slug.lower()) + _record_builtin_endpoint(hermes_slug) # --- 2b. Cross-check canonical provider list --- # Catches providers that are in CANONICAL_PROVIDERS but weren't found @@ -1317,6 +1350,7 @@ def list_authenticated_providers( "source": "canonical", }) seen_slugs.add(_cp.slug.lower()) + _record_builtin_endpoint(_cp.slug) # --- 3. User-defined endpoints from config --- # Track (name, base_url) of what section 3 emits so section 4 can skip @@ -1526,6 +1560,15 @@ def list_authenticated_providers( ) if _pair_key[0] and _pair_key[1] and _pair_key in _section3_emitted_pairs: continue + # Skip if a built-in row (sections 1/2/2b) already represents this + # endpoint. Fixes #16970: a user-defined "my-dashscope" pointing at + # https://coding-intl.dashscope.aliyuncs.com/v1 duplicates the + # built-in alibaba-coding-plan row whenever DASHSCOPE_API_KEY is + # set. The built-in row carries the curated model list, correct + # auth wiring, and canonical slug — keep it and hide the shadow. + _grp_url_norm = _pair_key[1] + if _grp_url_norm and _grp_url_norm in _builtin_endpoints: + continue results.append({ "slug": slug, "name": grp["name"], diff --git a/tests/hermes_cli/test_user_providers_model_switch.py b/tests/hermes_cli/test_user_providers_model_switch.py index b86dcdba3b..0a357c21fc 100644 --- a/tests/hermes_cli/test_user_providers_model_switch.py +++ b/tests/hermes_cli/test_user_providers_model_switch.py @@ -453,6 +453,142 @@ def test_list_authenticated_providers_no_duplicate_labels_across_schemas(monkeyp ) +def test_list_authenticated_providers_hides_custom_shadowing_builtin_endpoint(monkeypatch): + """#16970: a custom_providers entry whose ``base_url`` matches a built-in + provider's endpoint should be hidden. The built-in row already represents + that endpoint with its canonical slug, curated model list, and auth wiring. + + Repro: user sets ``DASHSCOPE_API_KEY`` (triggers the built-in ``alibaba`` + row pointing at the static ``inference_base_url``) AND defines a + ``my-alibaba`` custom provider pointing at the same URL. Before the fix, + the picker showed both rows for one endpoint. + """ + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test") + monkeypatch.setattr( + "agent.models_dev.fetch_models_dev", + lambda: { + "alibaba": { + "name": "Alibaba Cloud (DashScope)", + "env": ["DASHSCOPE_API_KEY"], + } + }, + ) + monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {}) + + custom_providers = [ + { + "name": "my-alibaba", + # Matches PROVIDER_REGISTRY['alibaba'].inference_base_url exactly. + "base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "api_key": "sk-sp-test", + "model": "qwen3.6-plus", + "models": {"qwen3.6-plus": {"context_length": 500000}}, + } + ] + + providers = list_authenticated_providers( + current_provider="my-alibaba", + user_providers={}, + custom_providers=custom_providers, + max_models=50, + ) + + slugs = [p["slug"] for p in providers] + # Built-in alibaba row should be present. + assert "alibaba" in slugs, ( + f"Expected built-in alibaba row, got slugs: {slugs}" + ) + # Custom shadow row should be hidden — its base_url matches the built-in's. + assert not any("my-alibaba" in s for s in slugs), ( + f"Custom my-alibaba should have been dedup'd against the built-in " + f"alibaba endpoint, got slugs: {slugs}" + ) + + +def test_list_authenticated_providers_keeps_custom_with_distinct_endpoint(monkeypatch): + """Dedup must only apply when the endpoint matches a built-in. A custom + provider on a genuinely distinct endpoint stays visible even if a + built-in is also authenticated.""" + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test") + monkeypatch.setattr( + "agent.models_dev.fetch_models_dev", + lambda: { + "alibaba": { + "name": "Alibaba Cloud (DashScope)", + "env": ["DASHSCOPE_API_KEY"], + } + }, + ) + monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {}) + + custom_providers = [ + { + "name": "my-private-relay", + "base_url": "https://relay.example.internal/v1", + "api_key": "sk-relay-test", + "model": "qwen3.6-plus", + "models": {"qwen3.6-plus": {}}, + } + ] + + providers = list_authenticated_providers( + current_provider="my-private-relay", + user_providers={}, + custom_providers=custom_providers, + max_models=50, + ) + + slugs = [p["slug"] for p in providers] + assert any("my-private-relay" in s for s in slugs), ( + f"Custom provider on distinct endpoint must stay visible, got: {slugs}" + ) + + +def test_list_authenticated_providers_dedup_honors_base_url_env_override(monkeypatch): + """The dedup must track the EFFECTIVE endpoint — if DASHSCOPE_BASE_URL + overrides the static inference_base_url, a custom provider pointing at + the overridden URL (not the static one) should still be recognized as + a duplicate.""" + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test") + monkeypatch.setenv( + "DASHSCOPE_BASE_URL", + "https://custom-dashscope.example.com/v1", + ) + monkeypatch.setattr( + "agent.models_dev.fetch_models_dev", + lambda: { + "alibaba": { + "name": "Alibaba Cloud (DashScope)", + "env": ["DASHSCOPE_API_KEY"], + } + }, + ) + monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {}) + + custom_providers = [ + { + "name": "my-dashscope-override", + # Same URL as DASHSCOPE_BASE_URL env override above. + "base_url": "https://custom-dashscope.example.com/v1", + "api_key": "sk-test", + "model": "qwen3.6-plus", + } + ] + + providers = list_authenticated_providers( + current_provider="alibaba", + user_providers={}, + custom_providers=custom_providers, + max_models=50, + ) + + slugs = [p["slug"] for p in providers] + assert not any("my-dashscope-override" in s for s in slugs), ( + f"Custom entry matching env-overridden built-in endpoint should be " + f"dedup'd, got: {slugs}" + ) + + # ============================================================================= # Tests for _get_named_custom_provider with providers: dict # =============================================================================