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
- 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.
Next step: read #15103, then check whether your report is the one still open.
Read #15103Pick up a Lab taskMost 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
-
#1994 fix: custom provider uses config base_url and api_key over env vars (#1760)
files:
hermes_cli/runtime_provider.py,tests/test_runtime_provider_resolution.pyShow 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
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.pyShow 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)
files:
hermes_cli/main.py,tests/hermes_cli/test_setup.pyShow 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)
files:
hermes_cli/runtime_provider.py,scripts/release.py,tests/hermes_cli/test_runtime_provider_resolution.pyShow 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)
files:
agent/auxiliary_client.py,agent/title_generator.py,cli.py,gateway/run.py,tests/agent/test_title_generator.pyShow 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.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab