From 271515b18ce3d38e3720e2ab89d3a7289f0e944d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:00:16 -0700 Subject: [PATCH] fix(auth): a configured custom endpoint counts as a provider in auto resolution (#108383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_provider("auto")` only honoured `model.provider` when it named a registry provider, so `provider: custom` (llama.cpp / vLLM / ollama, or a loopback `base_url` alone) fell straight through to "No inference provider configured". The boot inventory in free_tier_bootstrap asks exactly that question, records provider_configured=False, and every `setup.status` in `hermes serve` answers from that record — the dashboard's Ink chat then parked each new session on "Setup Required" while `hermes chat` (which builds the runtime through resolve_runtime_provider) worked on the same config. Merged in v0.21.2 (#107697 4bdd64b334ad); reporters on custom and on named registry providers were hit by the same record path. Recognise `custom` / `custom:` / local-server aliases, and a base_url the bare-custom runtime rung already trusts (_config_base_url_trustworthy_for_bare_custom), as explicit intent in _config_model_provider. Probe (temp HERMES_HOME, provider: custom, base_url 127.0.0.1:8000): before record.provider_configured=False setup.status.provider_configured=False after record.provider_configured=True setup.status.provider_configured=True --- hermes_cli/auth.py | 23 +++++- .../test_resolve_provider_custom_endpoint.py | 74 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_resolve_provider_custom_endpoint.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index f85a421e33..6fa7653637 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1356,16 +1356,33 @@ def _logged_in_oauth_active_provider(*, skip_free_tier: bool = False) -> Optiona def _config_model_provider() -> Tuple[Any, Optional[str]]: - """``(model_cfg, provider)`` from config.yaml when ``model.provider`` names a registry provider. + """``(model_cfg, provider)`` from config.yaml when ``model.provider`` names a registry provider + or a custom OpenAI-compatible endpoint (``custom``, ``custom:``, ``vllm``/``ollama``/...). The normal chat/gateway path resolves config.provider upstream in resolve_requested_provider(); - this is the safety net for the lone direct caller (main.py resolve_provider("auto")).""" + this is the safety net for the direct ``resolve_provider("auto")`` callers. A configured custom + endpoint is explicit intent like any registry pin: without this rung the boot inventory + (``free_tier_bootstrap``) read a llama.cpp/vLLM install as "nothing configured" and the + dashboard's Ink chat parked every session on Setup Required while ``hermes chat`` worked + (#108383).""" try: from hermes_cli.config import load_config model_cfg = (load_config() or {}).get("model") provider = model_cfg.get("provider") if isinstance(model_cfg, dict) else None provider = provider.strip().lower() if isinstance(provider, str) else "" - return model_cfg, (provider if provider in PROVIDER_REGISTRY else None) + provider = _plugin_aliases().get(provider, provider) + if provider == "custom" or provider.startswith("custom:"): + return model_cfg, "custom" + if provider in PROVIDER_REGISTRY: + return model_cfg, provider + # No provider pin but a base_url the bare-custom runtime rung would honour (a loopback + # llama.cpp/vLLM/ollama server) — same explicit intent, spelled by URL. + base_url = str(model_cfg.get("base_url") or "").strip() if isinstance(model_cfg, dict) else "" + if base_url: + from hermes_cli.runtime_provider import _config_base_url_trustworthy_for_bare_custom + if _config_base_url_trustworthy_for_bare_custom(base_url, provider): + return model_cfg, "custom" + return model_cfg, None except Exception as e: logger.debug("Could not read config.yaml model.provider for auto-resolution: %s", e) return None, None diff --git a/tests/hermes_cli/test_resolve_provider_custom_endpoint.py b/tests/hermes_cli/test_resolve_provider_custom_endpoint.py new file mode 100644 index 0000000000..1e6b2792e6 --- /dev/null +++ b/tests/hermes_cli/test_resolve_provider_custom_endpoint.py @@ -0,0 +1,74 @@ +"""A configured custom (OpenAI-compatible) endpoint is explicit provider intent. + +Regression for #108383: ``resolve_provider("auto")`` recognised only registry providers from +``model.provider``, so the boot inventory (``free_tier_bootstrap``) read a llama.cpp / vLLM / +ollama install as "nothing configured" and ``setup.status`` reported ``provider_configured: +False`` — the dashboard's Ink chat parked every new session on "Setup Required" while +``hermes chat`` (which resolves the runtime directly) worked against the same config. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + (home / ".env").write_text("", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("HERMES_GUEST_ONBOARDING", raising=False) + for var in ("OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL", + "OPENROUTER_BASE_URL", "HERMES_INFERENCE_PROVIDER", "NOUS_API_KEY"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) + from hermes_cli import free_tier_bootstrap as fb + fb.reset_for_tests() + return home + + +@pytest.mark.parametrize( + "model_block", + [ + pytest.param( + "model:\n default: nvidia/Nemotron\n provider: custom\n" + " base_url: http://127.0.0.1:8000/v1\n api_key: dummy\n", + id="provider-custom", + ), + pytest.param( + "model:\n default: qwen3\n provider: vllm\n base_url: http://127.0.0.1:8000/v1\n", + id="local-server-alias", + ), + pytest.param( + "model:\n default: qwen3\n base_url: http://localhost:8080/v1\n", + id="loopback-base-url-only", + ), + ], +) +def test_configured_custom_endpoint_resolves_as_a_provider(isolated_home, model_block): + (isolated_home / "config.yaml").write_text(model_block, encoding="utf-8") + from hermes_cli.auth import resolve_provider + from hermes_cli.free_tier_bootstrap import run_bootstrap + + assert resolve_provider("auto") == "custom" + record = run_bootstrap(announce=False) + assert record.provider_configured is True + assert record.other_providers is True + assert record.inference_provider == "custom" + + +def test_stale_remote_base_url_without_a_custom_pin_is_not_a_provider(isolated_home): + """The URL rung follows the runtime's own trust rule: a non-loopback ``base_url`` left behind + under another provider's pin is not custom intent (#14676), so a blank machine still reads + as unconfigured.""" + (isolated_home / "config.yaml").write_text( + "model:\n default: some/model\n provider: openrouter\n base_url: https://api.z.ai/v1\n", + encoding="utf-8", + ) + from hermes_cli.auth import AuthError, resolve_provider + from hermes_cli.free_tier_bootstrap import run_bootstrap + + with pytest.raises(AuthError): + resolve_provider("auto") + assert run_bootstrap(announce=False).provider_configured is False