# NousResearch/hermes-agent · Custom provider config falling back to OpenRouter

## Open now

- 1 issue, 1 PR open on GitHub.
- #13489: fix(provider): ACP sessions with provider="custom" fail to resolve correct credential
- PR #63676: fix(acp): persist original provider identity, not resolved runtime class

Most members concern custom or local provider settings (model.provider, base_url, api_key) in config.yaml not being respected, with resolution reportedly defaulting to OpenRouter across the CLI, gateway, ACP sessions, and auxiliary paths. The group mixes bug reports with proposed fixes and refactors, and a few touch adjacent topics such as the setup wizard overwriting config.

## TL;DR

- 21 member(s): 1 open issue(s), 6 closed issue(s), 1 open PR(s), 5 merged PR(s), 8 PR(s) closed without merging.
- A fix was merged to main in PR #15103. Verify it addresses your case.
- **Recommended:** read PR #15103 (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: `partially_resolved` · deciding PR(s): `pr-1994,pr-4180,pr-15103`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.10`: 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.

## Open related issues

- #13489: fix(provider): ACP sessions with provider="custom" fail to resolve correct credential

## Closed / duplicate issues

- #1760: \[Bug\]: Custom provider config (base_url, api_key) not being used - defaults to OpenRouter
- #4172: \[Bug\] Provider auto-detection ignores model.provider in config.yaml — stale API keys override explicit custom endpoint
- #5358: \[Bug\]: Gateway and CLI ignore model.provider config — fall back to OpenRouter when OPENROUTER_API_KEY exists
- #12146: Agent runs fall back to openrouter despite model.provider=custom in config.yaml
- #14676: bug: bare 'custom' provider falls through to OpenRouter — no base_url resolution
- #16623: \[Bug\]: Explicit local OpenAI-compatible config still resolves back to OpenRouter

## Prior & competing PRs

- **#1994: fix: custom provider uses config base_url and api_key over env vars (#1760)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/runtime_provider.py`, `tests/test_runtime_provider_resolution.py`
  - fix hunks:
  - `hermes_cli/runtime_provider.py`

    ```diff
    @@ -171,6 +171,12 @@ def _resolve_openrouter_runtime(
         model_cfg = _get_model_config()
         cfg_base_url = model_cfg.get("base_url") if isinstance(model_cfg.get("base_url"), str) else ""
         cfg_provider = model_cfg.get("provider") if isinstance(model_cfg.get("provider"), str) else ""
    +    cfg_api_key = ""
    +    for k in ("api_key", "api"):
    +        v = model_cfg.get(k)
    +        if isinstance(v, str) and v.strip():
    +            cfg_api_key = v.strip()
    +            break
         requested_norm = (requested_provider or "").strip().lower()
         cfg_provider = cfg_provider.strip().lower()
     
    ```

    ```diff
    @@ -178,26 +184,24 @@ def _resolve_openrouter_runtime(
         env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip()
     
         use_config_base_url = False
    -    if cfg_base_url.strip() and not explicit_base_url and not env_openai_base_url:
    +    if cfg_base_url.strip() and not explicit_base_url:
             if requested_norm == "auto":
    -            if not cfg_provider or cfg_provider == "auto":
    -                use_config_base_url = True
    -        elif requested_norm == "custom":
    -            # Persisted custom endpoints store their base URL in config.yaml.
    -            # If OPENAI_BASE_URL is not currently set in the environment, keep
    -            # honoring that saved endpoint instead of falling back to OpenRouter.
    -            if cfg_provider == "custom":
    +            if (not cfg_provider or cfg_provider == "auto") and not env_openai_base_url:
                     use_config_base_url = True
    +        elif requested_norm == "custom" and cfg_provider == "custom":
    +            # provider: custom — use base_url from config (Fixes #1760).
    +            use_config_base_url = True
     
         # When the user explicitly requested the openrouter provider, skip
         # OPENAI_BASE_URL — it typically points to a custom / non-OpenRouter
         # endpoint and would prevent switching back to OpenRouter (#874).
         skip_openai_base = requested_norm == "openrouter"
     
    +    # For custom, prefer config base_url over env so config.yaml is honored (#1760).
         base_url = (
             (explicit_base_url or "").strip()
    -        or ("" if skip_openai_base else env_openai_base_url)
             or (cfg_base_url.strip() if use_config_base_url else "")
    +        or ("" if skip_openai_base else env_openai_base_url)
             or env_openrouter_base_url
             or OPENROUTER_BASE_URL
         ).rstrip("/")
    ```

    ```diff
    @@ -216,8 +220,10 @@ def _resolve_openrouter_runtime(
                 or ""
             )
         else:
    +        # Custom endpoint: use api_key from config when using config base_url (#1760).
             api_key = (
                 explicit_api_key
    +            or (cfg_api_key if use_config_base_url else "")
                 or os.getenv("OPENAI_API_KEY")
                 or os.getenv("OPENROUTER_API_KEY")
                 or ""
    ```
  - `tests/test_runtime_provider_resolution.py`

    ```diff
    @@ -177,6 +177,50 @@ def test_custom_endpoint_uses_saved_config_base_url_when_env_missing(monkeypatch
         assert resolved["api_key"] == "local-key"
     
     
    +def test_custom_endpoint_uses_config_api_key_over_env(monkeypatch):
    +    """provider: custom with base_url and api_key in config uses them (#1760)."""
    +    monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
    +    monkeypatch.setattr(
    +        rp,
    +        "_get_model_config",
    +        lambda: {
    +            "provider": "custom",
    +            "base_url": "https://my-api.example.com/v1",
    +            "api_key": "[redacted:secret-assignment]",
    +        },
    +    )
    +    monkeypatch.setenv("OPENAI_BASE_URL", "https://other.example.com/v1")
    +    monkeypatch.setenv("OPENAI_API_KEY", "env-key")
    +    monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
    +
    +    resolved = rp.resolve_runtime_provider(requested="custom")
    +
    +    assert resolved["base_url"] == "https://my-api.example.com/v1"
    +    assert resolved["api_key"] == "config-api-key"
    +
    +
    +def test_custom_endpoint_uses_config_api_field_when_no_api_key(monkeypatch):
    +    """provider: custom with 'api' in config uses it as api_key (#1760)."""
    +    monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
    +    monkeypatch.setattr(
    +        rp,
    +        "_get_model_config",
    +        lambda: {
    +            "provider": "custom",
    +            "base_url": "https://custom.example.com/v1",
    +            "api": "config-api-field",
    +        },
    +    )
    +    monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
    +    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    +    monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
    +
    +    resolved = rp.resolve_runtime_provider(requested="custom")
    +
    +    assert resolved["base_url"] == "https://custom.example.com/v1"
    +    assert resolved["api_key"] == "config-api-field"
    +
    +
     def test_custom_endpoint_auto_provider_prefers_openai_key(monkeypatch):
         """Auto provider with non-OpenRouter base_url should prefer OPENAI_API_KEY.
     
    ```
