Nothing open on GitHub. Fix merged in #9016 on 13 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #9016. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #9016. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A bug where the v11→v12 config migration converts custom_providers to a providers dict, but runtime code does not read from that new location. Members address both the migration and the runtime resolution of named providers.
Open issues0
Open PRs0
Fixes on record
-
#9016 fix(config): restore custom providers after v11→v12 migration
files:
agent/auxiliary_client.py,agent/credential_pool.py,cli.py,gateway/run.py,hermes_cli/auth_commands.py,hermes_cli/config.py,hermes_cli/main.py,hermes_cli/runtime_provider.py,run_agent.py,tests/hermes_cli/test_config.py,tests/hermes_cli/test_runtime_provider_resolution.py,tests/tools/test_browser_camofox_state.pyShow the diff
-
agent/auxiliary_client.py@@ -1438,10 +1438,14 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): custom_entry = _get_named_custom_provider(provider) if custom_entry: custom_base = custom_entry.get("base_url", "").strip() - custom_key = custom_entry.get("api_key", "").strip() or "no-key-required" + custom_key = custom_entry.get("api_key", "").strip() + custom_key_env = custom_entry.get("key_env", "").strip() + if not custom_key and custom_key_env: + custom_key = os.getenv(custom_key_env, "").strip() + custom_key = custom_key or "no-key-required" if custom_base: final_model = _normalize_resolved_model( - model or _read_main_model() or "gpt-4o-mini", + model or custom_entry.get("model") or _read_main_model() or "gpt-4o-mini", provider, ) client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base) -
agent/credential_pool.py@@ -289,6 +289,14 @@ def _iter_custom_providers(config: Optional[dict] = None): return custom_providers = config.get("custom_providers") if not isinstance(custom_providers, list): + # Fall back to the v12+ providers dict via the compatibility layer + try: + from hermes_cli.config import get_compatible_custom_providers + + custom_providers = get_compatible_custom_providers(config) + except Exception: + return + if not custom_providers: return for entry in custom_providers: if not isinstance(entry, dict): -
cli.py@@ -4710,10 +4710,10 @@ def _handle_model_switch(self, cmd_original: str): user_provs = None custom_provs = None try: - from hermes_cli.config import load_config + from hermes_cli.config import get_compatible_custom_providers, load_config cfg = load_config() user_provs = cfg.get("providers") - custom_provs = cfg.get("custom_providers") + custom_provs = get_compatible_custom_providers(cfg) except Exception: pass -
gateway/run.py@@ -3330,21 +3330,26 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): # Must run after runtime resolution so _hyg_base_url is set. if _hyg_config_context_length is None and _hyg_base_url: try: - _hyg_custom_providers = _hyg_data.get("custom_providers") - if isinstance(_hyg_custom_providers, list): - for _cp in _hyg_custom_providers: - if not isinstance(_cp, dict): - continue - _cp_url = (_cp.get("base_url") or "").rstrip("/") - if _cp_url and _cp_url == _hyg_base_url.rstrip("/"): - _cp_models = _cp.get("models", {}) - if isinstance(_cp_models, dict): - _cp_model_cfg = _cp_models.get(_hyg_model, {}) - if isinstance(_cp_model_cfg, dict): - _cp_ctx = _cp_model_cfg.get("context_length") - if _cp_ctx is not None: - _hyg_config_context_length = int(_cp_ctx) - break + try: + from hermes_cli.config import get_compatible_custom_providers as _gw_gcp + _hyg_custom_providers = _gw_gcp(_hyg_data) + except Exception: + _hyg_custom_providers = _hyg_data.get("custom_providers") + if not isinstance(_hyg_custom_providers, list): + _hyg_custom_providers = [] + for _cp in _hyg_custom_providers: + if not isinstance(_cp, dict): + continue + _cp_url = (_cp.get("base_url") or "").rstrip("/") + if _cp_url and _cp_url == _hyg_base_url.rstrip("/"): + _cp_models = _cp.get("models", {}) + if isinstance(_cp_models, dict): + _cp_model_cfg = _cp_models.get(_hyg_model, {}) + if isinstance(_cp_model_cfg, dict): + _cp_ctx = _cp_model_cfg.get("context_length") + if _cp_ctx is not None: + _hyg_config_context_length = int(_cp_ctx) + break except (TypeError, ValueError): pass except Exception: @@ -4296,7 +4301,11 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: current_provider = model_cfg.get("provider", current_provider) current_base_url = model_cfg.get("base_url", "") user_provs = cfg.get("providers") - custom_provs = cfg.get("custom_providers") + try: + from hermes_cli.config import get_compatible_custom_providers + custom_provs = get_compatible_custom_providers(cfg) + except Exception: + custom_provs = cfg.get("custom_providers") except Exception: pass -
hermes_cli/auth_commands.py@@ -36,25 +36,23 @@ def _get_custom_provider_names() -> list: - """Return list of (display_name, pool_key) tuples for custom_providers in config.""" + """Return list of (display_name, pool_key, provider_key) tuples.""" try: - from hermes_cli.config import load_config + from hermes_cli.config import get_compatible_custom_providers, load_config config = load_config() except Exception: return [] - custom_providers = config.get("custom_providers") - if not isinstance(custom_providers, list): - return [] result = [] - for entry in custom_providers: + for entry in get_compatible_custom_providers(config): if not isinstance(entry, dict): continue name = entry.get("name") if not isinstance(name, str) or not name.strip(): continue pool_key = f"{CUSTOM_POOL_PREFIX}{_normalize_custom_pool_name(name)}" - result.append((name.strip(), pool_key)) + provider_key = str(entry.get("provider_key", "") or "").strip() + result.append((name.strip(), pool_key, provider_key)) return result @@ -66,9 +64,11 @@ def _resolve_custom_provider_input(raw: str) -> str | None: # Direct match on 'custom:name' format if normalized.startswith(CUSTOM_POOL_PREFIX): return normalized - for display_name, pool_key in _get_custom_provider_names(): + for display_name, pool_key, provider_key in _get_custom_provider_names(): if _normalize_custom_pool_name(display_name) == normalized: return pool_key + if provider_key and provider_key.strip().lower() == normalized: + return pool_key return None @@ -405,7 +405,7 @@ def _pick_provider(prompt: str = "Provider") -> str: known = sorted(set(list(PROVIDER_REGISTRY.keys()) + ["openrouter"])) custom_names = _get_custom_provider_names() if custom_names: - custom_display = [name for name, _key in custom_names] + custom_display = [name for name, _key, _provider_key in custom_names] print(f"\nKnown providers: {', '.join(known)}") print(f"Custom endpoints: {', '.join(custom_display)}") else: -
hermes_cli/config.py@@ -1544,6 +1544,136 @@ def get_missing_skill_config_vars() -> List[Dict[str, Any]]: return missing +def _normalize_custom_provider_entry( + entry: Any, + *, + provider_key: str = "", +) -> Optional[Dict[str, Any]]: + """Return a runtime-compatible custom provider entry or ``None``.""" + if not isinstance(entry, dict): + return None + + base_url = "" + for url_key in ("api", "url", "base_url"): + raw_url = entry.get(url_key) + if isinstance(raw_url, str) and raw_url.strip(): + base_url = raw_url.strip() + break + if not base_url: + return None + + name = "" + raw_name = entry.get("name") + if isinstance(raw_name, str) and raw_name.strip(): + name = raw_name.strip() + elif provider_key.strip(): + name = provider_key.strip() + if not name: + return None + + normalized: Dict[str, Any] = { + "name": name, + "base_url": base_url, + } + + provider_key = provider_key.strip() + if provider_key: + normalized["provider_key"] = provider_key + + api_key = entry.get("api_key") + if isinstance(api_key, str) and api_key.strip(): + normalized["api_key"] = api_key.strip() + + key_env = entry.get("key_env") + if isinstance(key_env, str) and key_env.strip(): + normalized["key_env"] = key_env.strip() + + api_mode = entry.get("api_mode") or entry.get("transport") + if isinstance(api_mode, str) and api_mode.strip(): + normalized["api_mode"] = api_mode.strip() + + model_name = entry.get("model") or entry.get("default_model") + if isinstance(model_name, str) and model_name.strip(): + normalized["model"] = model_name.strip() + + models = entry.get("models") + if isinstance(models, dict) and models: + normalized["models"] = models + + context_length = entry.get("context_length") + if isinstance(context_length, int) and context_length > 0: + normalized["context_length"] = context_length + + rate_limit_delay = entry.get("rate_limit_delay") + if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0: + normalized["rate_limit_delay"] = rate_limit_delay + + return normalized + + +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): + return [] + + custom_providers: List[Dict[str, Any]] = [] + for key, entry in providers_dict.items(): + normalized = _normalize_custom_provider_entry(entry, provider_key=str(key)) + if normalized is not None: + custom_providers.append(normalized) + + return custom_providers + + +def get_compatible_custom_providers( + config: Optional[Dict[str, Any]] = None, +) -> List[Dict[str, Any]]: + """Return a deduplicated custom-provider view across legacy and v12+ config. + + ``custom_providers`` remains the on-disk legacy format, while ``providers`` + is the newer keyed schema. Runtime and picker flows still need a single + list-shaped view, but we should not materialise that compatibility layer + back into config.yaml because it duplicates entries in UIs. + """ + if config is None: + config = load_config() + + compatible: List[Dict[str, Any]] = [] + seen_provider_keys: set = set() + seen_name_url_pairs: set = set() + + def _append_if_new(entry: Optional[Dict[str, Any]]) -> None: + if entry is None: + return + provider_key = str(entry.get("provider_key", "") or "").strip().lower() + name = str(entry.get("name", "") or "").strip().lower() + base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower() + pair = (name, base_url) + + if provider_key and provider_key in seen_provider_keys: + return + if name and base_url and pair in seen_name_url_pairs: + return + + compatible.append(entry) + if provider_key: + seen_provider_keys.add(provider_key) + if name and base_url: + seen_name_url_pairs.add(pair) + + custom_providers = config.get("custom_providers") + if custom_providers is not None: + if not isinstance(custom_providers, list): + return [] + for entry in custom_providers: + _append_if_new(_normalize_custom_provider_entry(entry)) + + for entry in providers_dict_to_custom_providers(config.get("providers")): + _append_if_new(entry) + + return compatible + + def check_config_version() -> Tuple[int, int]: """ Check config version. @@ -1861,8 +1991,8 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A if migrated_count > 0: config["providers"] = providers_dict - # Remove the old list - del config["custom_providers"] + # Remove the old list — runtime reads via get_compatible_custom_providers() + config.pop("custom_providers", None) save_config(config) if not quiet: print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section") -
hermes_cli/main.py@@ -999,7 +999,7 @@ def select_provider_and_model(args=None): from hermes_cli.auth import ( resolve_provider, AuthError, format_auth_error, ) - from hermes_cli.config import load_config, get_env_value + from hermes_cli.config import get_compatible_custom_providers, load_config, get_env_value config = load_config() current_model = config.get("model") @@ -1090,11 +1090,8 @@ def select_provider_and_model(args=None): ] def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: - custom_providers_cfg = cfg.get("custom_providers") or [] custom_provider_map = {} - if not isinstance(custom_providers_cfg, list): - return custom_provider_map - for entry in custom_providers_cfg: + for entry in get_compatible_custom_providers(cfg): if not isinstance(entry, dict): continue name = (entry.get("name") or "").strip() @@ -1102,12 +1099,20 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: if not name or not base_url: continue key = "custom:" + name.lower().replace(" ", "-") + provider_key = (entry.get("provider_key") or "").strip() + if provider_key: + try: + resolve_provider(provider_key) + except AuthError: + key = provider_key custom_provider_map[key] = { "name": name, "base_url": base_url, "api_key": entry.get("api_key", ""), + "key_env": entry.get("key_env", ""), "model": entry.get("model", ""), "api_mode": entry.get("api_mode", ""), + "provider_key": provider_key, } return custom_provider_map @@ -1157,7 +1162,8 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: if selected_provider == "more": ext_ordered = list(extended_providers) ext_ordered.append(("custom", "Custom endpoint (enter URL manually)")) - if _custom_provider_map: + _has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(config.get("custom_providers")) + if _has_saved_custom_list: ext_ordered.append(("remove-custom", "Remove a saved custom provider")) ext_ordered.append(("cancel", "Cancel")) @@ -1184,7 +1190,7 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: _model_flow_copilot(config, current_model) elif selected_provider == "custom": _model_flow_custom(config) - elif selected_provider.startswith("custom:"): + elif selected_provider.startswith("custom:") or selected_provider in _custom_provider_map: provider_info = _named_custom_provider_map(load_config()).get(selected_provider) if provider_info is None: print( @@ -1869,7 +1875,9 @@ def _model_flow_named_custom(config, provider_info): name = provider_info["name"] base_url = provider_info["base_url"] api_key = provider_info.get("api_key", "") + key_env = provider_info.get("key_env", "") saved_model = provider_info.get("model", "") + provider_key = (provider_info.get("provider_key") or "").strip() print(f" Provider: {name}") print(f" URL: {base_url}") @@ -1952,10 +1960,15 @@ def _model_flow_named_custom(config, provider_info): if not isinstance(model, dict): model = {"default": model} if model else {} cfg["model"] = model - model["provider"] = "custom" - model["base_url"] = base_url - if api_key: - model["api_key"] = api_key + if provider_key: + model["provider"] = provider_key + model.pop("base_url", None) + model.pop("api_key", None) + else: + model["provider"] = "custom" + model["base_url"] = base_url + if api_key: + model["api_key"] = api_key # Apply api_mode from custom_providers entry, or clear stale value custom_api_mode = provider_info.get("api_mode", "") if custom_api_mode: @@ -1965,8 +1978,23 @@ def _model_flow_named_custom(config, provider_info): save_config(cfg) deactivate_provider() - # Save model name to the custom_providers entry for next time - _save_custom_provider(base_url, api_key, model_name) + # Persist the selected model back to whichever schema owns this endpoint. + if provider_key: + cfg = load_config() + providers_cfg = cfg.get("providers") + if isinstance(providers_cfg, dict): + provider_entry = providers_cfg.get(provider_key) + if isinstance(provider_entry, dict): + provider_entry["default_model"] = model_name + if api_key and not str(provider_entry.get("api_key", "") or "").strip(): + provider_entry["api_key"] = api_key + if key_env and not str(provider_entry.get("key_env", "") or "").strip(): + provider_entry["key_env"] = key_env + cfg["providers"] = providers_cfg + save_config(cfg) + else: + # Save model name to the custom_providers entry for next time + _save_custom_provider(base_url, api_key, model_name) print(f"\n✅ Model set to: {model_name}") print(f" Provider: {name} ({base_url})") -
hermes_cli/runtime_provider.py@@ -26,7 +26,7 @@ resolve_external_process_provider_credentials, has_usable_secret, ) -from hermes_cli.config import load_config +from hermes_cli.config import get_compatible_custom_providers, load_config from hermes_constants import OPENROUTER_BASE_URL @@ -276,13 +276,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An config = load_config() custom_providers = config.get("custom_providers") - if not isinstance(custom_providers, list): - if isinstance(custom_providers, dict): - logger.warning( - "custom_providers in config.yaml is a dict, not a list. " - "Each entry must be prefixed with '-' in YAML. " - "Run 'hermes doctor' for details." - ) + if isinstance(custom_providers, dict): + logger.warning( + "custom_providers in config.yaml is a dict, not a list. " + "Each entry must be prefixed with '-' in YAML. " + "Run 'hermes doctor' for details." + ) + return None + + custom_providers = get_compatible_custom_providers(config) + if not custom_providers: return None for entry in custom_providers: @@ -294,13 +297,21 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An continue name_norm = _normalize_custom_provider_name(name) menu_key = f"custom:{name_norm}" - if requested_norm not in {name_norm, menu_key}: + provider_key = str(entry.get("provider_key", "") or "").strip() + provider_key_norm = _normalize_custom_provider_name(provider_key) if provider_key else "" + provider_menu_key = f"custom:{provider_key_norm}" if provider_key_norm else "" + if requested_norm not in {name_norm, menu_key, provider_key_norm, provider_menu_key}: continue result = { "name": name.strip(), "base_url": base_url.strip(), "api_key": [redacted:secret-assignment]"api_key", "") or "").strip(), } + key_env = str(entry.get("key_env", "") or "").strip() + if key_env: + result["key_env"] = key_env + if provider_key: + result["provider_key"] = provider_key api_mode = _parse_api_mode(entry.get("api_mode")) if api_mode: result["api_mode"] = api_mode @@ -342,6 +353,7 @@ def _resolve_named_custom_runtime( api_key_candidates = [ (explicit_api_key or "").strip(), str(custom_provider.get("api_key", "") or "").strip(), + os.getenv(str(custom_provider.get("key_env", "") or "").strip(), "").strip(), os.getenv("OPENAI_API_KEY", "").strip(), os.getenv("OPENROUTER_API_KEY", "").strip(), ] -
run_agent.py@@ -1232,24 +1232,29 @@ def __init__( # Check custom_providers per-model context_length if _config_context_length is None: - _custom_providers = _agent_cfg.get("custom_providers") - if isinstance(_custom_providers, list): - for _cp_entry in _custom_providers: - if not isinstance(_cp_entry, dict): - continue - _cp_url = (_cp_entry.get("base_url") or "").rstrip("/") - if _cp_url and _cp_url == self.base_url.rstrip("/"): - _cp_models = _cp_entry.get("models", {}) - if isinstance(_cp_models, dict): - _cp_model_cfg = _cp_models.get(self.model, {}) - if isinstance(_cp_model_cfg, dict): - _cp_ctx = _cp_model_cfg.get("context_length") - if _cp_ctx is not None: - try: - _config_context_length = int(_cp_ctx) - except (TypeError, ValueError): - pass - break + try: + from hermes_cli.config import get_compatible_custom_providers + _custom_providers = get_compatible_custom_providers(_agent_cfg) + except Exception: + _custom_providers = _agent_cfg.get("custom_providers") + if not isinstance(_custom_providers, list): + _custom_providers = [] + for _cp_entry in _custom_providers: + if not isinstance(_cp_entry, dict): + continue + _cp_url = (_cp_entry.get("base_url") or "").rstrip("/") + if _cp_url and _cp_url == self.base_url.rstrip("/"): + _cp_models = _cp_entry.get("models", {}) + if isinstance(_cp_models, dict): + _cp_model_cfg = _cp_models.get(self.model, {}) + if isinstance(_cp_model_cfg, dict): + _cp_ctx = _cp_model_cfg.get("context_length") + if _cp_ctx is not None: + try: + _config_context_length = int(_cp_ctx) + except (TypeError, ValueError): + pass + break # Select context engine: config-driven (like memory providers). # 1. Check config.yaml context.engine setting -
tests/hermes_cli/test_config.py@@ -10,6 +10,7 @@ DEFAULT_CONFIG, get_hermes_home, ensure_hermes_home, + get_compatible_custom_providers, load_config, load_env, migrate_config, @@ -424,6 +425,146 @@ def test_skips_on_version_9_or_later(self, tmp_path): assert load_env().get("ANTHROPIC_TOKEN") == "current-token" +class TestCustomProviderCompatibility: + """Custom provider compatibility across legacy and v12+ config schemas.""" + + def test_v11_upgrade_moves_custom_providers_into_providers(self, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "_config_version": 11, + "model": { + "default": "openai/gpt-5.4", + "provider": "openrouter", + }, + "custom_providers": [ + { + "name": "OpenAI Direct", + "base_url": "https://api.openai.com/v1", + "api_key": "[redacted:secret-assignment]", + "api_mode": "codex_responses", + "model": "gpt-5-mini", + } + ], + "fallback_providers": [ + {"provider": "openai-direct", "model": "gpt-5-mini"} + ], + } + ), + 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")) + + assert raw["_config_version"] == 17 + assert raw["providers"]["openai-direct"] == { + "api": "https://api.openai.com/v1", + "api_key": "[redacted:secret-assignment]", + "default_model": "gpt-5-mini", + "name": "OpenAI Direct", + "transport": "codex_responses", + } + # custom_providers removed by migration — runtime reads via compat layer + assert "custom_providers" not in raw + + 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.""" + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "_config_version": 17, + "providers": { + "openai-direct": { + "api": "https://api.openai.com/v1", + "api_key": "[redacted:secret-assignment]", + "default_model": "gpt-5-mini", + "name": "OpenAI Direct", + "transport": "codex_responses", + } + }, + } + ), + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + compatible = get_compatible_custom_providers() + + assert len(compatible) == 1 + assert compatible[0]["name"] == "OpenAI Direct" + assert compatible[0]["base_url"] == "https://api.openai.com/v1" + assert compatible[0]["provider_key"] == "openai-direct" + assert compatible[0]["api_mode"] == "codex_responses" + + def test_compatible_custom_providers_prefers_api_then_url_then_base_url(self, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "_config_version": 17, + "providers": { + "my-provider": { + "name": "My Provider", + "api": "https://api.example.com/v1", + "url": "https://url.example.com/v1", + "base_url": "https://base.example.com/v1", + } + }, + } + ), + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + compatible = get_compatible_custom_providers() + + assert compatible == [ + { + "name": "My Provider", + "base_url": "https://api.example.com/v1", + "provider_key": "my-provider", + } + ] + + def test_dedup_across_legacy_and_providers(self, tmp_path): + """Same name+url in both schemas should not produce duplicates.""" + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "_config_version": 17, + "custom_providers": [ + { + "name": "OpenAI Direct", + "base_url": "https://api.openai.com/v1", + "api_key": "[redacted:secret-assignment]", + } + ], + "providers": { + "openai-direct": { + "api": "https://api.openai.com/v1", + "api_key": "[redacted:secret-assignment]", + "name": "OpenAI Direct", + } + }, + } + ), + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + compatible = get_compatible_custom_providers() + + assert len(compatible) == 1 + # Legacy entry wins (read first) + assert compatible[0]["api_key"] == "legacy-key" + + class TestInterimAssistantMessageConfig: """Test the explicit gateway interim-message config gate.""" @@ -441,6 +582,6 @@ def test_migrate_to_v15_adds_interim_assistant_message_gate(self, tmp_path): migrate_config(interactive=False, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert raw["_config_version"] == 16 + assert raw["_config_version"] == 17 assert raw["display"]["tool_progress"] == "off" assert raw["display"]["interim_assistant_messages"] is True -
tests/hermes_cli/test_runtime_provider_resolution.py@@ -119,6 +119,11 @@ def has_credentials(self): def test_resolve_runtime_provider_codex(monkeypatch): + monkeypatch.setattr( + rp, + "load_pool", + lambda provider: type("P", (), {"has_credentials": lambda self: False})(), + ) monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex") monkeypatch.setattr( rp, @@ -567,6 +572,87 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch): assert resolved["source"] == "custom_provider:Local" +def test_named_custom_provider_uses_providers_dict_when_list_missing(monkeypatch): + """After v11→v12 migration deletes custom_providers, resolution should + still find entries in the providers dict via get_compatible_custom_providers.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "openai-direct-primary": { + "api": "https://api.openai.com/v1", + "api_key": "[redacted:secret-assignment]", + "default_model": "gpt-5-mini", + "name": "OpenAI Direct (Primary)", + "transport": "codex_responses", + } + } + }, + ) + monkeypatch.setattr( + rp, + "resolve_provider", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError( + "resolve_provider should not be called for named custom providers" + ) + ), + ) + + resolved = rp.resolve_runtime_provider(requested="openai-direct-primary") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "codex_responses" + assert resolved["base_url"] == "https://api.openai.com/v1" + assert resolved["api_key"] == "dir-key" + assert resolved["requested_provider"] == "openai-direct-primary" + assert resolved["source"] == "custom_provider:OpenAI Direct (Primary)" + assert resolved["model"] == "gpt-5-mini" + + +def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch): + """providers dict entries with key_env should resolve API key from env var.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setenv("MYCORP_API_KEY", "env-secret") + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "mycorp-proxy": { + "base_url": "https://proxy.example.com/v1", + "default_model": "acme-large", + "key_env": "MYCORP_API_KEY", + "name": "MyCorp Proxy", + } + } + }, + ) + monkeypatch.setattr( + rp, + "resolve_provider", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError( + "resolve_provider should not be called for named custom providers" + ) + ), + ) + + resolved = rp.resolve_runtime_provider(requested="mycorp-proxy") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "chat_completions" + assert resolved["base_url"] == "https://proxy.example.com/v1" + assert resolved["api_key"] == "env-secret" + assert resolved["requested_provider"] == "mycorp-proxy" + assert resolved["source"] == "custom_provider:MyCorp Proxy" + assert resolved["model"] == "acme-large" + + def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key") monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) -
tests/tools/test_browser_camofox_state.py@@ -64,4 +64,4 @@ def test_config_version_matches_current_schema(self): # The current schema version is tracked globally; unrelated default # options may bump it after browser defaults are added. - assert DEFAULT_CONFIG["_config_version"] == 15 + assert DEFAULT_CONFIG["_config_version"] == 17
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#8814 fix(config): restore custom providers after v11→v12 migration · merged via another change →
#9016,#8776
Closed issues · 1
- Closed#8776v11→v12 config migration converts custom_providers → providers: dict, but no runtime code reads providers:
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means 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 LabAt a glance
- Merged PRs
- #9016
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 13 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.