# NousResearch/hermes-agent · v11→v12 config migration custom providers bug

## Open now

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

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #9016. Verify it addresses your case.
- **Recommended:** read PR #9016 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-9016`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #8776: v11→v12 config migration converts custom_providers → providers: dict, but no runtime code reads providers:

## Prior & competing PRs

- **#9016: fix(config): restore custom providers after v11→v12 migration**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
     
     
    ```

    ```diff
    @@ -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
     
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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"))
     
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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}")
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
     
     
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -10,6 +10,7 @@
         DEFAULT_CONFIG,
         get_hermes_home,
         ensure_hermes_home,
    +    get_compatible_custom_providers,
         load_config,
         load_env,
         migrate_config,
    ```

    ```diff
    @@ -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."""
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#8814: fix(config): restore custom providers after v11→v12 migration**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#9016`, `#8776`

## Fix status

- Merged to main in PR #9016 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-13`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