- **#4165: refactor: make config.yaml the single source of truth for endpoint URLs**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/auxiliary_client.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, `hermes_cli/models.py`, `hermes_cli/runtime_provider.py`, `hermes_cli/setup.py`, `hermes_cli/tools_config.py`, `tests/agent/test_auxiliary_client.py`, `tests/hermes_cli/test_setup_model_provider.py`, `tests/test_cli_provider_resolution.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -7,7 +7,7 @@
     Resolution order for text tasks (auto mode):
       1. OpenRouter  (OPENROUTER_API_KEY)
       2. Nous Portal (~/.hermes/auth.json active provider)
    -  3. Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY)
    +  3. Custom endpoint (config.yaml model.base_url + OPENAI_API_KEY)
       4. Codex OAuth (Responses API via chatgpt.com with gpt-5.3-codex,
          wrapped to look like a chat.completions client)
       5. Native Anthropic
    ```

    ```diff
    @@ -584,15 +584,11 @@ def _try_nous() -> Tuple[Optional[OpenAI], Optional[str]]:
     
     
     def _read_main_model() -> str:
    -    """Read the user's configured main model from config/env.
    +    """Read the user's configured main model from config.yaml.
     
    -    Falls back through HERMES_MODEL → LLM_MODEL → config.yaml model.default
    -    so the auxiliary client can use the same model as the main agent when no
    -    dedicated auxiliary model is available.
    +    config.yaml model.default is the single source of truth for the active
    +    model. Environment variables are no longer consulted.
         """
    -    from_env = os.getenv("OPENAI_MODEL") or os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL")
    -    if from_env:
    -        return from_env.strip()
         try:
             from hermes_cli.config import load_config
             cfg = load_config()
    ```
  - `cli.py`

    ```diff
    @@ -1124,9 +1124,9 @@ def __init__(
             self.acp_args: list[str] = []
             self.base_url = (
                 base_url
    -            or os.getenv("OPENAI_BASE_URL")
    -            or os.getenv("OPENROUTER_BASE_URL", CLI_CONFIG["model"]["base_url"])
    -        )
    +            or CLI_CONFIG["model"].get("base_url", "")
    +            or os.getenv("OPENROUTER_BASE_URL", "")
    +        ) or None
             # Match key to resolved base_url: OpenRouter URL → prefer OPENROUTER_API_KEY,
             # custom endpoint → prefer OPENAI_API_KEY (issue #560).
             # Note: _ensure_runtime_credentials() re-resolves this before first use.
    ```

    ```diff
    @@ -3239,7 +3239,7 @@ def _show_model_and_providers(self):
                             print(f"      {mid}{current_marker}")
                     elif p["id"] == "custom":
                         from hermes_cli.models import _get_custom_base_url
    -                    custom_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "")
    +                    custom_url = _get_custom_base_url()
                         if custom_url:
                             print(f"      endpoint: {custom_url}")
                         if is_active:
    ```
  - `gateway/run.py`

    ```diff
    @@ -364,20 +364,19 @@ def _load_gateway_config() -> dict:
     
     
     def _resolve_gateway_model(config: dict | None = None) -> str:
    -    """Read model from env/config — mirrors the resolution in _run_agent_sync.
    +    """Read model from config.yaml — single source of truth.
     
         Without this, temporary AIAgent instances (memory flush, /compress) fall
         back to the hardcoded default which fails when the active provider is
         openai-codex.
         """
    -    model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or ""
         cfg = config if config is not None else _load_gateway_config()
         model_cfg = cfg.get("model", {})
         if isinstance(model_cfg, str):
    -        model = model_cfg
    +        return model_cfg
         elif isinstance(model_cfg, dict):
    -        model = model_cfg.get("default") or model_cfg.get("model") or model
    -    return model
    +        return model_cfg.get("default") or model_cfg.get("model") or ""
    +    return ""
     
     
     def _resolve_hermes_bin() -> Optional[list[str]]:
    ```

    ```diff
    @@ -2759,7 +2758,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
                         {
                             "role": "session_meta",
                             "tools": tool_defs or [],
    -                        "model": os.getenv("HERMES_MODEL", ""),
    +                        "model": _resolve_gateway_model(),
                             "platform": source.platform.value if source.platform else "",
                             "timestamp": ts,
                         }
    ```

    ```diff
    @@ -3224,9 +3223,11 @@ async def _handle_provider_command(self, event: MessageEvent) -> str:
                 except Exception:
                     current_provider = "openrouter"
     
    -        # Detect custom endpoint
    -        if current_provider == "openrouter" and os.getenv("OPENAI_BASE_URL", "").strip():
    -            current_provider = "custom"
    +        # Detect custom endpoint from config base_url
    +        if current_provider == "openrouter":
    +            _cfg_base = model_cfg.get("base_url", "") if isinstance(model_cfg, dict) else ""
    +            if _cfg_base and "openrouter.ai" not in _cfg_base:
    +                current_provider = "custom"
     
             current_label = _PROVIDER_LABELS.get(current_provider, current_provider)
     
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -1050,10 +1050,6 @@ def _model_flow_openrouter(config, current_model=""):
     
         selected = _prompt_model_selection(openrouter_models, current_model=current_model)
         if selected:
    -        # Clear any custom endpoint and set provider to openrouter
    -        if get_env_value("OPENAI_BASE_URL"):
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _save_model_choice(selected)
     
             # Update config provider and deactivate any OAuth provider
    ```

    ```diff
    @@ -1143,10 +1139,6 @@ def _model_flow_nous(config, current_model=""):
             # Reactivate Nous as the provider and update config
             inference_url = creds.get("base_url", "")
             _update_config_for_provider("nous", inference_url)
    -        # Clear any custom endpoint that might conflict
    -        if get_env_value("OPENAI_BASE_URL"):
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             print(f"Default model set to: {selected} (via Nous Portal)")
         else:
             print("No change.")
    ```

    ```diff
    @@ -1191,10 +1183,6 @@ def _model_flow_openai_codex(config, current_model=""):
         if selected:
             _save_model_choice(selected)
             _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
    -        # Clear custom endpoint env vars that would otherwise override Codex.
    -        if get_env_value("OPENAI_BASE_URL"):
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             print(f"Default model set to: {selected} (via OpenAI Codex)")
         else:
             print("No change.")
    ```

    ```diff
    @@ -1275,11 +1263,6 @@ def _model_flow_custom(config):
             if probe.get("suggested_base_url"):
                 print(f"  If this server expects /v1, try base URL: {probe['suggested_base_url']}")
     
    -    if base_url:
    -        save_env_value("OPENAI_BASE_URL", effective_url)
    -    if api_key:
    -        save_env_value("OPENAI_API_KEY", api_key)
    -
         if model_name:
             _save_model_choice(model_name)
     
    ```

    ```diff
    @@ -1439,9 +1422,6 @@ def _model_flow_named_custom(config, provider_info):
     
         # If a model is saved, just activate immediately — no probing needed
         if saved_model:
    -        save_env_value("OPENAI_BASE_URL", base_url)
    -        if api_key:
    -            save_env_value("OPENAI_API_KEY", api_key)
             _save_model_choice(saved_model)
     
             cfg = load_config()
    ```

    ```diff
    @@ -1513,9 +1493,6 @@ def _model_flow_named_custom(config, provider_info):
                 return
     
         # Activate and save the model to the custom_providers entry
    -    save_env_value("OPENAI_BASE_URL", base_url)
    -    if api_key:
    -        save_env_value("OPENAI_API_KEY", api_key)
         _save_model_choice(model_name)
     
         cfg = load_config()
    ```

    ```diff
    @@ -1829,11 +1806,6 @@ def _model_flow_copilot(config, current_model=""):
                 catalog=catalog,
                 api_key=[redacted:secret-assignment]
             ) or selected
    -        # Clear stale custom-endpoint overrides so the Copilot provider wins cleanly.
    -        if get_env_value("OPENAI_BASE_URL"):
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
    -
             initial_cfg = load_config()
             current_effort = _current_reasoning_effort(initial_cfg)
             reasoning_efforts = github_model_reasoning_efforts(
    ```

    ```diff
    @@ -2058,11 +2030,6 @@ def _model_flow_kimi(config, current_model=""):
                 selected = None
     
         if selected:
    -        # Clear custom endpoint if set (avoid confusion)
    -        if get_env_value("OPENAI_BASE_URL"):
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
    -
             _save_model_choice(selected)
     
             # Update config with provider and base URL
    ```

    ```diff
    @@ -2165,11 +2132,6 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
                 selected = None
     
         if selected:
    -        # Clear custom endpoint if set (avoid confusion)
    -        if get_env_value("OPENAI_BASE_URL"):
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
    -
             _save_model_choice(selected)
     
             # Update config with provider and base URL
    ```

    ```diff
    @@ -2381,11 +2343,6 @@ def _model_flow_anthropic(config, current_model=""):
                 selected = None
     
         if selected:
    -        # Clear custom endpoint if set
    -        if get_env_value("OPENAI_BASE_URL"):
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
    -
             _save_model_choice(selected)
     
             # Update config with provider — clear base_url since
    ```
  - `hermes_cli/models.py`

    ```diff
    @@ -347,7 +347,7 @@ def list_available_providers() -> list[dict[str, str]]:
             try:
                 from hermes_cli.auth import get_auth_status, has_usable_secret
                 if pid == "custom":
    -                custom_base_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "")
    +                custom_base_url = _get_custom_base_url() or ""
                     has_creds = bool(custom_base_url.strip())
                 elif pid == "openrouter":
                     has_creds = has_usable_secret(os.getenv("OPENROUTER_API_KEY", ""))
    ```
  - `hermes_cli/runtime_provider.py`

    ```diff
    @@ -229,28 +229,22 @@ def _resolve_openrouter_runtime(
         requested_norm = (requested_provider or "").strip().lower()
         cfg_provider = cfg_provider.strip().lower()
     
    -    env_openai_base_url = os.getenv("OPENAI_BASE_URL", "").strip()
         env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip()
     
    +    # Use config base_url when available and the provider context matches.
    +    # OPENAI_BASE_URL env var is no longer consulted — config.yaml is
    +    # the single source of truth for endpoint URLs.
         use_config_base_url = False
         if cfg_base_url.strip() and not explicit_base_url:
             if requested_norm == "auto":
    -            if (not cfg_provider or cfg_provider == "auto") and not env_openai_base_url:
    +            if not cfg_provider or cfg_provider == "auto":
                     use_config_base_url = True
             elif requested_norm == "custom" and cfg_provider == "custom":
    -            # provider: custom — use base_url from config (Fixes #1760).
                 use_config_base_url = True
     
    -    # When the user explicitly requested the openrouter provider, skip
    -    # OPENAI_BASE_URL — it typically points to a custom / non-OpenRouter
    -    # endpoint and would prevent switching back to OpenRouter (#874).
    -    skip_openai_base = requested_norm == "openrouter"
    -
    -    # For custom, prefer config base_url over env so config.yaml is honored (#1760).
         base_url = (
             (explicit_base_url or "").strip()
             or (cfg_base_url.strip() if use_config_base_url else "")
    -        or ("" if skip_openai_base else env_openai_base_url)
             or env_openrouter_base_url
             or OPENROUTER_BASE_URL
         ).rstrip("/")
    ```
  - `hermes_cli/setup.py`

    ```diff
    @@ -941,10 +941,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without an API key")
     
    -        # Clear any custom endpoint if switching to OpenRouter
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
     
             # Update config.yaml and deactivate any OAuth provider so the
             # resolver doesn't keep returning the old provider (e.g. Codex).
    ```

    ```diff
    @@ -1032,10 +1028,6 @@ def setup_model_provider(config: dict):
     
                 mock_args = argparse.Namespace()
                 _login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"])
    -            # Clear custom endpoint vars that would override provider routing.
    -            if existing_custom:
    -                save_env_value("OPENAI_BASE_URL", "")
    -                save_env_value("OPENAI_API_KEY", "")
                 _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
                 _set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL)
             except SystemExit:
    ```

    ```diff
    @@ -1118,10 +1110,6 @@ def setup_model_provider(config: dict):
                         "  If you get billing errors, check your plan at https://open.bigmodel.cn/"
                     )
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "zai", zai_base_url)
             selected_base_url = zai_base_url
     
    ```

    ```diff
    @@ -1151,10 +1139,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without an API key")
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "kimi-coding", pconfig.inference_base_url)
             selected_base_url = pconfig.inference_base_url
     
    ```

    ```diff
    @@ -1184,10 +1168,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without an API key")
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "minimax", pconfig.inference_base_url)
             selected_base_url = pconfig.inference_base_url
     
    ```

    ```diff
    @@ -1217,10 +1197,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without an API key")
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "minimax-cn", pconfig.inference_base_url)
             selected_base_url = pconfig.inference_base_url
     
    ```

    ```diff
    @@ -1250,10 +1226,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without an API key")
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "kilocode", pconfig.inference_base_url)
             selected_base_url = pconfig.inference_base_url
     
    ```

    ```diff
    @@ -1352,10 +1324,6 @@ def setup_model_provider(config: dict):
                     else:
                         print_warning("Skipped — agent won't work without credentials")
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             # Don't save base_url for Anthropic — resolve_runtime_provider()
             # always hardcodes it. Stale base_urls contaminate other providers.
             _set_model_provider(config, "anthropic")
    ```

    ```diff
    @@ -1386,10 +1354,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without an API key")
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _update_config_for_provider("ai-gateway", pconfig.inference_base_url, default_model="anthropic/claude-opus-4.6")
             _set_model_provider(config, "ai-gateway", pconfig.inference_base_url)
     
    ```

    ```diff
    @@ -1418,10 +1382,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without an API key")
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _update_config_for_provider("alibaba", pconfig.inference_base_url, default_model="qwen3.5-plus")
             _set_model_provider(config, "alibaba", pconfig.inference_base_url)
     
    ```

    ```diff
    @@ -1451,10 +1411,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without an API key")
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "opencode-zen", pconfig.inference_base_url)
             selected_base_url = pconfig.inference_base_url
     
    ```

    ```diff
    @@ -1484,10 +1440,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without an API key")
     
    -        # Clear custom endpoint vars if switching
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "opencode-go", pconfig.inference_base_url)
             selected_base_url = pconfig.inference_base_url
     
    ```

    ```diff
    @@ -1518,9 +1470,6 @@ def setup_model_provider(config: dict):
                 else:
                     print_warning("Skipped - agent won't work without a GitHub token or gh auth login")
     
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "copilot", pconfig.inference_base_url)
             selected_base_url = pconfig.inference_base_url
     
    ```

    ```diff
    @@ -1534,9 +1483,6 @@ def setup_model_provider(config: dict):
             print_info(f"Base marker: {pconfig.inference_base_url}")
             print()
     
    -        if existing_custom:
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "copilot-acp", pconfig.inference_base_url)
             selected_base_url = pconfig.inference_base_url
     
    ```

    ```diff
    @@ -1553,9 +1499,6 @@ def setup_model_provider(config: dict):
             api_key = [redacted:secret-assignment]"  HF Token", password=True)
             if api_key:
                 save_env_value("HF_TOKEN", api_key)
    -            # Clear OpenRouter env vars to prevent routing confusion
    -            save_env_value("OPENAI_BASE_URL", "")
    -            save_env_value("OPENAI_API_KEY", "")
             _set_model_provider(config, "huggingface", pconfig.inference_base_url)
             selected_base_url = pconfig.inference_base_url
     
    ```

    ```diff
    @@ -1632,7 +1575,9 @@ def setup_model_provider(config: dict):
                 _oai_key = prompt(_api_key_label, password=[redacted:secret-assignment]
                 if _oai_key:
                     save_env_value("OPENAI_API_KEY", _oai_key)
    -                save_env_value("OPENAI_BASE_URL", _base_url)
    +                # Save vision base URL to config (not .env — only secrets go there)
    +                _vaux = config.setdefault("auxiliary", {}).setdefault("vision", {})
    +                _vaux["base_url"] = _base_url
                     if "api.openai.com" in _base_url.lower():
                         _oai_vision_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]
                         _vm_choices = _oai_vision_models + ["Use default (gpt-4o-mini)"]
    ```
  - `hermes_cli/tools_config.py`

    ```diff
    @@ -983,8 +983,13 @@ def _configure_simple_requirements(ts_key: str):
                 key_label = "    OPENAI_API_KEY" if "api.openai.com" in base_url.lower() else "    API key"
                 api_key = [redacted:secret-assignment] password=True)
                 if api_key and api_key.strip():
    -                save_env_value("OPENAI_BASE_URL", base_url)
                     save_env_value("OPENAI_API_KEY", api_key.strip())
    +                # Save vision base URL to config (not .env — only secrets go there)
    +                from hermes_cli.config import load_config, save_config
    +                _cfg = load_config()
    +                _aux = _cfg.setdefault("auxiliary", {}).setdefault("vision", {})
    +                _aux["base_url"] = base_url
    +                save_config(_cfg)
                     if "api.openai.com" in base_url.lower():
                         save_env_value("AUXILIARY_VISION_MODEL", "gpt-4o-mini")
                     _print_success("    Saved")
    ```
  - `tests/agent/test_auxiliary_client.py`

    ```diff
    @@ -465,9 +465,16 @@ def test_nous_takes_priority_over_codex(self, monkeypatch, codex_auth_dir):
             assert model == "google/gemini-3-flash-preview"
     
         def test_custom_endpoint_over_codex(self, monkeypatch, codex_auth_dir):
    -        monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
    +        config = {
    +            "model": {
    +                "provider": "custom",
    +                "base_url": "http://localhost:1234/v1",
    +                "default": "my-local-model",
    +            }
    +        }
             monkeypatch.setenv("OPENAI_API_KEY", "lm-studio-key")
    -        monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
    +        monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
    +        monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
             # Override the autouse monkeypatch for codex
             monkeypatch.setattr(
                 "agent.auxiliary_client._read_codex_access_token",
    ```

    ```diff
    @@ -726,10 +733,17 @@ def test_vision_uses_nous_when_available(self, monkeypatch):
     
         def test_vision_forced_main_uses_custom_endpoint(self, monkeypatch):
             """When explicitly forced to 'main', vision CAN use custom endpoint."""
    +        config = {
    +            "model": {
    +                "provider": "custom",
    +                "base_url": "http://localhost:1234/v1",
    +                "default": "my-local-model",
    +            }
    +        }
             monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
    -        monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
             monkeypatch.setenv("OPENAI_API_KEY", "local-key")
    -        monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
    +        monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
    +        monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
             with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
                  patch("agent.auxiliary_client.OpenAI") as mock_openai:
                 client, model = get_vision_auxiliary_client()
    ```

    ```diff
    @@ -827,9 +841,16 @@ def test_forced_nous_not_configured(self, monkeypatch):
             assert model is None
     
         def test_forced_main_uses_custom(self, monkeypatch):
    -        monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1")
    +        config = {
    +            "model": {
    +                "provider": "custom",
    +                "base_url": "http://local:8080/v1",
    +                "default": "my-local-model",
    +            }
    +        }
             monkeypatch.setenv("OPENAI_API_KEY", "local-key")
    -        monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
    +        monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
    +        monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
             with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
                  patch("agent.auxiliary_client.OpenAI") as mock_openai:
                 client, model = _resolve_forced_provider("main")
    ```

    ```diff
    @@ -858,10 +879,17 @@ def test_forced_main_uses_config_saved_custom_endpoint(self, monkeypatch):
     
         def test_forced_main_skips_openrouter_nous(self, monkeypatch):
             """Even if OpenRouter key is set, 'main' skips it."""
    +        config = {
    +            "model": {
    +                "provider": "custom",
    +                "base_url": "http://local:8080/v1",
    +                "default": "my-local-model",
    +            }
    +        }
             monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
    -        monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1")
             monkeypatch.setenv("OPENAI_API_KEY", "local-key")
    -        monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
    +        monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
    +        monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
             with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
                  patch("agent.auxiliary_client.OpenAI") as mock_openai:
                 client, model = _resolve_forced_provider("main")
    ```
  - `tests/hermes_cli/test_setup_model_provider.py`

    ```diff
    @@ -129,16 +129,13 @@ def fake_prompt_choice(question, choices, default=0):
     
         env = _read_env(tmp_path)
     
    -    # _model_flow_custom saves env vars and config to disk
    -    assert env.get("OPENAI_BASE_URL") == "http://localhost:8000/v1"
    -    assert env.get("OPENAI_API_KEY") == "local-key"
    -
    -    # The model config is saved as a dict by _model_flow_custom
    +    # _model_flow_custom saves config to disk (base_url in config, not .env)
         reloaded = load_config()
         model_cfg = reloaded.get("model", {})
         if isinstance(model_cfg, dict):
             assert model_cfg.get("provider") == "custom"
             assert model_cfg.get("default") == "llm"
    +        assert model_cfg.get("base_url") == "http://localhost:8000/v1"
     
     
     def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tmp_path, monkeypatch):
    ```

    ```diff
    @@ -232,8 +229,11 @@ def fake_prompt_choice(question, choices, default=0):
         env = _read_env(tmp_path)
     
         assert env.get("OPENAI_API_KEY") == "sk-openai"
    -    assert env.get("OPENAI_BASE_URL") == "https://api.openai.com/v1"
         assert env.get("AUXILIARY_VISION_MODEL") == "gpt-4o-mini"
    +    # Vision base URL saved to config.yaml, not .env
    +    reloaded = load_config()
    +    vision_cfg = reloaded.get("auxiliary", {}).get("vision", {})
    +    assert vision_cfg.get("base_url") == "https://api.openai.com/v1"
     
     
     def test_setup_copilot_uses_gh_auth_and_saves_provider(tmp_path, monkeypatch):
    ```

    ```diff
    @@ -433,8 +433,7 @@ def fake_prompt_choice(question, choices, default=0):
         env = _read_env(tmp_path)
         reloaded = load_config()
     
    -    assert env.get("OPENAI_BASE_URL") == ""
    -    assert env.get("OPENAI_API_KEY") == ""
    +    # OPENAI_BASE_URL is no longer written/cleared in .env — config is authoritative
         assert reloaded["model"]["provider"] == "openai-codex"
         assert reloaded["model"]["default"] == "openai/gpt-5.3-codex"
         assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"
    ```
  - `tests/test_cli_provider_resolution.py`

    ```diff
    @@ -466,6 +466,6 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
         output = capsys.readouterr().out
     
         assert "Saving the working base URL instead" in output
    -    assert saved_env["OPENAI_BASE_URL"] == "http://localhost:8000/v1"
    -    assert saved_env["OPENAI_API_KEY"] == "local-key"
    +    # OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative
    +    assert "OPENAI_BASE_URL" not in saved_env
         assert saved_env["MODEL"] == "llm"
    \ No newline at end of file
    ```
- **#4180: fix: setup wizard overwrites custom endpoint config (#4172)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/main.py`, `tests/hermes_cli/test_setup.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -1278,10 +1278,25 @@ def _model_flow_custom(config):
             save_config(cfg)
             deactivate_provider()
     
    +        # Sync the caller's config dict so the setup wizard's final
    +        # save_config(config) preserves our model settings.  Without
    +        # this, the wizard overwrites model.provider/base_url with
    +        # the stale values from its own config dict (#4172).
    +        config["model"] = dict(model)
    +
             print(f"Default model set to: {model_name} (via {effective_url})")
         else:
             if base_url or api_key:
                 deactivate_provider()
    +        # Even without a model name, persist the custom endpoint on the
    +        # caller's config dict so the setup wizard doesn't lose it.
    +        _caller_model = config.get("model")
    +        if not isinstance(_caller_model, dict):
    +            _caller_model = {"default": _caller_model} if _caller_model else {}
    +        _caller_model["provider"] = "custom"
    +        _caller_model["base_url"] = effective_url
    +        _caller_model.pop("api_mode", None)
    +        config["model"] = _caller_model
             print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
     
         # Auto-save to custom_providers so it appears in the menu next time
    ```
  - `tests/hermes_cli/test_setup.py`

    ```diff
    @@ -118,11 +118,125 @@ def fake_prompt_choice(question, choices, default=0):
         # Core assertion: switching to custom endpoint clears OAuth provider
         assert get_active_provider() is None
     
    -    # _model_flow_custom writes config via its own load/save cycle
    +    # Simulate what the real setup wizard does: save_config(config) AFTER
    +    # setup_model_provider returns.  This is the step that previously
    +    # overwrote model.provider/base_url (#4172).
    +    save_config(config)
    +
    +    reloaded = load_config()
    +    assert isinstance(reloaded.get("model"), dict), (
    +        "model should be a dict after custom setup, not "
    +        + repr(type(reloaded.get("model")))
    +    )
    +    assert reloaded["model"].get("provider") == "custom"
    +    assert reloaded["model"].get("default") == "custom/model"
    +    assert "custom.example" in reloaded["model"].get("base_url", "")
    +
    +
    +def test_custom_setup_preserves_provider_after_wizard_save_config(
    +    tmp_path, monkeypatch
    +):
    +    """Regression test for #4172: the setup wizard's final save_config(config)
    +    must not overwrite model.provider/base_url that _model_flow_custom set.
    +
    +    Simulates the full flow:
    +      1. load config (fresh install — model is a string)
    +      2. setup_model_provider picks custom
    +      3. wizard calls save_config(config) afterward
    +      4. verify resolve_requested_provider returns "custom"
    +    """
    +    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +    _clear_provider_env(monkeypatch)
    +
    +    config = load_config()
    +    # Sanity: fresh install has model as a string
    +    assert isinstance(config.get("model"), str) or config.get("model") is None
    +
    +    def fake_prompt_choice(question, choices, default=0):
    +        if question == "Select your inference provider:":
    +            return 3  # Custom endpoint
    +        tts_idx = _maybe_keep_current_tts(question, choices)
    +        if tts_idx is not None:
    +            return tts_idx
    +        raise AssertionError(f"Unexpected prompt_choice call: {question}")
    +
    +    monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
    +
    +    input_values = iter([
    +        "http://localhost:11434/v1",  # Ollama URL
    +        "",                           # no API key (local)
    +        "qwen3.5:32b",               # model name
    +        "",                           # context length (auto-detect)
    +    ])
    +    monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values))
    +    monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False)
    +    monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
    +    monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *a, **kw: None)
    +    monkeypatch.setattr(
    +        "hermes_cli.models.probe_api_models",
    +        lambda api_key, base_url: {"models": ["qwen3.5:32b"], "probed_url": base_url + "/models"},
    +    )
    +
    +    # Full wizard cycle
    +    setup_model_provider(config)
    +    save_config(config)  # ← this is what the real wizard does
    +
    +    # Verify config on disk
    +    reloaded = load_config()
    +    assert isinstance(reloaded["model"], dict)
    +    assert reloaded["model"]["provider"] == "custom"
    +    assert reloaded["model"]["base_url"] == "http://localhost:11434/v1"
    +    assert reloaded["model"]["default"] == "qwen3.5:32b"
    +    assert "api_mode" not in reloaded["model"]
    +
    +    # Verify the runtime resolver sees "custom", not "auto"
    +    from hermes_cli.runtime_provider import resolve_requested_provider
    +    assert resolve_requested_provider() == "custom"
    +
    +
    +def test_custom_setup_no_model_name_still_preserves_endpoint(
    +    tmp_path, monkeypatch
    +):
    +    """When the user enters a URL and key but skips the model name,
    +    model.provider and model.base_url must still survive the wizard's
    +    final save_config(config)."""
    +    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +    _clear_provider_env(monkeypatch)
    +
    +    config = load_config()
    +
    +    def fake_prompt_choice(question, choices, default=0):
    +        if question == "Select your inference provider:":
    +            return 3
    +        tts_idx = _maybe_keep_current_tts(question, choices)
    +        if tts_idx is not None:
    +            return tts_idx
    +        raise AssertionError(f"Unexpected prompt_choice call: {question}")
    +
    +    monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
    +
    +    input_values = iter([
    +        "http://192.168.1.50:8080/v1",  # URL
    +        "my-key",                        # API key
    +        "",                              # no model name
    +        "",                              # context length
    +    ])
    +    monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values))
    +    monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False)
    +    monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
    +    monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *a, **kw: None)
    +    monkeypatch.setattr(
    +        "hermes_cli.models.probe_api_models",
    +        lambda api_key, base_url: {"models": None, "probed_url": base_url + "/models"},
    +    )
    +
    +    setup_model_provider(config)
    +    save_config(config)
    +
         reloaded = load_config()
    -    if isinstance(reloaded.get("model"), dict):
    -        assert reloaded["model"].get("provider") == "custom"
    -        assert reloaded["model"].get("default") == "custom/model"
    +    assert isinstance(reloaded["model"], dict)
    +    assert reloaded["model"]["provider"] == "custom"
    +    assert reloaded["model"]["base_url"] == "http://192.168.1.50:8080/v1"
     
     
     def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):
    ```
