Skip to content

Commit c4e05a5

Browse files
lwgrayclaude
andauthored
fix(ai): lock provider selection to config.ai.provider, hard-fail on miss (#535)
Closes a silent-fallback bug: when config.ai.provider="anthropic" was set, LLMAbstraction still initialized OpenAI alongside Anthropic whenever an OpenAI key was present in config or env (via ${OPENAI_API_KEY} substitution in config_marcus.json). OpenAI then joined the fallback chain. When Anthropic momentarily failed, Marcus cascaded to OpenAI silently, and real cost rows landed under provider="openai" for a user who believed they had locked the system to Anthropic. The earlier code gated only the ENV-VAR fallback by configured_provider — the init block itself ran whenever the substituted key passed validation. This change inverts the gate: each provider's init is wrapped in _allowed(name) which is True only when configured_provider is empty (legacy auto-discovery) or matches the provider's name. Also adds a hard-fail at the end of _initialize_providers: when configured_provider is set but didn't make it into self.providers, raise RuntimeError instead of silently allowing whatever else loaded. Surfaces misconfig immediately at startup. Tests: 3 new regression cases in TestProviderLockdownRegression covering the leak, symmetric case, and hard-fail behavior. Updated one existing test's regex match to the new (more specific) error message. Refs: #533 (graceful-degradation follow-up) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d73f9bb commit c4e05a5

2 files changed

Lines changed: 220 additions & 90 deletions

File tree

src/ai/providers/llm_abstraction.py

Lines changed: 125 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -185,79 +185,107 @@ def _initialize_providers(self) -> None:
185185

186186
config = MarcusConfig()
187187

188-
# Check if user explicitly configured a provider
189-
# If so, only initialize that provider (no fallbacks to env vars)
188+
# Provider lockdown (Marcus #531). When the user explicitly sets
189+
# ``config.ai.provider``, ONLY that provider initializes. Other
190+
# providers never enter ``self.providers`` and never become
191+
# fallback candidates — even if their credentials happen to be
192+
# present in config or in the environment.
193+
#
194+
# The earlier code gated only the ENV-VAR fallback by
195+
# ``configured_provider``, but left the init block gated only
196+
# on key validity. Config substitution (``"openai_api_key":
197+
# "${OPENAI_API_KEY}"`` in config_marcus.json) put a real OpenAI
198+
# key into ``config.ai.openai_api_key`` whenever the env var was
199+
# exported in the user's shell — so OpenAI silently joined the
200+
# fallback chain even when ``provider: anthropic`` was set, and
201+
# cascaded billing to OpenAI when Anthropic momentarily failed.
190202
configured_provider = config.ai.provider or ""
191203

192-
# Try to initialize Anthropic provider
193-
anthropic_key = config.ai.anthropic_api_key or ""
194-
# Only fall back to env var if no specific provider is configured
195-
# or if anthropic is the configured provider
196-
should_fallback_anthropic = (
197-
not configured_provider or configured_provider == "anthropic"
198-
)
199-
if not anthropic_key and should_fallback_anthropic:
200-
anthropic_key = os.getenv("CLAUDE_API_KEY", "").strip()
201-
202-
if (
203-
anthropic_key
204-
and anthropic_key.startswith("sk-ant-")
205-
and len(anthropic_key) > 10
206-
and anthropic_key != "sk-ant-your-api-key-here"
207-
):
208-
try:
209-
from .anthropic_provider import AnthropicProvider
210-
211-
# Pass key directly to the provider — never write into
212-
# os.environ. ANTHROPIC_API_KEY in the env would force
213-
# Claude Code subprocesses (Epictetus, project creator,
214-
# workers, monitor) to bill the API instead of using the
215-
# user's Claude Code subscription.
216-
self.providers["anthropic"] = AnthropicProvider(api_key=anthropic_key)
217-
self.fallback_providers.append("anthropic")
218-
logger.info("Successfully initialized Anthropic provider")
219-
except Exception as e:
220-
logger.warning(f"Failed to initialize Anthropic provider: {e}")
221-
else:
222-
logger.debug(
223-
f"Skipping Anthropic provider - no valid API key configured "
224-
f"(key present: {bool(anthropic_key)})"
225-
)
204+
def _allowed(name: str) -> bool:
205+
"""Return True iff ``name`` may initialize under the current config.
206+
207+
When ``configured_provider`` is set, only the matching name
208+
is allowed. When it's empty (legacy auto-discovery), every
209+
provider with valid credentials is allowed.
210+
"""
211+
return not configured_provider or configured_provider == name
212+
213+
# ----- Anthropic -----------------------------------------------------
214+
if _allowed("anthropic"):
215+
anthropic_key = config.ai.anthropic_api_key or ""
216+
if not anthropic_key:
217+
anthropic_key = os.getenv("CLAUDE_API_KEY", "").strip()
218+
219+
if (
220+
anthropic_key
221+
and anthropic_key.startswith("sk-ant-")
222+
and len(anthropic_key) > 10
223+
and anthropic_key != "sk-ant-your-api-key-here"
224+
):
225+
try:
226+
from .anthropic_provider import AnthropicProvider
227+
228+
# Pass key directly to the provider — never write into
229+
# os.environ. ANTHROPIC_API_KEY in the env would force
230+
# Claude Code subprocesses (Epictetus, project creator,
231+
# workers, monitor) to bill the API instead of using the
232+
# user's Claude Code subscription.
233+
self.providers["anthropic"] = AnthropicProvider(
234+
api_key=anthropic_key
235+
)
236+
self.fallback_providers.append("anthropic")
237+
logger.info("Successfully initialized Anthropic provider")
238+
except Exception as e:
239+
logger.warning(f"Failed to initialize Anthropic provider: {e}")
240+
else:
241+
logger.debug(
242+
f"Skipping Anthropic provider - no valid API key configured "
243+
f"(key present: {bool(anthropic_key)})"
244+
)
226245

227-
# Only try OpenAI if we have a valid API key
228-
openai_key = config.ai.openai_api_key or ""
229-
# Only fall back to env var if no specific provider is configured
230-
# or if openai is the configured provider
231-
should_fallback_openai = (
232-
not configured_provider or configured_provider == "openai"
233-
)
234-
if not openai_key and should_fallback_openai:
235-
openai_key = os.getenv("OPENAI_API_KEY", "").strip()
236-
237-
if (
238-
openai_key
239-
and openai_key.startswith("sk-")
240-
and len(openai_key) > 10
241-
and openai_key != "sk-your-openai-key-here"
242-
):
243-
try:
244-
from .openai_provider import OpenAIProvider
246+
# ----- OpenAI --------------------------------------------------------
247+
if _allowed("openai"):
248+
openai_key = config.ai.openai_api_key or ""
249+
if not openai_key:
250+
openai_key = os.getenv("OPENAI_API_KEY", "").strip()
251+
252+
if (
253+
openai_key
254+
and openai_key.startswith("sk-")
255+
and len(openai_key) > 10
256+
and openai_key != "sk-your-openai-key-here"
257+
):
258+
try:
259+
from .openai_provider import OpenAIProvider
245260

246-
# Temporarily set env var for the provider
247-
os.environ["OPENAI_API_KEY"] = openai_key
248-
self.providers["openai"] = OpenAIProvider()
249-
self.fallback_providers.append("openai")
250-
logger.info("Successfully initialized OpenAI provider")
251-
except Exception as e:
252-
logger.warning(f"Failed to initialize OpenAI provider: {e}")
253-
else:
254-
logger.debug(
255-
f"Skipping OpenAI provider - no valid API key configured "
256-
f"(key present: {bool(openai_key)})"
261+
# Temporarily set env var for the provider
262+
os.environ["OPENAI_API_KEY"] = openai_key
263+
self.providers["openai"] = OpenAIProvider()
264+
self.fallback_providers.append("openai")
265+
logger.info("Successfully initialized OpenAI provider")
266+
except Exception as e:
267+
logger.warning(f"Failed to initialize OpenAI provider: {e}")
268+
else:
269+
logger.debug(
270+
f"Skipping OpenAI provider - no valid API key configured "
271+
f"(key present: {bool(openai_key)})"
272+
)
273+
elif config.ai.openai_api_key or os.getenv("OPENAI_API_KEY", "").strip():
274+
# Diagnostic only: user has an OpenAI key available somewhere
275+
# but `config.ai.provider` excludes openai. Tell them loudly
276+
# so they know we deliberately ignored the key.
277+
logger.info(
278+
"OpenAI key present but provider=%r — OpenAI deliberately "
279+
"NOT initialized. To use OpenAI, set ai.provider='openai' "
280+
"in config_marcus.json.",
281+
configured_provider,
257282
)
258283

259-
# Add cloud provider if configured
260-
if configured_provider == "cloud":
284+
# ----- Cloud ---------------------------------------------------------
285+
# Cloud was already gated correctly (only inits when explicitly
286+
# configured), so the existing check stays. Comment kept for
287+
# consistency with the rewritten anthropic/openai blocks above.
288+
if _allowed("cloud") and configured_provider == "cloud":
261289
cloud_key = config.ai.cloud_api_key or ""
262290
if not cloud_key:
263291
cloud_key = os.getenv("MARCUS_CLOUD_LLM_KEY", "").strip()
@@ -294,28 +322,38 @@ def _initialize_providers(self) -> None:
294322
bool(cloud_model),
295323
)
296324

297-
# Add local provider if configured
298-
local_model_path = config.ai.local_model or ""
299-
# Only fall back to env var if no specific provider is configured
300-
# or if local is the configured provider
301-
should_fallback_local = (
302-
not configured_provider or configured_provider == "local"
303-
)
304-
if not local_model_path and should_fallback_local:
305-
local_model_path = os.getenv("MARCUS_LOCAL_LLM_PATH", "").strip()
325+
# ----- Local ---------------------------------------------------------
326+
if _allowed("local"):
327+
local_model_path = config.ai.local_model or ""
328+
if not local_model_path:
329+
local_model_path = os.getenv("MARCUS_LOCAL_LLM_PATH", "").strip()
306330

307-
if local_model_path:
308-
try:
309-
from .local_provider import LocalLLMProvider
331+
if local_model_path:
332+
try:
333+
from .local_provider import LocalLLMProvider
310334

311-
self.providers["local"] = LocalLLMProvider(local_model_path)
312-
self.fallback_providers.append("local")
313-
logger.info(
314-
f"Successfully initialized local LLM provider "
315-
f"with model: {local_model_path}"
316-
)
317-
except Exception as e:
318-
logger.warning(f"Failed to initialize local LLM provider: {e}")
335+
self.providers["local"] = LocalLLMProvider(local_model_path)
336+
self.fallback_providers.append("local")
337+
logger.info(
338+
f"Successfully initialized local LLM provider "
339+
f"with model: {local_model_path}"
340+
)
341+
except Exception as e:
342+
logger.warning(f"Failed to initialize local LLM provider: {e}")
343+
344+
# Hard-fail when the user explicitly set a provider and it didn't
345+
# initialize. The earlier code logged a warning and silently
346+
# cascaded to whichever provider happened to be available — that
347+
# caused real cost rows to land under the "wrong" provider after a
348+
# silent fallback. Surface the gap immediately. (Marcus #531)
349+
if configured_provider and configured_provider not in self.providers:
350+
raise RuntimeError(
351+
f"config.ai.provider={configured_provider!r} is set but the "
352+
f"provider failed to initialize. Refusing to silently fall "
353+
f"back to another provider. Check that the corresponding "
354+
f"credentials are present and valid in config_marcus.json "
355+
f"or the matching environment variable, then restart Marcus."
356+
)
319357

320358
# Initialize provider stats only for successfully loaded providers
321359
self.provider_stats = {

tests/unit/ai/test_llm_provider_selection.py

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import pytest
1212

13+
pytestmark = pytest.mark.unit
14+
1315
from src.ai.providers.llm_abstraction import LLMAbstraction
1416

1517

@@ -244,8 +246,9 @@ def test_anthropic_api_key_env_var_is_NOT_read(
244246
245247
With only ANTHROPIC_API_KEY in env (no CLAUDE_API_KEY, no config key),
246248
the Anthropic provider must NOT initialize. ``_initialize_providers``
247-
raises ``RuntimeError`` when nothing initializes, which is the
248-
correct outcome — that proves the env var was ignored.
249+
raises ``RuntimeError`` when the configured provider failed to init
250+
(Marcus #531), which is the correct outcome — that proves the env
251+
var was ignored.
249252
"""
250253
os.environ["ANTHROPIC_API_KEY"] = "sk-ant-this-should-be-ignored-completely"
251254

@@ -258,7 +261,7 @@ def test_anthropic_api_key_env_var_is_NOT_read(
258261

259262
with patch("src.config.marcus_config.get_config", return_value=mock_config):
260263
llm = LLMAbstraction()
261-
with pytest.raises(RuntimeError, match="No LLM providers"):
264+
with pytest.raises(RuntimeError, match="ai.provider='anthropic' is set"):
262265
llm._initialize_providers()
263266

264267
# Even after the failed initialization attempt, the providers dict
@@ -267,3 +270,92 @@ def test_anthropic_api_key_env_var_is_NOT_read(
267270
"Marcus must not fall back to ANTHROPIC_API_KEY — that env var "
268271
"belongs to Claude Code's subscription auth."
269272
)
273+
274+
275+
class TestProviderLockdownRegression:
276+
"""Regression: real OpenAI key in config must not leak past
277+
``provider: anthropic`` setting (Marcus #531).
278+
279+
The earlier code gated only the env-var fallback by configured
280+
provider; the init block ran whenever
281+
``config.ai.openai_api_key`` was non-empty — and Marcus's
282+
``config_marcus.json`` substitutes ``${OPENAI_API_KEY}`` into
283+
that field, so a shell-exported OpenAI key silently joined the
284+
fallback chain alongside Anthropic. When Anthropic momentarily
285+
failed, Marcus cascaded to OpenAI and billed real tokens to the
286+
OpenAI key without the user knowing.
287+
288+
These tests reproduce the exact leak condition (config carries
289+
BOTH keys; provider explicitly set to one) and assert the other
290+
provider stays out of ``self.providers``.
291+
"""
292+
293+
@pytest.fixture
294+
def env_with_real_openai_key(self):
295+
"""Reproduce the user's environment: real openai key in env."""
296+
prev = os.environ.get("OPENAI_API_KEY")
297+
os.environ["OPENAI_API_KEY"] = "sk-proj-s8abcdef1234567890abcdef"
298+
yield
299+
if prev is None:
300+
os.environ.pop("OPENAI_API_KEY", None)
301+
else:
302+
os.environ["OPENAI_API_KEY"] = prev
303+
304+
def test_openai_excluded_when_provider_is_anthropic(self, env_with_real_openai_key):
305+
"""Provider=anthropic + real OpenAI key in config: no OpenAI provider."""
306+
mock_config = Mock()
307+
mock_config.ai.provider = "anthropic"
308+
# Reproduce config_marcus.json's substituted state: anthropic
309+
# AND openai keys both present.
310+
mock_config.ai.anthropic_api_key = "sk-ant-api03-fake-for-test-1234567890"
311+
mock_config.ai.openai_api_key = "sk-proj-s8abcdef1234567890abcdef"
312+
mock_config.ai.model = "claude-sonnet-4-6"
313+
mock_config.ai.local_model = None
314+
315+
with patch("src.config.marcus_config.get_config", return_value=mock_config):
316+
llm = LLMAbstraction()
317+
llm._initialize_providers()
318+
assert "anthropic" in llm.providers
319+
assert "openai" not in llm.providers, (
320+
"OpenAI provider must NOT initialize when provider=anthropic, "
321+
"even if openai_api_key is non-empty"
322+
)
323+
assert "openai" not in llm.fallback_providers
324+
325+
def test_anthropic_excluded_when_provider_is_openai(self, env_with_real_openai_key):
326+
"""Provider=openai + real Anthropic key in config: no Anthropic provider."""
327+
mock_config = Mock()
328+
mock_config.ai.provider = "openai"
329+
mock_config.ai.anthropic_api_key = "sk-ant-api03-fake-for-test-1234567890"
330+
mock_config.ai.openai_api_key = "sk-proj-s8abcdef1234567890abcdef"
331+
mock_config.ai.model = "gpt-4o-mini"
332+
mock_config.ai.local_model = None
333+
334+
with patch("src.config.marcus_config.get_config", return_value=mock_config):
335+
llm = LLMAbstraction()
336+
llm._initialize_providers()
337+
assert "openai" in llm.providers
338+
assert "anthropic" not in llm.providers
339+
assert "anthropic" not in llm.fallback_providers
340+
341+
def test_hard_fail_when_configured_provider_does_not_initialize(self):
342+
"""Refusing silent fallback: missing anthropic key + provider=anthropic raises."""
343+
mock_config = Mock()
344+
mock_config.ai.provider = "anthropic"
345+
# Explicitly no anthropic key but a real openai key present —
346+
# the earlier code would silently fall back to OpenAI.
347+
mock_config.ai.anthropic_api_key = None
348+
mock_config.ai.openai_api_key = "sk-proj-s8abcdef1234567890abcdef"
349+
mock_config.ai.model = "claude-sonnet-4-6"
350+
mock_config.ai.local_model = None
351+
352+
# Clear CLAUDE_API_KEY too so the env-var fallback can't satisfy it.
353+
prev = os.environ.pop("CLAUDE_API_KEY", None)
354+
try:
355+
with patch("src.config.marcus_config.get_config", return_value=mock_config):
356+
with pytest.raises(RuntimeError, match="ai.provider='anthropic'"):
357+
llm = LLMAbstraction()
358+
llm._initialize_providers()
359+
finally:
360+
if prev is not None:
361+
os.environ["CLAUDE_API_KEY"] = prev

0 commit comments

Comments
 (0)