From 8354c35eed401d3facfa5ac0f6c6ad7102d371b7 Mon Sep 17 00:00:00 2001 From: Mateusz Konopelski Date: Fri, 3 Jul 2026 12:44:53 +0200 Subject: [PATCH] Auto-detect DEFAULT_PROVIDER from whichever LLM API key is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-editing .env to switch from OPENROUTER_API_KEY to ANTHROPIC_API_KEY (without re-running specflow-init.sh) left DEFAULT_PROVIDER unset, and docker-compose's hardcoded `${DEFAULT_PROVIDER:-openrouter}` fallback silently forced openrouter — failing startup validation even though a valid Anthropic key was present, which also made the TUI startup gate misreport containers as "not running" (backend never reached ready). Settings now infers DEFAULT_PROVIDER from ANTHROPIC_API_KEY/ OPENROUTER_API_KEY presence on every construction (not just at one-time init), matching the contract already documented in .env.quickstart.example. Explicit DEFAULT_PROVIDER still overrides. docker-compose passes through blank instead of baking in "openrouter" so that inference isn't pre-empted. --- backend/app/core/config.py | 27 ++++++- backend/test/core/test_enums.py | 73 ++++++++++++++++++- .../test/services/test_startup_validation.py | 23 ++++++ docker-compose.yml | 5 +- 4 files changed, 125 insertions(+), 3 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index bf494ad..9e22c9b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -229,9 +229,34 @@ def _empty_str_to_none_int(cls, v: object) -> object: FIRESTORE_DATABASE_NAME: str = "default" # Firestore database name (default: "(default)") # LLM Provider Configuration - # Active LLM provider: "openrouter" (default) or "anthropic". + # Active LLM provider: "openrouter" (default) or "anthropic". Auto-detected from + # whichever API key is set when left unset — see _infer_default_provider_from_keys. + # Set explicitly to override the auto-detection (e.g. both keys present but you + # want Anthropic). DEFAULT_PROVIDER: LLMProvider = LLMProvider.OPENROUTER + @model_validator(mode="before") + @classmethod + def _infer_default_provider_from_keys(cls, data: object) -> object: + """Infer DEFAULT_PROVIDER from whichever API key is set, when not explicit. + + Single source of truth for the auto-detection documented in + .env.quickstart.example ("set ONE of the two keys ... if both are set, + OpenRouter is used by default"). Runs on every Settings() construction — + not only at `specflow-init.sh` time — so switching keys by hand later + (without re-running init) still resolves to the right provider instead + of silently falling back to OpenRouter and failing startup validation. + """ + if not isinstance(data, dict): + return data + if not data.get("DEFAULT_PROVIDER"): + data.pop("DEFAULT_PROVIDER", None) + anthropic_key = data.get("ANTHROPIC_API_KEY") + openrouter_key = data.get("OPENROUTER_API_KEY") + if anthropic_key and not openrouter_key: + data["DEFAULT_PROVIDER"] = LLMProvider.ANTHROPIC.value + return data + # LLM Model Tier Configuration # Values follow OpenRouter naming convention: provider/model (e.g., anthropic/claude-opus-4.5) # Can be comma-separated for multiple models (used in multi-workspace generation for variance reduction) diff --git a/backend/test/core/test_enums.py b/backend/test/core/test_enums.py index 7c6d496..79424ee 100644 --- a/backend/test/core/test_enums.py +++ b/backend/test/core/test_enums.py @@ -68,7 +68,10 @@ class TestSettingsDefaultProvider: def test_default_is_openrouter(self): from app.core.config import Settings - s = Settings() + with pytest.MonkeyPatch.context() as mp: + mp.delenv("ANTHROPIC_API_KEY", raising=False) + mp.delenv("OPENROUTER_API_KEY", raising=False) + s = Settings() assert s.DEFAULT_PROVIDER == LLMProvider.OPENROUTER assert s.DEFAULT_PROVIDER == "openrouter" @@ -87,3 +90,71 @@ def test_database_type_bogus_raises(self): mp.setenv("DATABASE_TYPE", "bogus") with pytest.raises(ValidationError, match="Invalid DATABASE_TYPE"): Settings() + + +class TestDefaultProviderInference: + """DEFAULT_PROVIDER auto-detection from whichever API key is set (unset case). + + Regression coverage for the bug where hand-editing .env to switch from + OPENROUTER_API_KEY to ANTHROPIC_API_KEY (without re-running specflow-init.sh) + left DEFAULT_PROVIDER unset, and docker-compose's hardcoded + ``${DEFAULT_PROVIDER:-openrouter}`` fallback silently forced openrouter, + failing startup validation despite a valid Anthropic key being present. + """ + + def _settings(self, mp, *, anthropic: str | None, openrouter: str | None): + from app.core.config import Settings + + mp.delenv("DEFAULT_PROVIDER", raising=False) + if anthropic is None: + mp.delenv("ANTHROPIC_API_KEY", raising=False) + else: + mp.setenv("ANTHROPIC_API_KEY", anthropic) + if openrouter is None: + mp.delenv("OPENROUTER_API_KEY", raising=False) + else: + mp.setenv("OPENROUTER_API_KEY", openrouter) + return Settings() + + def test_anthropic_only_infers_anthropic(self): + with pytest.MonkeyPatch.context() as mp: + s = self._settings(mp, anthropic="sk-ant-test", openrouter=None) + assert s.DEFAULT_PROVIDER == LLMProvider.ANTHROPIC + + def test_openrouter_only_infers_openrouter(self): + with pytest.MonkeyPatch.context() as mp: + s = self._settings(mp, anthropic=None, openrouter="or-test") + assert s.DEFAULT_PROVIDER == LLMProvider.OPENROUTER + + def test_both_keys_set_defaults_to_openrouter(self): + with pytest.MonkeyPatch.context() as mp: + s = self._settings(mp, anthropic="sk-ant-test", openrouter="or-test") + assert s.DEFAULT_PROVIDER == LLMProvider.OPENROUTER + + def test_neither_key_set_defaults_to_openrouter(self): + with pytest.MonkeyPatch.context() as mp: + s = self._settings(mp, anthropic=None, openrouter=None) + assert s.DEFAULT_PROVIDER == LLMProvider.OPENROUTER + + def test_explicit_default_provider_wins_over_inference(self): + """An explicit DEFAULT_PROVIDER always overrides key-based inference.""" + from app.core.config import Settings + + with pytest.MonkeyPatch.context() as mp: + mp.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + mp.delenv("OPENROUTER_API_KEY", raising=False) + mp.setenv("DEFAULT_PROVIDER", "openrouter") + s = Settings() + assert s.DEFAULT_PROVIDER == LLMProvider.OPENROUTER + + def test_blank_default_provider_still_infers(self): + """Mirrors docker-compose's ${DEFAULT_PROVIDER:-} passing an empty string + rather than omitting the var entirely — must be treated as unset.""" + from app.core.config import Settings + + with pytest.MonkeyPatch.context() as mp: + mp.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + mp.delenv("OPENROUTER_API_KEY", raising=False) + mp.setenv("DEFAULT_PROVIDER", "") + s = Settings() + assert s.DEFAULT_PROVIDER == LLMProvider.ANTHROPIC diff --git a/backend/test/services/test_startup_validation.py b/backend/test/services/test_startup_validation.py index ee6a540..7c11534 100644 --- a/backend/test/services/test_startup_validation.py +++ b/backend/test/services/test_startup_validation.py @@ -323,6 +323,29 @@ async def test_anthropic_only_passes(self, validator): result = await validator._check_environment() assert result["passed"] is True + @pytest.mark.asyncio + async def test_anthropic_only_passes_without_explicit_default_provider(self, validator): + """Regression: switching to Anthropic by only setting ANTHROPIC_API_KEY + (leaving DEFAULT_PROVIDER unset, as .env.quickstart.example documents) + must pass without also requiring DEFAULT_PROVIDER=anthropic. + + Previously this failed because DEFAULT_PROVIDER defaulted to openrouter + unless a one-time init script had persisted the override — so switching + keys by hand later silently broke startup validation. + """ + from app.core.config import Settings + from unittest.mock import patch as mpatch + + with patch.dict(os.environ, { + "ANTHROPIC_API_KEY": "sk-ant-key", + "DATABASE_TYPE": "memory", + }, clear=True): + anthropic_settings = Settings() + assert anthropic_settings.DEFAULT_PROVIDER == "anthropic" + with mpatch("app.services.startup_validation.settings", anthropic_settings): + result = await validator._check_environment() + assert result["passed"] is True + @pytest.mark.asyncio async def test_both_provider_keys_missing_fails_with_provider_name(self, validator): """When no provider key set, message names both the variable and the active provider (FR-3).""" diff --git a/docker-compose.yml b/docker-compose.yml index 4e917f1..4e903ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,7 +85,10 @@ services: # Common environment variables - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - - DEFAULT_PROVIDER=${DEFAULT_PROVIDER:-openrouter} + # Blank (not "openrouter") when unset: the backend infers the provider from + # whichever API key is set (Settings._infer_default_provider_from_keys). A + # hardcoded default here would mask that inference and always force openrouter. + - DEFAULT_PROVIDER=${DEFAULT_PROVIDER:-} - LOG_LEVEL=${LOG_LEVEL:-DEBUG} - WORKSPACE_BASE_PATH=/workspaces - NOTIFY_EMAIL_USERNAME=${NOTIFY_EMAIL_USERNAME}