- **#5369: fix(gateway,cli): respect model.provider and model.base_url from config**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `388dd4789`, `#5358`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#15103: fix(runtime): resolve bare custom provider to loopback or CUSTOM_BASE_URL (salvage #14719)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/runtime_provider.py`, `scripts/release.py`, `tests/hermes_cli/test_runtime_provider_resolution.py`
  - fix hunks:
  - `hermes_cli/runtime_provider.py`

    ```diff
    @@ -36,6 +36,29 @@ def _normalize_custom_provider_name(value: str) -> str:
         return value.strip().lower().replace(" ", "-")
     
     
    +def _loopback_hostname(host: str) -> bool:
    +    h = (host or "").lower().rstrip(".")
    +    return h in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
    +
    +
    +def _config_base_url_trustworthy_for_bare_custom(cfg_base_url: str, cfg_provider: str) -> bool:
    +    """Decide whether ``model.base_url`` may back bare ``custom`` runtime resolution.
    +
    +    GitHub #14676: the model picker can select Custom while ``model.provider`` still reflects a
    +    previous provider. Reject non-loopback URLs unless the YAML provider is already ``custom``,
    +    so a stale OpenRouter/Z.ai base_url cannot hijack local ``custom`` sessions.
    +    """
    +    cfg_provider_norm = (cfg_provider or "").strip().lower()
    +    bu = (cfg_base_url or "").strip()
    +    if not bu:
    +        return False
    +    if cfg_provider_norm == "custom":
    +        return True
    +    if base_url_host_matches(bu, "openrouter.ai"):
    +        return False
    +    return _loopback_hostname(base_url_hostname(bu))
    +
    +
     def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
         """Auto-detect api_mode from the resolved base URL.
     
    ```

    ```diff
    @@ -472,6 +495,7 @@ def _resolve_openrouter_runtime(
         cfg_provider = cfg_provider.strip().lower()
     
         env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip()
    +    env_custom_base_url = os.getenv("CUSTOM_BASE_URL", "").strip()
     
         # Use config base_url when available and the provider context matches.
         # OPENAI_BASE_URL env var is no longer consulted — config.yaml is
    ```

    ```diff
    @@ -481,11 +505,14 @@ def _resolve_openrouter_runtime(
             if requested_norm == "auto":
                 if not cfg_provider or cfg_provider == "auto":
                     use_config_base_url = True
    -        elif requested_norm == "custom" and cfg_provider == "custom":
    +        elif requested_norm == "custom" and _config_base_url_trustworthy_for_bare_custom(
    +            cfg_base_url, cfg_provider
    +        ):
                 use_config_base_url = True
     
         base_url = (
             (explicit_base_url or "").strip()
    +        or env_custom_base_url
             or (cfg_base_url.strip() if use_config_base_url else "")
             or env_openrouter_base_url
             or OPENROUTER_BASE_URL
    ```
  - `scripts/release.py`

    ```diff
    @@ -191,6 +191,7 @@
         "[redacted:email]": "ReginaldasR",
         "[redacted:email]": "0xharryriddle",
         "[redacted:email]": "ericnicolaides",
    +    "[redacted:email]": "georgex8001",
         "[redacted:email]": "benbarclay",
         "[redacted:email]": "ChimingLiu",
         "[redacted:email]": "openclaw",
    ```
  - `tests/hermes_cli/test_runtime_provider_resolution.py`

    ```diff
    @@ -536,6 +536,72 @@ def test_custom_endpoint_explicit_custom_prefers_config_key(monkeypatch):
         assert resolved["api_key"] == "sk-vllm-key"
     
     
    +def test_bare_custom_uses_loopback_model_base_url_when_provider_not_custom(monkeypatch):
    +    """Regression for #14676: /model can select Custom while YAML still lists another provider."""
    +    monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
    +    monkeypatch.setattr(
    +        rp,
    +        "_get_model_config",
    +        lambda: {
    +            "provider": "openrouter",
    +            "base_url": "http://127.0.0.1:8082/v1",
    +            "default": "my-local-model",
    +        },
    +    )
    +    monkeypatch.delenv("CUSTOM_BASE_URL", raising=False)
    +    monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
    +    monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
    +    monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
    +    monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
    +
    +    resolved = rp.resolve_runtime_provider(requested="custom")
    +
    +    assert resolved["provider"] == "custom"
    +    assert resolved["base_url"] == "http://127.0.0.1:8082/v1"
    +    assert resolved["api_key"] == "openai-key"
    +
    +
    +def test_bare_custom_custom_base_url_env_overrides_remote_yaml(monkeypatch):
    +    monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
    +    monkeypatch.setattr(
    +        rp,
    +        "_get_model_config",
    +        lambda: {
    +            "provider": "openrouter",
    +            "base_url": "https://api.openrouter.ai/api/v1",
    +        },
    +    )
    +    monkeypatch.setenv("CUSTOM_BASE_URL", "http://localhost:9999/v1")
    +    monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
    +    monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
    +
    +    resolved = rp.resolve_runtime_provider(requested="custom")
    +
    +    assert resolved["provider"] == "custom"
    +    assert resolved["base_url"] == "http://localhost:9999/v1"
    +
    +
    +def test_bare_custom_does_not_trust_non_loopback_when_provider_not_custom(monkeypatch):
    +    monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
    +    monkeypatch.setattr(
    +        rp,
    +        "_get_model_config",
    +        lambda: {
    +            "provider": "openrouter",
    +            "base_url": "https://remote.example.com/v1",
    +        },
    +    )
    +    monkeypatch.delenv("CUSTOM_BASE_URL", raising=False)
    +    monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
    +    monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
    +
    +    resolved = rp.resolve_runtime_provider(requested="custom")
    +
    +    assert resolved["provider"] == "custom"
    +    assert "openrouter.ai" in resolved["base_url"]
    +    assert "remote.example.com" not in resolved["base_url"]
    +
    +
     def test_named_custom_provider_uses_saved_credentials(monkeypatch):
         monkeypatch.delenv("OPENAI_API_KEY", raising=False)
         monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
    ```
