From 887295ba547b60f31df3508069fd0d301104e45c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:43:20 -0700 Subject: [PATCH] fix(config): preserve custom-provider models maps and metadata through v11->v12 migration (#40573) Salvaged from #40410; cleaned up, re-verified against main, tests added. Co-authored-by: rodboev --- hermes_cli/config.py | 67 ++++++++++++++++++----- tests/hermes_cli/test_config.py | 65 ++++++++++++++++++++++ tests/tools/test_docker_config_migrate.py | 17 +++++- 3 files changed, 133 insertions(+), 16 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 644485d8d36..76f008e3d3a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3793,6 +3793,42 @@ def _normalize_custom_provider_entry( return normalized +def _custom_provider_entry_to_provider_config( + entry: Any, + *, + provider_key: str = "", +) -> Optional[Dict[str, Any]]: + """Translate a legacy custom provider entry to the v12 providers shape.""" + normalized = _normalize_custom_provider_entry( + dict(entry) if isinstance(entry, dict) else entry, + provider_key=provider_key, + ) + if normalized is None: + return None + + provider_entry: Dict[str, Any] = {"api": normalized["base_url"]} + + for field in ( + "name", + "api_key", + "key_env", + "models", + "context_length", + "rate_limit_delay", + "discover_models", + "extra_body", + ): + if field in normalized: + provider_entry[field] = normalized[field] + + if "model" in normalized: + provider_entry["default_model"] = normalized["model"] + if "api_mode" in normalized: + provider_entry["transport"] = normalized["api_mode"] + + return provider_entry + + def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]: """Normalize ``providers`` config entries into the legacy custom-provider shape.""" if not isinstance(providers_dict, dict): @@ -4299,8 +4335,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A if not isinstance(entry, dict): continue old_name = entry.get("name", "") - old_url = entry.get("base_url", "") or entry.get("url", "") or "" - old_key = entry.get("api_key", "") + old_url = entry.get("base_url", "") or entry.get("url", "") or entry.get("api", "") or "" if not old_url: continue # skip entries with no URL @@ -4320,20 +4355,22 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A key = f"endpoint-{migrated_count}" # Don't overwrite existing entries - if key in providers_dict: - key = f"{key}-{migrated_count}" + base_key = key + suffix = migrated_count + while key in providers_dict: + key = f"{base_key}-{suffix}" + suffix += 1 - new_entry = {"api": old_url} - if old_name: - new_entry["name"] = old_name - if old_key and old_key not in {"no-key", "no-key-required", ""}: - new_entry["api_key"] = old_key - - # Carry over model and api_mode if present - if entry.get("model"): - new_entry["default_model"] = entry["model"] - if entry.get("api_mode"): - new_entry["transport"] = entry["api_mode"] + new_entry = _custom_provider_entry_to_provider_config( + entry, + provider_key=key, + ) + if new_entry is None: + continue + if not old_name: + new_entry.pop("name", None) + if new_entry.get("api_key") in {"no-key", "no-key-required", ""}: + new_entry.pop("api_key", None) providers_dict[key] = new_entry migrated_count += 1 diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 288a5a25778..29ff5f6f9e9 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -706,6 +706,71 @@ class TestCustomProviderCompatibility: # custom_providers removed by migration — runtime reads via compat layer assert "custom_providers" not in raw + def test_v11_upgrade_preserves_custom_provider_model_metadata(self, tmp_path): + config_path = tmp_path / "config.yaml" + model_map = { + "kimi-k2.6": {"context_length": 262144}, + "moonshotai/Kimi-K2.6-ACED": {"context_length": 131072}, + } + config_path.write_text( + yaml.safe_dump( + { + "_config_version": 11, + "custom_providers": [ + { + "name": "Kimi Coding Plan", + "base_url": "https://api.kimi.example.com/coding", + "api_key_env": "KIMI_CODING_API_KEY", + "api_mode": "anthropic_messages", + "model": "kimi-k2.6", + "models": model_map, + "context_length": 262144, + "rate_limit_delay": 0.25, + "discover_models": False, + "extra_body": { + "chat_template_kwargs": {"enable_thinking": False} + }, + }, + { + "name": "List Models", + "base_url": "https://list.example.com/v1", + "models": ["alpha", "beta"], + }, + ], + } + ), + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + migrate_config(interactive=False, quiet=True) + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + compatible = get_compatible_custom_providers(raw) + + assert "custom_providers" not in raw + provider = raw["providers"]["kimi-coding-plan"] + assert provider["api"] == "https://api.kimi.example.com/coding" + assert provider["key_env"] == "KIMI_CODING_API_KEY" + assert provider["transport"] == "anthropic_messages" + assert provider["default_model"] == "kimi-k2.6" + assert provider["models"] == model_map + assert provider["context_length"] == 262144 + assert provider["rate_limit_delay"] == 0.25 + assert provider["discover_models"] is False + assert provider["extra_body"] == { + "chat_template_kwargs": {"enable_thinking": False} + } + assert raw["providers"]["list-models"]["models"] == { + "alpha": {}, + "beta": {}, + } + + compatible_provider = next( + entry for entry in compatible if entry["provider_key"] == "kimi-coding-plan" + ) + assert compatible_provider["models"] == model_map + assert compatible_provider["key_env"] == "KIMI_CODING_API_KEY" + def test_providers_dict_resolves_at_runtime(self, tmp_path): """After migration deleted custom_providers, get_compatible_custom_providers still finds entries from the providers dict.""" diff --git a/tests/tools/test_docker_config_migrate.py b/tests/tools/test_docker_config_migrate.py index 61f1dcc1a34..a7fe193818d 100644 --- a/tests/tools/test_docker_config_migrate.py +++ b/tests/tools/test_docker_config_migrate.py @@ -35,6 +35,10 @@ def _run_migration(hermes_home: Path, **env_overrides: str) -> subprocess.Comple def test_docker_config_migrate_backs_up_and_migrates_legacy_config(tmp_path: Path) -> None: config_path = tmp_path / "config.yaml" env_path = tmp_path / ".env" + model_map = { + "local-small": {"context_length": 8192}, + "local-large": {"context_length": 32768}, + } config_path.write_text( yaml.safe_dump( { @@ -44,6 +48,11 @@ def test_docker_config_migrate_backs_up_and_migrates_legacy_config(tmp_path: Pat "name": "Local API", "base_url": "http://localhost:8080/v1", "api_key": "test-key", + "api_mode": "chat_completions", + "model": "local-small", + "models": model_map, + "context_length": 32768, + "discover_models": False, } ], } @@ -59,7 +68,13 @@ def test_docker_config_migrate_backs_up_and_migrates_legacy_config(tmp_path: Pat raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] assert "custom_providers" not in raw - assert raw["providers"]["local-api"]["api"] == "http://localhost:8080/v1" + provider = raw["providers"]["local-api"] + assert provider["api"] == "http://localhost:8080/v1" + assert provider["transport"] == "chat_completions" + assert provider["default_model"] == "local-small" + assert provider["models"] == model_map + assert provider["context_length"] == 32768 + assert provider["discover_models"] is False assert list(tmp_path.glob("config.yaml.bak-*")) assert list(tmp_path.glob(".env.bak-*"))