From b0b7ff0d75e45826481b189b2802a1e00fc218c7 Mon Sep 17 00:00:00 2001 From: Mibayy Date: Sun, 28 Jun 2026 14:16:09 -0700 Subject: [PATCH] fix(provider): auto+base_url bypasses cloud API when custom endpoint configured (#3846) When config.yaml has `provider: auto` and a non-cloud `base_url` (e.g. Ollama at localhost:11434), requests were silently sent to https://api.anthropic.com whenever ANTHROPIC_API_KEY was present in the environment, ignoring the configured local endpoint and returning HTTP 401 / "credit balance too low". Root cause: resolve_provider("auto") scans env vars and returns "anthropic" when ANTHROPIC_API_KEY is set, before config.model.base_url is ever consulted. In resolve_runtime_provider(), before calling resolve_provider(), short-circuit to the OpenAI-compatible resolver when no explicit creds were passed, provider is "auto"/unset, and a non-cloud base_url is configured. Well-known cloud roots (openrouter.ai, anthropic.com, openai.com) are matched on HOST (not substring) so look-alike hosts can't evade the bypass and leak a cloud credential. Co-authored-by: Hermes Agent --- hermes_cli/runtime_provider.py | 37 +++++++ .../test_runtime_provider_resolution.py | 99 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c2e5e4b3d13..ef3df63b893 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1454,6 +1454,43 @@ def resolve_runtime_provider( custom_runtime["requested_provider"] = requested_provider return custom_runtime + # If provider is "auto" (or unset) but config.yaml has an explicit base_url + # pointing at a custom/local endpoint (e.g. Ollama at localhost:11434), + # route through the OpenAI-compatible resolver instead of letting + # resolve_provider() pick up an ANTHROPIC_API_KEY or OPENAI_API_KEY from + # the environment and send the request to a cloud API. Fixes #3846. + if not explicit_base_url and not explicit_api_key: + model_cfg = _get_model_config() + cfg_provider = str(model_cfg.get("provider") or "").strip().lower() + cfg_base_url = str(model_cfg.get("base_url") or "").strip() + if cfg_base_url and cfg_provider in ("auto", ""): + # Check that base_url isn't one of the well-known cloud API roots + # (OpenRouter, Anthropic, OpenAI). If it's something else (Ollama, + # LM Studio, vLLM, …) we honour it directly. The full detection + # logic lives in _resolve_openrouter_runtime; we just skip the + # resolve_provider() call so env-var credentials don't shadow it. + # Match on HOST, not substring, so a look-alike base_url + # (e.g. http://api.anthropic.com.attacker.test/v1, or one whose + # path merely contains "openai.com") cannot evade the bypass and + # leak a cloud credential. Mirrors the host-gating used for + # API-key selection in _resolve_openrouter_runtime. + _known_cloud_hosts = ( + "openrouter.ai", + "anthropic.com", + "openai.com", + ) + if not any( + base_url_host_matches(cfg_base_url, host) + for host in _known_cloud_hosts + ): + runtime = _resolve_openrouter_runtime( + requested_provider=requested_provider, + explicit_api_key=explicit_api_key, + explicit_base_url=explicit_base_url, + ) + runtime["requested_provider"] = requested_provider + return runtime + provider = resolve_provider( requested_provider, explicit_api_key=explicit_api_key, diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 236e27899e7..d0e41efad6b 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -2893,3 +2893,102 @@ def test_resolve_runtime_provider_bedrock_nonclaude_target_model_uses_converse(m assert resolved["provider"] == "bedrock" assert resolved["api_mode"] == "bedrock_converse" assert resolved.get("bedrock_anthropic") is not True + + +def test_auto_provider_with_local_base_url_bypasses_anthropic_key(monkeypatch): + """provider:auto + base_url:localhost should NOT route to Anthropic even if + ANTHROPIC_API_KEY is set in the environment. Regression test for #3846. + + Users running Ollama locally had requests silently sent to Anthropic's API + because resolve_provider("auto") picked up ANTHROPIC_API_KEY before the + config.yaml base_url was checked. + """ + # ANTHROPIC_API_KEY is present in environment — should be ignored + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-key") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "default": "ollama/minimax-m2.7:cloud", + "provider": "auto", + "base_url": "http://localhost:11434", + }, + ) + + resolved = rp.resolve_runtime_provider() + + # Must NOT go to Anthropic's API + assert "anthropic.com" not in resolved.get("base_url", ""), ( + f"Expected custom endpoint, got Anthropic: {resolved}" + ) + assert resolved["base_url"] == "http://localhost:11434" + # provider should be custom/openrouter (OpenAI-compat), not anthropic + assert resolved["provider"] != "anthropic", ( + f"Should have routed to custom endpoint, not anthropic: {resolved}" + ) + + +def test_auto_provider_with_known_cloud_base_url_still_uses_anthropic(monkeypatch): + """provider:auto + base_url pointing to Anthropic should still use Anthropic. + + The local-endpoint bypass only applies to non-cloud endpoints; when the + configured base_url IS a cloud API root, resolve_provider() must run + normally and pick up ANTHROPIC_API_KEY. + """ + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-key") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "auto", + "base_url": "https://api.anthropic.com", + }, + ) + + resolved = rp.resolve_runtime_provider() + + # Cloud base_url → bypass does NOT fire → resolve_provider picks anthropic. + assert resolved["provider"] == "anthropic", ( + f"Cloud base_url must not be diverted to the custom resolver: {resolved}" + ) + + +def test_auto_provider_lookalike_cloud_host_does_not_bypass_to_cloud(monkeypatch): + """A look-alike host (api.anthropic.com.attacker.test) must be treated as a + custom endpoint, NOT mistaken for the real Anthropic cloud. + + Guards the host-match (vs naive substring) check in the local-endpoint + bypass: substring matching on "api.anthropic.com" would wrongly classify + this attacker-controlled host as cloud and hand it the ANTHROPIC_API_KEY. + """ + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-key") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + lookalike = "http://api.anthropic.com.attacker.test/v1" + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: {"provider": "auto", "base_url": lookalike}, + ) + + resolved = rp.resolve_runtime_provider() + + # Host doesn't actually match anthropic.com → bypass fires → custom route, + # and the real Anthropic credential is NOT sent there. + assert resolved["provider"] != "anthropic", ( + f"Look-alike host must not be classified as Anthropic cloud: {resolved}" + ) + assert resolved["base_url"] == lookalike