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:
Mibayy 2026-06-28 14:16:09 -07:00 committed by Teknium
parent 86e64900b9
commit b0b7ff0d75
2 changed files with 136 additions and 0 deletions

View file

@ -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,

View file

@ -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