Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +238 to +241

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how is this hooked to the DEFAULT_PROVIDER field?? I dont see direct connection


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.
Comment on lines +243 to +248

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic will be buried here - better to remove this docstring and just explain this in Settings class as it is now. And make sure README / QUICKSTART have same wording

"""
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)
Expand Down
73 changes: 72 additions & 1 deletion backend/test/core/test_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
23 changes: 23 additions & 0 deletions backend/test/services/test_startup_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down