From 16192103f4b6d23fa36aa9e6f509f951ba58517f Mon Sep 17 00:00:00 2001 From: ms-alan Date: Sat, 27 Jun 2026 04:01:16 -0700 Subject: [PATCH] fix(config): accept placeholder base_url in custom provider validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _normalize_custom_provider_entry() ran urlparse() on base_url and dropped any entry whose value was an un-expanded placeholder, so a caller reaching the normalizer with raw config (e.g. the Dockerized gateway path) silently skipped the provider with a 'not a valid URL' warning. Skip URL validation when the candidate contains a placeholder token — both ${ENV_VAR} env-refs and bare {region}-style templates — since those are expanded at runtime. Closes #14457 --- hermes_cli/config.py | 8 ++++ .../test_provider_config_validation.py | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 45a32e9f8d9..ae500e6db83 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4269,6 +4269,14 @@ def _normalize_custom_provider_entry( raw_url = entry.get(url_key) if isinstance(raw_url, str) and raw_url.strip(): candidate = raw_url.strip() + # Accept URLs containing unresolved placeholder tokens — both + # ``${ENV_VAR}`` env-refs and bare ``{region}``-style templates — + # without URL validation. They are expanded at runtime, so a + # caller reaching this normalizer with raw (un-expanded) config + # would otherwise see the provider silently dropped (#14457). + if re.search(r"\{[^}]+\}", candidate): + base_url = candidate + break parsed = urlparse(candidate) if parsed.scheme and parsed.netloc: base_url = candidate diff --git a/tests/hermes_cli/test_provider_config_validation.py b/tests/hermes_cli/test_provider_config_validation.py index 50cc283d90c..43b9cb38121 100644 --- a/tests/hermes_cli/test_provider_config_validation.py +++ b/tests/hermes_cli/test_provider_config_validation.py @@ -191,3 +191,49 @@ class TestNormalizeCustomProviderEntry: result = _normalize_custom_provider_entry(entry) assert result is not None assert "models" not in result + + def test_env_var_placeholder_in_base_url_not_rejected(self): + """A base_url that is an un-expanded ${ENV_VAR} placeholder must not be + rejected as an invalid URL — it is expanded at runtime, so a caller + reaching this normalizer with raw config would otherwise see the + provider silently dropped. Regression test for #14457.""" + entry = { + "name": "PROVIDER_A", + "base_url": "${PROVIDER_A_BASE_URL}", + "key_env": "PROVIDER_A_API_KEY", + } + result = _normalize_custom_provider_entry(entry, provider_key="PROVIDER_A") + assert result is not None + assert result["base_url"] == "${PROVIDER_A_BASE_URL}" + + def test_multiple_env_vars_in_base_url(self): + """base_url with multiple ${VAR} placeholders is accepted verbatim.""" + entry = { + "name": "multi-var-provider", + "base_url": "${SCHEME}://${HOST}:${PORT}/v1", + } + result = _normalize_custom_provider_entry(entry) + assert result is not None + assert result["base_url"] == "${SCHEME}://${HOST}:${PORT}/v1" + + def test_bare_brace_region_placeholder_accepted(self): + """A bare {region}-style template token (not an env-ref) is also + accepted without validation, supporting region-substitution URLs.""" + entry = { + "name": "regional", + "base_url": "https://{region}.api.example.com/v1", + } + result = _normalize_custom_provider_entry(entry, provider_key="regional") + assert result is not None + assert result["base_url"] == "https://{region}.api.example.com/v1" + + def test_invalid_url_without_placeholder_still_rejected(self): + """A malformed URL with no scheme/host AND no placeholder token is + still rejected — the placeholder bypass must not weaken validation of + ordinary literal URLs.""" + entry = { + "name": "bad", + "base_url": "not-a-url", + } + result = _normalize_custom_provider_entry(entry, provider_key="bad") + assert result is None