NousResearch/hermes-agent

Custom provider config falling back to OpenRouter

Cluster 1760 · acp. Data as of

2 open items. Fix merged in #15103 on 24 Apr 2026. Check it covers your case.

The short answer

1open issue
1open PR
21items grouped
Known?
Yes. 21 related items grouped here, 6 issues already closed.
Fix event
A fix was merged to main in PR #15103. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #1994, #4180, #15103. That is separate from the fix event.
Still open
1 issue and 1 PR.

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.

Open issues1

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

Open PRs1

  • PR#63676fix(acp): persist original provider identity, not resolved runtime class

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #1994 fix: custom provider uses config base_url and api_key over env vars (#1760)

    merged to main

    files: hermes_cli/runtime_provider.py, tests/test_runtime_provider_resolution.py

    Show the diff
    • hermes_cli/runtime_provider.py
      @@ -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()
       
      @@ -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("/")
      @@ -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
      @@ -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

    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

    Show the diff
    • agent/auxiliary_client.py
      @@ -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
      @@ -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
      @@ -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.
      @@ -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
      @@ -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]]:
      @@ -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,
                           }
      @@ -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
      @@ -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
      @@ -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.")
      @@ -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.")
      @@ -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)
       
      @@ -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()
      @@ -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()
      @@ -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(
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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).
      @@ -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:
      @@ -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
       
      @@ -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
       
      @@ -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
       
      @@ -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
       
      @@ -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
       
      @@ -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")
      @@ -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)
       
      @@ -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)
       
      @@ -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
       
      @@ -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
       
      @@ -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
       
      @@ -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
       
      @@ -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
       
      @@ -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
      @@ -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
      @@ -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",
      @@ -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()
      @@ -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")
      @@ -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
      @@ -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):
      @@ -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):
      @@ -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
      @@ -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

    files: hermes_cli/main.py, tests/hermes_cli/test_setup.py

    Show the diff
    • hermes_cli/main.py
      @@ -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
      @@ -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):
  • #15103 fix(runtime): resolve bare custom provider to loopback or CUSTOM_BASE_URL (salvage #14719)

    merged to main

    files: hermes_cli/runtime_provider.py, scripts/release.py, tests/hermes_cli/test_runtime_provider_resolution.py

    Show the diff
    • hermes_cli/runtime_provider.py
      @@ -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.
       
      @@ -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
      @@ -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
      @@ -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
      @@ -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

    files: agent/auxiliary_client.py, agent/title_generator.py, cli.py, gateway/run.py, tests/agent/test_title_generator.py

    Show the diff
    • agent/auxiliary_client.py
      @@ -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()
      @@ -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 = {}
      @@ -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)",
      @@ -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(
      @@ -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(
      @@ -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
      @@ -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
      @@ -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: "
      @@ -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.
       
      @@ -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
      @@ -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.
       
      @@ -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
      @@ -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
      @@ -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
      @@ -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):
      @@ -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):

8 PRs closed without merging

From the close comments: done elsewhere 2 · merged via another change 2 · closed without comment 1 · stale or abandoned 1 · superseded by another PR 1 · other 1

  • #1762 Fix custom provider: use config base_url and api_key (Fixes #1760) · merged via another change → #1994
  • #4174 fix: custom provider falls through to OpenRouter — three missing resolution paths · done elsewhere → #4165
  • #5369 fix(gateway,cli): respect model.provider and model.base_url from config · other → 388dd47, #5358
  • #13831 fix(runtime_provider): handle provider='custom' with explicit base_url in ACP sessions · superseded by another PR → #15103, #16922
  • #14718 fix(agent): map bare "custom" provider to named providers: key via base_url match · done elsewhere → #15103, #14719
Show 3 more
  • #14719 fix(runtime): resolve bare custom provider to loopback or CUSTOM_BASE_URL (#14676) · stale or abandoned
  • #17072 Fix/lmstudio runtime resolution fork2 · closed without comment
  • #35056 fix(provider): unify provider/credential resolution — config.yaml as single source, fail-closed on custom intent · merged via another change → #4600, #5358, #8919, #12146, #27132, #13489, #3263, #29285
Closed issues · 6
  • Closed#1760[Bug]: Custom provider config (base_url, api_key) not being used - defaults to OpenRouter
  • Closed#4172[Bug] Provider auto-detection ignores model.provider in config.yaml — stale API keys override explicit custom endpoint
  • Closed#5358[Bug]: Gateway and CLI ignore model.provider config — fall back to OpenRouter when OPENROUTER_API_KEY exists
  • Closed#12146Agent runs fall back to openrouter despite model.provider=custom in config.yaml
  • Closed#14676bug: bare 'custom' provider falls through to OpenRouter — no base_url resolution
  • Closed#16623[Bug]: Explicit local OpenAI-compatible config still resolves back to OpenRouter
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
acp
Merged PRs
#1994, #4165, #4180, #15103, #16922
Closed issues
6
Attempts
8 PRs closed without merging
Fix link checked
28 Apr 2026

For your agent

The same facts, typed.