From 8ca4c745d0b6547fc7f74b48b082f70b1da526f8 Mon Sep 17 00:00:00 2001 From: ijevin Date: Tue, 21 Jul 2026 13:46:59 +0800 Subject: [PATCH] fix(models): resolve custom provider model ids Map picker-prefixed custom provider selections back to their configured model IDs before validation, persistence, and API requests. Fixes #68347 --- hermes_cli/model_switch.py | 35 ++++++++++++++++ .../test_model_switch_custom_providers.py | 42 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 4205bdd302e2..3e239a593ed0 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -965,6 +965,38 @@ def _configured_provider_matches( return matches +def _resolve_named_custom_model_id( + model_name: str, + target_provider: str, + custom_providers: Optional[list], +) -> str: + """Map a picker-prefixed custom model selection to its configured ID.""" + provider = str(target_provider or "").strip().lower() + if not provider.startswith("custom:") or "/" not in model_name: + return model_name + + prefix, candidate = model_name.split("/", 1) + prefix = prefix.strip().lower() + candidate = candidate.strip() + if not prefix or not candidate: + return model_name + + for entry in custom_providers or []: + if not isinstance(entry, dict): + continue + entry_slugs = { + custom_provider_slug(str(entry.get(key) or "")).lower() + for key in ("name", "provider_key") + if str(entry.get(key) or "").strip() + } + if provider not in entry_slugs or f"custom:{prefix}" not in entry_slugs: + continue + for model_id in _declared_model_ids(entry.get("models")): + if model_id.lower() == candidate.lower(): + return model_id + return model_name + + # --------------------------------------------------------------------------- # Core model-switching pipeline # --------------------------------------------------------------------------- @@ -1447,6 +1479,9 @@ def switch_model( api_mode = determine_api_mode(target_provider, base_url) # --- Normalize model name for target provider --- + new_model = _resolve_named_custom_model_id( + new_model, target_provider, custom_providers + ) new_model = normalize_model_for_provider(new_model, target_provider) # --- Validate --- diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 2f9784ee4401..5b1529a1002f 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -283,6 +283,48 @@ def test_switch_model_accepts_explicit_named_custom_provider(monkeypatch): assert result.api_key == "no-key-required" +def test_picker_selection_resolves_named_custom_provider_model_id(monkeypatch): + """Picker prefixes must not leak into a named custom provider API model id.""" + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **kwargs: { + "api_key": "test-key", + "base_url": "https://token.sensenova.cn/v1", + "api_mode": "chat_completions", + }, + ) + monkeypatch.setattr( + "hermes_cli.models.validate_requested_model", + lambda *a, **k: _MOCK_VALIDATION, + ) + monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None) + monkeypatch.setattr( + "hermes_cli.model_switch.get_model_capabilities", + lambda *a, **k: None, + ) + + result = switch_model( + raw_input="sensenova/deepseek-v4-flash", + current_provider="openai-codex", + current_model="gpt-5.4", + explicit_provider="custom:sensenova", + user_providers={}, + custom_providers=[ + { + "name": "sensenova", + "base_url": "https://token.sensenova.cn/v1", + "models": [ + {"id": "deepseek-v4-flash", "name": "deepseek-v4-flash"} + ], + } + ], + ) + + assert result.success is True + assert result.target_provider == "custom:sensenova" + assert result.new_model == "deepseek-v4-flash" + + def test_list_groups_same_name_custom_providers_into_one_row(monkeypatch): """Multiple custom_providers entries sharing a name should produce one row with all models collected, not N duplicate rows."""