@@ -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 = {
0 commit comments