- **#16922: fix(auxiliary): custom provider URL rewrite + main_runtime model for title gen (widened)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/auxiliary_client.py`, `agent/title_generator.py`, `cli.py`, `gateway/run.py`, `tests/agent/test_title_generator.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -1834,7 +1834,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
         # ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────
         if provider == "custom":
             if explicit_base_url:
    -            custom_base = explicit_base_url.strip()
    +            custom_base = _to_openai_base_url(explicit_base_url).strip()
                 custom_key = (
                     (explicit_api_key or "").strip()
                     or os.getenv("OPENAI_API_KEY", "").strip()
    ```

    ```diff
    @@ -1847,7 +1847,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
                     )
                     return None, None
                 final_model = _normalize_resolved_model(
    -                model or _read_main_model() or "gpt-4o-mini",
    +                model or (main_runtime.get("model") if main_runtime else None) or "gpt-4o-mini",
                     provider,
                 )
                 extra = {}
    ```

    ```diff
    @@ -1895,10 +1895,22 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
                 entry_api_mode = (api_mode or custom_entry.get("api_mode") or "").strip()
                 if custom_base:
                     final_model = _normalize_resolved_model(
    -                    model or custom_entry.get("model") or _read_main_model() or "gpt-4o-mini",
    +                    model
    +                    or custom_entry.get("model")
    +                    or (main_runtime.get("model") if main_runtime else None)
    +                    or _read_main_model()
    +                    or "gpt-4o-mini",
                         provider,
                     )
    -                _clean_base2, _dq2 = _extract_url_query_params(custom_base)
    +                # anthropic_messages talks to the /anthropic surface directly;
    +                # OpenAI-wire paths (chat_completions / codex_responses) need the
    +                # /v1 equivalent.  Rewrite only on the OpenAI-wire path so the
    +                # Anthropic fallback SDK still sees the original URL.
    +                if entry_api_mode == "anthropic_messages":
    +                    openai_base = custom_base
    +                else:
    +                    openai_base = _to_openai_base_url(custom_base)
    +                _clean_base2, _dq2 = _extract_url_query_params(openai_base)
                     _extra2 = {"default_query": _dq2} if _dq2 else {}
                     logger.debug(
                         "resolve_provider_client: named custom provider %r (%s, api_mode=%s)",
    ```

    ```diff
    @@ -1917,7 +1929,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
                                 "installed — falling back to OpenAI-wire.",
                                 provider,
                             )
    -                        client = OpenAI(api_key=[redacted:secret-assignment] base_url=_clean_base2, **_extra2)
    +                        # Fallback went OpenAI-wire after all — redo the query
    +                        # extraction against the rewritten /v1 URL.
    +                        _fallback_base = _to_openai_base_url(custom_base)
    +                        _fb_clean, _fb_dq = _extract_url_query_params(_fallback_base)
    +                        _fb_extra = {"default_query": _fb_dq} if _fb_dq else {}
    +                        client = OpenAI(api_key=[redacted:secret-assignment] base_url=_fb_clean, **_fb_extra)
                             return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
                                     else (client, final_model))
                         sync_anthropic = AnthropicAuxiliaryClient(
    ```

    ```diff
    @@ -1936,7 +1953,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
                     ):
                         client = CodexAuxiliaryClient(client, final_model)
                     else:
    -                    client = _wrap_if_needed(client, final_model, custom_base)
    +                    client = _wrap_if_needed(client, final_model, openai_base)
                     return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
                             else (client, final_model))
                 logger.warning(
    ```

    ```diff
    @@ -2038,7 +2055,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
     
         if pconfig.auth_type == "external_process":
             creds = resolve_external_process_provider_credentials(provider)
    -        final_model = _normalize_resolved_model(model or _read_main_model(), provider)
    +        final_model = _normalize_resolved_model(
    +            model
    +            or (main_runtime.get("model") if main_runtime else None)
    +            or _read_main_model(),
    +            provider,
    +        )
             if provider == "copilot-acp":
                 api_key = [redacted:secret-assignment]"api_key", "")).strip()
                 base_url = str(creds.get("base_url", "")).strip()
    ```
  - `agent/title_generator.py`

    ```diff
    @@ -30,10 +30,12 @@ def generate_title(
         assistant_response: str,
         timeout: float = 30.0,
         failure_callback: Optional[FailureCallback] = None,
    +    main_runtime: dict = None,
     ) -> Optional[str]:
         """Generate a session title from the first exchange.
     
    -    Uses the auxiliary LLM client (cheapest/fastest available model).
    +    Uses the main runtime's model when available, falling back to the
    +    auxiliary LLM client (cheapest/fastest available model).
         Returns the title string or None on failure.
     
         ``failure_callback`` is invoked with ``(task, exception)`` when the
    ```

    ```diff
    @@ -57,6 +59,7 @@ def generate_title(
                 max_tokens=500,
                 temperature=0.3,
                 timeout=timeout,
    +            main_runtime=main_runtime,
             )
             title = (response.choices[0].message.content or "").strip()
             # Clean up: remove quotes, trailing punctuation, prefixes like "Title: "
    ```

    ```diff
    @@ -86,6 +89,7 @@ def auto_title_session(
         user_message: str,
         assistant_response: str,
         failure_callback: Optional[FailureCallback] = None,
    +    main_runtime: dict = None,
     ) -> None:
         """Generate and set a session title if one doesn't already exist.
     
    ```

    ```diff
    @@ -107,7 +111,7 @@ def auto_title_session(
             return
     
         title = generate_title(
    -        user_message, assistant_response, failure_callback=failure_callback
    +        user_message, assistant_response, failure_callback=failure_callback, main_runtime=main_runtime
         )
         if not title:
             return
    ```

    ```diff
    @@ -126,6 +130,7 @@ def maybe_auto_title(
         assistant_response: str,
         conversation_history: list,
         failure_callback: Optional[FailureCallback] = None,
    +    main_runtime: dict = None,
     ) -> None:
         """Fire-and-forget title generation after the first exchange.
     
    ```

    ```diff
    @@ -147,7 +152,7 @@ def maybe_auto_title(
         thread = threading.Thread(
             target=auto_title_session,
             args=(session_db, session_id, user_message, assistant_response),
    -        kwargs={"failure_callback": failure_callback},
    +        kwargs={"failure_callback": failure_callback, "main_runtime": main_runtime},
             daemon=True,
             name="auto-title",
         )
    ```
  - `cli.py`

    ```diff
    @@ -8814,6 +8814,13 @@ def run_agent():
                             response,
                             self.conversation_history,
                             failure_callback=_title_failure_cb,
    +                        main_runtime={
    +                            "model": self.model,
    +                            "provider": self.provider,
    +                            "base_url": self.base_url,
    +                            "api_key": self.api_key,
    +                            "api_mode": self.api_mode,
    +                        },
                         )
                     except Exception:
                         pass
    ```
  - `gateway/run.py`

    ```diff
    @@ -10700,6 +10700,13 @@ def _approval_notify_sync(approval_data: dict) -> None:
                             final_response,
                             all_msgs,
                             failure_callback=_title_failure_cb,
    +                        main_runtime={
    +                            "model": getattr(agent, "model", None),
    +                            "provider": getattr(agent, "provider", None),
    +                            "base_url": getattr(agent, "base_url", None),
    +                            "api_key": [redacted:secret-assignment] "api_key", None),
    +                            "api_mode": getattr(agent, "api_mode", None),
    +                        } if agent else None,
                         )
                     except Exception:
                         pass
    ```
  - `tests/agent/test_title_generator.py`

    ```diff
    @@ -182,7 +182,7 @@ def test_fires_on_first_exchange(self):
                 import time
                 time.sleep(0.3)
                 mock_auto.assert_called_once_with(
    -                db, "sess-1", "hello", "hi there", failure_callback=None
    +                db, "sess-1", "hello", "hi there", failure_callback=None, main_runtime=None
                 )
     
         def test_forwards_failure_callback_to_worker(self):
    ```

    ```diff
    @@ -202,7 +202,7 @@ def _cb(task, exc):
                 import time
                 time.sleep(0.3)
                 mock_auto.assert_called_once_with(
    -                db, "sess-1", "hello", "hi there", failure_callback=_cb
    +                db, "sess-1", "hello", "hi there", failure_callback=_cb, main_runtime=None
                 )
     
         def test_skips_if_no_response(self):
    ```
- **#1762: Fix custom provider: use config base_url and api_key (Fixes #1760)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#1994`
- **#4174: fix: custom provider falls through to OpenRouter — three missing resolution paths**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#4165`
- **#13831: fix(runtime_provider): handle provider='custom' with explicit base_url in ACP sessions**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#15103`, `#16922`
- **#14718: fix(agent): map bare "custom" provider to named providers: key via base_url match**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#15103`, `#14719`
- **#14719: fix(runtime): resolve bare custom provider to loopback or CUSTOM_BASE_URL (#14676)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#17072: Fix/lmstudio runtime resolution fork2**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#35056: fix(provider): unify provider/credential resolution — config.yaml as single source, fail-closed on custom intent**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#4600`, `#5358`, `#8919`, `#12146`, `#27132`, `#13489`, `#3263`, `#29285`
- **#63676: fix(acp): persist original provider identity, not resolved runtime class**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `acp_adapter/session.py`

## Fix status

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