{"repo":"NousResearch/hermes-agent","public_id":"8776","display_label":"v11→v12 config migration custom providers bug","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":9016,"merged_at":"2026-04-13T17:50:52+00:00"},"resolution":{"state":"resolved","by":"pr-9016"},"freshness":{"state":"stale","last_verified_at":"2026-04-13T17:50:52+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":8776,"pr_number":9016,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-13T17:50:52+00:00"}],"members":[{"kind":"issue","number":8776,"title":"v11→v12 config migration converts custom_providers → providers: dict, but no runtime code reads providers:","url":null,"state":"closed"},{"kind":"pr","number":8814,"title":"fix(config): restore custom providers after v11→v12 migration","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9016","number":9016},{"kind":"pr","value":"#8776","number":8776}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":9016,"title":"fix(config): restore custom providers after v11→v12 migration","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1438,10 +1438,14 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n         custom_entry = _get_named_custom_provider(provider)\n         if custom_entry:\n             custom_base = custom_entry.get(\"base_url\", \"\").strip()\n-            custom_key = custom_entry.get(\"api_key\", \"\").strip() or \"no-key-required\"\n+            custom_key = custom_entry.get(\"api_key\", \"\").strip()\n+            custom_key_env = custom_entry.get(\"key_env\", \"\").strip()\n+            if not custom_key and custom_key_env:\n+                custom_key = os.getenv(custom_key_env, \"\").strip()\n+            custom_key = custom_key or \"no-key-required\"\n             if custom_base:\n                 final_model = _normalize_resolved_model(\n-                    model or _read_main_model() or \"gpt-4o-mini\",\n+                    model or custom_entry.get(\"model\") or _read_main_model() or \"gpt-4o-mini\",\n                     provider,\n                 )\n                 client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)"]},{"file":"agent/credential_pool.py","hunks":["@@ -289,6 +289,14 @@ def _iter_custom_providers(config: Optional[dict] = None):\n         return\n     custom_providers = config.get(\"custom_providers\")\n     if not isinstance(custom_providers, list):\n+        # Fall back to the v12+ providers dict via the compatibility layer\n+        try:\n+            from hermes_cli.config import get_compatible_custom_providers\n+\n+            custom_providers = get_compatible_custom_providers(config)\n+        except Exception:\n+            return\n+    if not custom_providers:\n         return\n     for entry in custom_providers:\n         if not isinstance(entry, dict):"]},{"file":"cli.py","hunks":["@@ -4710,10 +4710,10 @@ def _handle_model_switch(self, cmd_original: str):\n             user_provs = None\n             custom_provs = None\n             try:\n-                from hermes_cli.config import load_config\n+                from hermes_cli.config import get_compatible_custom_providers, load_config\n                 cfg = load_config()\n                 user_provs = cfg.get(\"providers\")\n-                custom_provs = cfg.get(\"custom_providers\")\n+                custom_provs = get_compatible_custom_providers(cfg)\n             except Exception:\n                 pass\n "]},{"file":"gateway/run.py","hunks":["@@ -3330,21 +3330,26 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n                 # Must run after runtime resolution so _hyg_base_url is set.\n                 if _hyg_config_context_length is None and _hyg_base_url:\n                     try:\n-                        _hyg_custom_providers = _hyg_data.get(\"custom_providers\")\n-                        if isinstance(_hyg_custom_providers, list):\n-                            for _cp in _hyg_custom_providers:\n-                                if not isinstance(_cp, dict):\n-                                    continue\n-                                _cp_url = (_cp.get(\"base_url\") or \"\").rstrip(\"/\")\n-                                if _cp_url and _cp_url == _hyg_base_url.rstrip(\"/\"):\n-                                    _cp_models = _cp.get(\"models\", {})\n-                                    if isinstance(_cp_models, dict):\n-                                        _cp_model_cfg = _cp_models.get(_hyg_model, {})\n-                                        if isinstance(_cp_model_cfg, dict):\n-                                            _cp_ctx = _cp_model_cfg.get(\"context_length\")\n-                                            if _cp_ctx is not None:\n-                                                _hyg_config_context_length = int(_cp_ctx)\n-                                    break\n+                        try:\n+                            from hermes_cli.config import get_compatible_custom_providers as _gw_gcp\n+                            _hyg_custom_providers = _gw_gcp(_hyg_data)\n+                        except Exception:\n+                            _hyg_custom_providers = _hyg_data.get(\"custom_providers\")\n+                            if not isinstance(_hyg_custom_providers, list):\n+                                _hyg_custom_providers = []\n+                        for _cp in _hyg_custom_providers:\n+                            if not isinstance(_cp, dict):\n+                                continue\n+                            _cp_url = (_cp.get(\"base_url\") or \"\").rstrip(\"/\")\n+                            if _cp_url and _cp_url == _hyg_base_url.rstrip(\"/\"):\n+                                _cp_models = _cp.get(\"models\", {})\n+                                if isinstance(_cp_models, dict):\n+                                    _cp_model_cfg = _cp_models.get(_hyg_model, {})\n+                                    if isinstance(_cp_model_cfg, dict):\n+                                        _cp_ctx = _cp_model_cfg.get(\"context_length\")\n+                                        if _cp_ctx is not None:\n+                                            _hyg_config_context_length = int(_cp_ctx)\n+                                break\n                     except (TypeError, ValueError):\n                         pass\n             except Exception:","@@ -4296,7 +4301,11 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n                     current_provider = model_cfg.get(\"provider\", current_provider)\n                     current_base_url = model_cfg.get(\"base_url\", \"\")\n                 user_provs = cfg.get(\"providers\")\n-                custom_provs = cfg.get(\"custom_providers\")\n+                try:\n+                    from hermes_cli.config import get_compatible_custom_providers\n+                    custom_provs = get_compatible_custom_providers(cfg)\n+                except Exception:\n+                    custom_provs = cfg.get(\"custom_providers\")\n         except Exception:\n             pass\n "]},{"file":"hermes_cli/auth_commands.py","hunks":["@@ -36,25 +36,23 @@\n \n \n def _get_custom_provider_names() -> list:\n-    \"\"\"Return list of (display_name, pool_key) tuples for custom_providers in config.\"\"\"\n+    \"\"\"Return list of (display_name, pool_key, provider_key) tuples.\"\"\"\n     try:\n-        from hermes_cli.config import load_config\n+        from hermes_cli.config import get_compatible_custom_providers, load_config\n \n         config = load_config()\n     except Exception:\n         return []\n-    custom_providers = config.get(\"custom_providers\")\n-    if not isinstance(custom_providers, list):\n-        return []\n     result = []\n-    for entry in custom_providers:\n+    for entry in get_compatible_custom_providers(config):\n         if not isinstance(entry, dict):\n             continue\n         name = entry.get(\"name\")\n         if not isinstance(name, str) or not name.strip():\n             continue\n         pool_key = f\"{CUSTOM_POOL_PREFIX}{_normalize_custom_pool_name(name)}\"\n-        result.append((name.strip(), pool_key))\n+        provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip()\n+        result.append((name.strip(), pool_key, provider_key))\n     return result\n \n ","@@ -66,9 +64,11 @@ def _resolve_custom_provider_input(raw: str) -> str | None:\n     # Direct match on 'custom:name' format\n     if normalized.startswith(CUSTOM_POOL_PREFIX):\n         return normalized\n-    for display_name, pool_key in _get_custom_provider_names():\n+    for display_name, pool_key, provider_key in _get_custom_provider_names():\n         if _normalize_custom_pool_name(display_name) == normalized:\n             return pool_key\n+        if provider_key and provider_key.strip().lower() == normalized:\n+            return pool_key\n     return None\n \n ","@@ -405,7 +405,7 @@ def _pick_provider(prompt: str = \"Provider\") -> str:\n     known = sorted(set(list(PROVIDER_REGISTRY.keys()) + [\"openrouter\"]))\n     custom_names = _get_custom_provider_names()\n     if custom_names:\n-        custom_display = [name for name, _key in custom_names]\n+        custom_display = [name for name, _key, _provider_key in custom_names]\n         print(f\"\\nKnown providers: {', '.join(known)}\")\n         print(f\"Custom endpoints: {', '.join(custom_display)}\")\n     else:"]},{"file":"hermes_cli/config.py","hunks":["@@ -1544,6 +1544,136 @@ def get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n     return missing\n \n \n+def _normalize_custom_provider_entry(\n+    entry: Any,\n+    *,\n+    provider_key: str = \"\",\n+) -> Optional[Dict[str, Any]]:\n+    \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n+    if not isinstance(entry, dict):\n+        return None\n+\n+    base_url = \"\"\n+    for url_key in (\"api\", \"url\", \"base_url\"):\n+        raw_url = entry.get(url_key)\n+        if isinstance(raw_url, str) and raw_url.strip():\n+            base_url = raw_url.strip()\n+            break\n+    if not base_url:\n+        return None\n+\n+    name = \"\"\n+    raw_name = entry.get(\"name\")\n+    if isinstance(raw_name, str) and raw_name.strip():\n+        name = raw_name.strip()\n+    elif provider_key.strip():\n+        name = provider_key.strip()\n+    if not name:\n+        return None\n+\n+    normalized: Dict[str, Any] = {\n+        \"name\": name,\n+        \"base_url\": base_url,\n+    }\n+\n+    provider_key = provider_key.strip()\n+    if provider_key:\n+        normalized[\"provider_key\"] = provider_key\n+\n+    api_key = entry.get(\"api_key\")\n+    if isinstance(api_key, str) and api_key.strip():\n+        normalized[\"api_key\"] = api_key.strip()\n+\n+    key_env = entry.get(\"key_env\")\n+    if isinstance(key_env, str) and key_env.strip():\n+        normalized[\"key_env\"] = key_env.strip()\n+\n+    api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n+    if isinstance(api_mode, str) and api_mode.strip():\n+        normalized[\"api_mode\"] = api_mode.strip()\n+\n+    model_name = entry.get(\"model\") or entry.get(\"default_model\")\n+    if isinstance(model_name, str) and model_name.strip():\n+        normalized[\"model\"] = model_name.strip()\n+\n+    models = entry.get(\"models\")\n+    if isinstance(models, dict) and models:\n+        normalized[\"models\"] = models\n+\n+    context_length = entry.get(\"context_length\")\n+    if isinstance(context_length, int) and context_length > 0:\n+        normalized[\"context_length\"] = context_length\n+\n+    rate_limit_delay = entry.get(\"rate_limit_delay\")\n+    if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n+        normalized[\"rate_limit_delay\"] = rate_limit_delay\n+\n+    return normalized\n+\n+\n+def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n+    \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n+    if not isinstance(providers_dict, dict):\n+        return []\n+\n+    custom_providers: List[Dict[str, Any]] = []\n+    for key, entry in providers_dict.items():\n+        normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n+        if normalized is not None:\n+            custom_providers.append(normalized)\n+\n+    return custom_providers\n+\n+\n+def get_compatible_custom_providers(\n+    config: Optional[Dict[str, Any]] = None,\n+) -> List[Dict[str, Any]]:\n+    \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n+\n+    ``custom_providers`` remains the on-disk legacy format, while ``providers``\n+    is the newer keyed schema.  Runtime and picker flows still need a single\n+    list-shaped view, but we should not materialise that compatibility layer\n+    back into config.yaml because it duplicates entries in UIs.\n+    \"\"\"\n+    if config is None:\n+        config = load_config()\n+\n+    compatible: List[Dict[str, Any]] = []\n+    seen_provider_keys: set = set()\n+    seen_name_url_pairs: set = set()\n+\n+    def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n+        if entry is None:\n+            return\n+        provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n+        name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n+        base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n+        pair = (name, base_url)\n+\n+        if provider_key and provider_key in seen_provider_keys:\n+            return\n+        if name and base_url and pair in seen_name_url_pairs:\n+            return\n+\n+        compatible.append(entry)\n+        if provider_key:\n+            seen_provider_keys.add(provider_key)\n+        if name and base_url:\n+            seen_name_url_pairs.add(pair)\n+\n+    custom_providers = config.get(\"custom_providers\")\n+    if custom_providers is not None:\n+        if not isinstance(custom_providers, list):\n+            return []\n+        for entry in custom_providers:\n+            _append_if_new(_normalize_custom_provider_entry(entry))\n+\n+    for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n+        _append_if_new(entry)\n+\n+    return compatible\n+\n+\n def check_config_version() -> Tuple[int, int]:\n     \"\"\"\n     Check config version.","@@ -1861,8 +1991,8 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A\n \n             if migrated_count > 0:\n                 config[\"providers\"] = providers_dict\n-                # Remove the old list\n-                del config[\"custom_providers\"]\n+                # Remove the old list — runtime reads via get_compatible_custom_providers()\n+                config.pop(\"custom_providers\", None)\n                 save_config(config)\n                 if not quiet:\n                     print(f\"  ✓ Migrated {migrated_count} custom provider(s) to providers: section\")"]},{"file":"hermes_cli/main.py","hunks":["@@ -999,7 +999,7 @@ def select_provider_and_model(args=None):\n     from hermes_cli.auth import (\n         resolve_provider, AuthError, format_auth_error,\n     )\n-    from hermes_cli.config import load_config, get_env_value\n+    from hermes_cli.config import get_compatible_custom_providers, load_config, get_env_value\n \n     config = load_config()\n     current_model = config.get(\"model\")","@@ -1090,11 +1090,8 @@ def select_provider_and_model(args=None):\n     ]\n \n     def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:\n-        custom_providers_cfg = cfg.get(\"custom_providers\") or []\n         custom_provider_map = {}\n-        if not isinstance(custom_providers_cfg, list):\n-            return custom_provider_map\n-        for entry in custom_providers_cfg:\n+        for entry in get_compatible_custom_providers(cfg):\n             if not isinstance(entry, dict):\n                 continue\n             name = (entry.get(\"name\") or \"\").strip()","@@ -1102,12 +1099,20 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:\n             if not name or not base_url:\n                 continue\n             key = \"custom:\" + name.lower().replace(\" \", \"-\")\n+            provider_key = (entry.get(\"provider_key\") or \"\").strip()\n+            if provider_key:\n+                try:\n+                    resolve_provider(provider_key)\n+                except AuthError:\n+                    key = provider_key\n             custom_provider_map[key] = {\n                 \"name\": name,\n                 \"base_url\": base_url,\n                 \"api_key\": entry.get(\"api_key\", \"\"),\n+                \"key_env\": entry.get(\"key_env\", \"\"),\n                 \"model\": entry.get(\"model\", \"\"),\n                 \"api_mode\": entry.get(\"api_mode\", \"\"),\n+                \"provider_key\": provider_key,\n             }\n         return custom_provider_map\n ","@@ -1157,7 +1162,8 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:\n     if selected_provider == \"more\":\n         ext_ordered = list(extended_providers)\n         ext_ordered.append((\"custom\", \"Custom endpoint (enter URL manually)\"))\n-        if _custom_provider_map:\n+        _has_saved_custom_list = isinstance(config.get(\"custom_providers\"), list) and bool(config.get(\"custom_providers\"))\n+        if _has_saved_custom_list:\n             ext_ordered.append((\"remove-custom\", \"Remove a saved custom provider\"))\n         ext_ordered.append((\"cancel\", \"Cancel\"))\n ","@@ -1184,7 +1190,7 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:\n         _model_flow_copilot(config, current_model)\n     elif selected_provider == \"custom\":\n         _model_flow_custom(config)\n-    elif selected_provider.startswith(\"custom:\"):\n+    elif selected_provider.startswith(\"custom:\") or selected_provider in _custom_provider_map:\n         provider_info = _named_custom_provider_map(load_config()).get(selected_provider)\n         if provider_info is None:\n             print(","@@ -1869,7 +1875,9 @@ def _model_flow_named_custom(config, provider_info):\n     name = provider_info[\"name\"]\n     base_url = provider_info[\"base_url\"]\n     api_key = provider_info.get(\"api_key\", \"\")\n+    key_env = provider_info.get(\"key_env\", \"\")\n     saved_model = provider_info.get(\"model\", \"\")\n+    provider_key = (provider_info.get(\"provider_key\") or \"\").strip()\n \n     print(f\"  Provider: {name}\")\n     print(f\"  URL:      {base_url}\")","@@ -1952,10 +1960,15 @@ def _model_flow_named_custom(config, provider_info):\n     if not isinstance(model, dict):\n         model = {\"default\": model} if model else {}\n         cfg[\"model\"] = model\n-    model[\"provider\"] = \"custom\"\n-    model[\"base_url\"] = base_url\n-    if api_key:\n-        model[\"api_key\"] = api_key\n+    if provider_key:\n+        model[\"provider\"] = provider_key\n+        model.pop(\"base_url\", None)\n+        model.pop(\"api_key\", None)\n+    else:\n+        model[\"provider\"] = \"custom\"\n+        model[\"base_url\"] = base_url\n+        if api_key:\n+            model[\"api_key\"] = api_key\n     # Apply api_mode from custom_providers entry, or clear stale value\n     custom_api_mode = provider_info.get(\"api_mode\", \"\")\n     if custom_api_mode:","@@ -1965,8 +1978,23 @@ def _model_flow_named_custom(config, provider_info):\n     save_config(cfg)\n     deactivate_provider()\n \n-    # Save model name to the custom_providers entry for next time\n-    _save_custom_provider(base_url, api_key, model_name)\n+    # Persist the selected model back to whichever schema owns this endpoint.\n+    if provider_key:\n+        cfg = load_config()\n+        providers_cfg = cfg.get(\"providers\")\n+        if isinstance(providers_cfg, dict):\n+            provider_entry = providers_cfg.get(provider_key)\n+            if isinstance(provider_entry, dict):\n+                provider_entry[\"default_model\"] = model_name\n+                if api_key and not str(provider_entry.get(\"api_key\", \"\") or \"\").strip():\n+                    provider_entry[\"api_key\"] = api_key\n+                if key_env and not str(provider_entry.get(\"key_env\", \"\") or \"\").strip():\n+                    provider_entry[\"key_env\"] = key_env\n+                cfg[\"providers\"] = providers_cfg\n+                save_config(cfg)\n+    else:\n+        # Save model name to the custom_providers entry for next time\n+        _save_custom_provider(base_url, api_key, model_name)\n \n     print(f\"\\n✅ Model set to: {model_name}\")\n     print(f\"   Provider: {name} ({base_url})\")"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -26,7 +26,7 @@\n     resolve_external_process_provider_credentials,\n     has_usable_secret,\n )\n-from hermes_cli.config import load_config\n+from hermes_cli.config import get_compatible_custom_providers, load_config\n from hermes_constants import OPENROUTER_BASE_URL\n \n ","@@ -276,13 +276,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An\n \n     config = load_config()\n     custom_providers = config.get(\"custom_providers\")\n-    if not isinstance(custom_providers, list):\n-        if isinstance(custom_providers, dict):\n-            logger.warning(\n-                \"custom_providers in config.yaml is a dict, not a list. \"\n-                \"Each entry must be prefixed with '-' in YAML. \"\n-                \"Run 'hermes doctor' for details.\"\n-            )\n+    if isinstance(custom_providers, dict):\n+        logger.warning(\n+            \"custom_providers in config.yaml is a dict, not a list. \"\n+            \"Each entry must be prefixed with '-' in YAML. \"\n+            \"Run 'hermes doctor' for details.\"\n+        )\n+        return None\n+\n+    custom_providers = get_compatible_custom_providers(config)\n+    if not custom_providers:\n         return None\n \n     for entry in custom_providers:","@@ -294,13 +297,21 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An\n             continue\n         name_norm = _normalize_custom_provider_name(name)\n         menu_key = f\"custom:{name_norm}\"\n-        if requested_norm not in {name_norm, menu_key}:\n+        provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip()\n+        provider_key_norm = _normalize_custom_provider_name(provider_key) if provider_key else \"\"\n+        provider_menu_key = f\"custom:{provider_key_norm}\" if provider_key_norm else \"\"\n+        if requested_norm not in {name_norm, menu_key, provider_key_norm, provider_menu_key}:\n             continue\n         result = {\n             \"name\": name.strip(),\n             \"base_url\": base_url.strip(),\n             \"api_key\": [redacted:secret-assignment]\"api_key\", \"\") or \"\").strip(),\n         }\n+        key_env = str(entry.get(\"key_env\", \"\") or \"\").strip()\n+        if key_env:\n+            result[\"key_env\"] = key_env\n+        if provider_key:\n+            result[\"provider_key\"] = provider_key\n         api_mode = _parse_api_mode(entry.get(\"api_mode\"))\n         if api_mode:\n             result[\"api_mode\"] = api_mode","@@ -342,6 +353,7 @@ def _resolve_named_custom_runtime(\n     api_key_candidates = [\n         (explicit_api_key or \"\").strip(),\n         str(custom_provider.get(\"api_key\", \"\") or \"\").strip(),\n+        os.getenv(str(custom_provider.get(\"key_env\", \"\") or \"\").strip(), \"\").strip(),\n         os.getenv(\"OPENAI_API_KEY\", \"\").strip(),\n         os.getenv(\"OPENROUTER_API_KEY\", \"\").strip(),\n     ]"]},{"file":"run_agent.py","hunks":["@@ -1232,24 +1232,29 @@ def __init__(\n \n         # Check custom_providers per-model context_length\n         if _config_context_length is None:\n-            _custom_providers = _agent_cfg.get(\"custom_providers\")\n-            if isinstance(_custom_providers, list):\n-                for _cp_entry in _custom_providers:\n-                    if not isinstance(_cp_entry, dict):\n-                        continue\n-                    _cp_url = (_cp_entry.get(\"base_url\") or \"\").rstrip(\"/\")\n-                    if _cp_url and _cp_url == self.base_url.rstrip(\"/\"):\n-                        _cp_models = _cp_entry.get(\"models\", {})\n-                        if isinstance(_cp_models, dict):\n-                            _cp_model_cfg = _cp_models.get(self.model, {})\n-                            if isinstance(_cp_model_cfg, dict):\n-                                _cp_ctx = _cp_model_cfg.get(\"context_length\")\n-                                if _cp_ctx is not None:\n-                                    try:\n-                                        _config_context_length = int(_cp_ctx)\n-                                    except (TypeError, ValueError):\n-                                        pass\n-                        break\n+            try:\n+                from hermes_cli.config import get_compatible_custom_providers\n+                _custom_providers = get_compatible_custom_providers(_agent_cfg)\n+            except Exception:\n+                _custom_providers = _agent_cfg.get(\"custom_providers\")\n+                if not isinstance(_custom_providers, list):\n+                    _custom_providers = []\n+            for _cp_entry in _custom_providers:\n+                if not isinstance(_cp_entry, dict):\n+                    continue\n+                _cp_url = (_cp_entry.get(\"base_url\") or \"\").rstrip(\"/\")\n+                if _cp_url and _cp_url == self.base_url.rstrip(\"/\"):\n+                    _cp_models = _cp_entry.get(\"models\", {})\n+                    if isinstance(_cp_models, dict):\n+                        _cp_model_cfg = _cp_models.get(self.model, {})\n+                        if isinstance(_cp_model_cfg, dict):\n+                            _cp_ctx = _cp_model_cfg.get(\"context_length\")\n+                            if _cp_ctx is not None:\n+                                try:\n+                                    _config_context_length = int(_cp_ctx)\n+                                except (TypeError, ValueError):\n+                                    pass\n+                    break\n         \n         # Select context engine: config-driven (like memory providers).\n         # 1. Check config.yaml context.engine setting"]},{"file":"tests/hermes_cli/test_config.py","hunks":["@@ -10,6 +10,7 @@\n     DEFAULT_CONFIG,\n     get_hermes_home,\n     ensure_hermes_home,\n+    get_compatible_custom_providers,\n     load_config,\n     load_env,\n     migrate_config,","@@ -424,6 +425,146 @@ def test_skips_on_version_9_or_later(self, tmp_path):\n             assert load_env().get(\"ANTHROPIC_TOKEN\") == \"current-token\"\n \n \n+class TestCustomProviderCompatibility:\n+    \"\"\"Custom provider compatibility across legacy and v12+ config schemas.\"\"\"\n+\n+    def test_v11_upgrade_moves_custom_providers_into_providers(self, tmp_path):\n+        config_path = tmp_path / \"config.yaml\"\n+        config_path.write_text(\n+            yaml.safe_dump(\n+                {\n+                    \"_config_version\": 11,\n+                    \"model\": {\n+                        \"default\": \"openai/gpt-5.4\",\n+                        \"provider\": \"openrouter\",\n+                    },\n+                    \"custom_providers\": [\n+                        {\n+                            \"name\": \"OpenAI Direct\",\n+                            \"base_url\": \"https://api.openai.com/v1\",\n+                            \"api_key\": \"[redacted:secret-assignment]\",\n+                            \"api_mode\": \"codex_responses\",\n+                            \"model\": \"gpt-5-mini\",\n+                        }\n+                    ],\n+                    \"fallback_providers\": [\n+                        {\"provider\": \"openai-direct\", \"model\": \"gpt-5-mini\"}\n+                    ],\n+                }\n+            ),\n+            encoding=\"utf-8\",\n+        )\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            migrate_config(interactive=False, quiet=True)\n+            raw = yaml.safe_load(config_path.read_text(encoding=\"utf-8\"))\n+\n+        assert raw[\"_config_version\"] == 17\n+        assert raw[\"providers\"][\"openai-direct\"] == {\n+            \"api\": \"https://api.openai.com/v1\",\n+            \"api_key\": \"[redacted:secret-assignment]\",\n+            \"default_model\": \"gpt-5-mini\",\n+            \"name\": \"OpenAI Direct\",\n+            \"transport\": \"codex_responses\",\n+        }\n+        # custom_providers removed by migration — runtime reads via compat layer\n+        assert \"custom_providers\" not in raw\n+\n+    def test_providers_dict_resolves_at_runtime(self, tmp_path):\n+        \"\"\"After migration deleted custom_providers, get_compatible_custom_providers\n+        still finds entries from the providers dict.\"\"\"\n+        config_path = tmp_path / \"config.yaml\"\n+        config_path.write_text(\n+            yaml.safe_dump(\n+                {\n+                    \"_config_version\": 17,\n+                    \"providers\": {\n+                        \"openai-direct\": {\n+                            \"api\": \"https://api.openai.com/v1\",\n+                            \"api_key\": \"[redacted:secret-assignment]\",\n+                            \"default_model\": \"gpt-5-mini\",\n+                            \"name\": \"OpenAI Direct\",\n+                            \"transport\": \"codex_responses\",\n+                        }\n+                    },\n+                }\n+            ),\n+            encoding=\"utf-8\",\n+        )\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            compatible = get_compatible_custom_providers()\n+\n+        assert len(compatible) == 1\n+        assert compatible[0][\"name\"] == \"OpenAI Direct\"\n+        assert compatible[0][\"base_url\"] == \"https://api.openai.com/v1\"\n+        assert compatible[0][\"provider_key\"] == \"openai-direct\"\n+        assert compatible[0][\"api_mode\"] == \"codex_responses\"\n+\n+    def test_compatible_custom_providers_prefers_api_then_url_then_base_url(self, tmp_path):\n+        config_path = tmp_path / \"config.yaml\"\n+        config_path.write_text(\n+            yaml.safe_dump(\n+                {\n+                    \"_config_version\": 17,\n+                    \"providers\": {\n+                        \"my-provider\": {\n+                            \"name\": \"My Provider\",\n+                            \"api\": \"https://api.example.com/v1\",\n+                            \"url\": \"https://url.example.com/v1\",\n+                            \"base_url\": \"https://base.example.com/v1\",\n+                        }\n+                    },\n+                }\n+            ),\n+            encoding=\"utf-8\",\n+        )\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            compatible = get_compatible_custom_providers()\n+\n+        assert compatible == [\n+            {\n+                \"name\": \"My Provider\",\n+                \"base_url\": \"https://api.example.com/v1\",\n+                \"provider_key\": \"my-provider\",\n+            }\n+        ]\n+\n+    def test_dedup_across_legacy_and_providers(self, tmp_path):\n+        \"\"\"Same name+url in both schemas should not produce duplicates.\"\"\"\n+        config_path = tmp_path / \"config.yaml\"\n+        config_path.write_text(\n+            yaml.safe_dump(\n+                {\n+                    \"_config_version\": 17,\n+                    \"custom_providers\": [\n+                        {\n+                            \"name\": \"OpenAI Direct\",\n+                            \"base_url\": \"https://api.openai.com/v1\",\n+                            \"api_key\": \"[redacted:secret-assignment]\",\n+                        }\n+                    ],\n+                    \"providers\": {\n+                        \"openai-direct\": {\n+                            \"api\": \"https://api.openai.com/v1\",\n+                            \"api_key\": \"[redacted:secret-assignment]\",\n+                            \"name\": \"OpenAI Direct\",\n+                        }\n+                    },\n+                }\n+            ),\n+            encoding=\"utf-8\",\n+        )\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            compatible = get_compatible_custom_providers()\n+\n+        assert len(compatible) == 1\n+        # Legacy entry wins (read first)\n+        assert compatible[0][\"api_key\"] == \"legacy-key\"\n+\n+\n class TestInterimAssistantMessageConfig:\n     \"\"\"Test the explicit gateway interim-message config gate.\"\"\"\n ","@@ -441,6 +582,6 @@ def test_migrate_to_v15_adds_interim_assistant_message_gate(self, tmp_path):\n             migrate_config(interactive=False, quiet=True)\n             raw = yaml.safe_load(config_path.read_text(encoding=\"utf-8\"))\n \n-        assert raw[\"_config_version\"] == 16\n+        assert raw[\"_config_version\"] == 17\n         assert raw[\"display\"][\"tool_progress\"] == \"off\"\n         assert raw[\"display\"][\"interim_assistant_messages\"] is True"]},{"file":"tests/hermes_cli/test_runtime_provider_resolution.py","hunks":["@@ -119,6 +119,11 @@ def has_credentials(self):\n \n \n def test_resolve_runtime_provider_codex(monkeypatch):\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_pool\",\n+        lambda provider: type(\"P\", (), {\"has_credentials\": lambda self: False})(),\n+    )\n     monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"openai-codex\")\n     monkeypatch.setattr(\n         rp,","@@ -567,6 +572,87 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch):\n     assert resolved[\"source\"] == \"custom_provider:Local\"\n \n \n+def test_named_custom_provider_uses_providers_dict_when_list_missing(monkeypatch):\n+    \"\"\"After v11→v12 migration deletes custom_providers, resolution should\n+    still find entries in the providers dict via get_compatible_custom_providers.\"\"\"\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"providers\": {\n+                \"openai-direct-primary\": {\n+                    \"api\": \"https://api.openai.com/v1\",\n+                    \"api_key\": \"[redacted:secret-assignment]\",\n+                    \"default_model\": \"gpt-5-mini\",\n+                    \"name\": \"OpenAI Direct (Primary)\",\n+                    \"transport\": \"codex_responses\",\n+                }\n+            }\n+        },\n+    )\n+    monkeypatch.setattr(\n+        rp,\n+        \"resolve_provider\",\n+        lambda *a, **k: (_ for _ in ()).throw(\n+            AssertionError(\n+                \"resolve_provider should not be called for named custom providers\"\n+            )\n+        ),\n+    )\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"openai-direct-primary\")\n+\n+    assert resolved[\"provider\"] == \"custom\"\n+    assert resolved[\"api_mode\"] == \"codex_responses\"\n+    assert resolved[\"base_url\"] == \"https://api.openai.com/v1\"\n+    assert resolved[\"api_key\"] == \"dir-key\"\n+    assert resolved[\"requested_provider\"] == \"openai-direct-primary\"\n+    assert resolved[\"source\"] == \"custom_provider:OpenAI Direct (Primary)\"\n+    assert resolved[\"model\"] == \"gpt-5-mini\"\n+\n+\n+def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch):\n+    \"\"\"providers dict entries with key_env should resolve API key from env var.\"\"\"\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+    monkeypatch.setenv(\"MYCORP_API_KEY\", \"env-secret\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"providers\": {\n+                \"mycorp-proxy\": {\n+                    \"base_url\": \"https://proxy.example.com/v1\",\n+                    \"default_model\": \"acme-large\",\n+                    \"key_env\": \"MYCORP_API_KEY\",\n+                    \"name\": \"MyCorp Proxy\",\n+                }\n+            }\n+        },\n+    )\n+    monkeypatch.setattr(\n+        rp,\n+        \"resolve_provider\",\n+        lambda *a, **k: (_ for _ in ()).throw(\n+            AssertionError(\n+                \"resolve_provider should not be called for named custom providers\"\n+            )\n+        ),\n+    )\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"mycorp-proxy\")\n+\n+    assert resolved[\"provider\"] == \"custom\"\n+    assert resolved[\"api_mode\"] == \"chat_completions\"\n+    assert resolved[\"base_url\"] == \"https://proxy.example.com/v1\"\n+    assert resolved[\"api_key\"] == \"env-secret\"\n+    assert resolved[\"requested_provider\"] == \"mycorp-proxy\"\n+    assert resolved[\"source\"] == \"custom_provider:MyCorp Proxy\"\n+    assert resolved[\"model\"] == \"acme-large\"\n+\n+\n def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch):\n     monkeypatch.setenv(\"OPENAI_API_KEY\", \"env-openai-key\")\n     monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)"]},{"file":"tests/tools/test_browser_camofox_state.py","hunks":["@@ -64,4 +64,4 @@ def test_config_version_matches_current_schema(self):\n \n         # The current schema version is tracked globally; unrelated default\n         # options may bump it after browser defaults are added.\n-        assert DEFAULT_CONFIG[\"_config_version\"] == 15\n+        assert DEFAULT_CONFIG[\"_config_version\"] == 17\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}