mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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@nousresearch.com>
This commit is contained in:
parent
86e64900b9
commit
b0b7ff0d75
2 changed files with 136 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue