agnes-the-ai-analyst/tests/test_llm_provider_env_fallback.py
ZdenekSrotyr bbb04ac041 fix(setup): seed default ai: block + env-var fallback (#176)
POST /api/admin/configure now writes a default ai: block into the
instance.yaml overlay when the request leaves it untouched and either
ANTHROPIC_API_KEY or LLM_API_KEY is set in the environment. The block
references the env var via ${VAR} syntax — secrets never land in YAML.

connectors.llm.factory grows create_extractor_from_env_or_config which
falls back to ANTHROPIC_API_KEY / LLM_API_KEY when ai_config is empty
and raises a clear ValueError when neither is available. Both
services/corporate_memory and services/verification_detector switch to
the new helper, replacing the old 'silently skip when ai: missing'
path that was the silent-failure root cause.

Tests:
- tests/test_setup_ai_block.py — overlay seeding contract.
- tests/test_llm_provider_env_fallback.py — fallback + fail-fast.
2026-05-04 23:55:19 +02:00

80 lines
3.5 KiB
Python

"""LLM provider env-var fallback + fail-fast behavior.
When no ai: block is configured, `connectors.llm.factory.create_extractor`
must:
1. Build an extractor from `ANTHROPIC_API_KEY` / `LLM_API_KEY` if either
env var is set (so an operator who only edited .env still gets a
working pipeline).
2. Fail fast with a clear error if neither config nor env is available.
No silent skip.
Closes one of five defects in #176.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from connectors.llm.factory import create_extractor, create_extractor_from_env_or_config
class TestEnvFallback:
"""Mocks the AnthropicExtractor constructor so the tests don't try to
open a live SDK client (which loads system SSL certs at __init__ time
and is unhappy on machines with corporate CA-bundle env vars pointing
at a non-existent file). The test surface is the factory routing logic,
not the SDK wiring — that's covered by tests/test_llm_providers_full.py.
"""
def test_anthropic_env_fallback_builds_extractor(self, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-from-env-aaa")
monkeypatch.delenv("LLM_API_KEY", raising=False)
with patch("connectors.llm.factory.AnthropicExtractor") as mock_cls:
ex = create_extractor_from_env_or_config(ai_config=None)
assert ex is mock_cls.return_value
kwargs = mock_cls.call_args.kwargs
assert kwargs["api_key"] == "sk-ant-from-env-aaa"
def test_llm_api_key_env_fallback_builds_extractor(self, monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("LLM_API_KEY", "sk-proxy-from-env-bbb")
with patch("connectors.llm.factory.AnthropicExtractor") as mock_cls:
ex = create_extractor_from_env_or_config(ai_config=None)
assert ex is mock_cls.return_value
kwargs = mock_cls.call_args.kwargs
assert kwargs["api_key"] == "sk-proxy-from-env-bbb"
def test_no_config_no_env_fails_fast(self, monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("LLM_API_KEY", raising=False)
with pytest.raises(ValueError) as excinfo:
create_extractor_from_env_or_config(ai_config=None)
msg = str(excinfo.value)
# Error must mention BOTH config + env paths so operators know how to fix it.
assert "instance.yaml" in msg or "ai:" in msg
assert "ANTHROPIC_API_KEY" in msg
def test_explicit_ai_config_wins_over_env(self, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "ignored-because-config-wins")
ai_cfg = {
"provider": "anthropic",
"api_key": "sk-ant-from-cfg-zzz",
"model": "claude-haiku-4-5-20251001",
}
with patch("connectors.llm.factory.AnthropicExtractor") as mock_cls:
ex = create_extractor_from_env_or_config(ai_config=ai_cfg)
assert ex is mock_cls.return_value
kwargs = mock_cls.call_args.kwargs
assert kwargs["api_key"] == "sk-ant-from-cfg-zzz"
def test_empty_dict_falls_through_to_env(self, monkeypatch):
"""ai: {} is treated the same as no block — fall through to env vars."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-from-env-ccc")
with patch("connectors.llm.factory.AnthropicExtractor") as mock_cls:
ex = create_extractor_from_env_or_config(ai_config={})
assert ex is mock_cls.return_value
kwargs = mock_cls.call_args.kwargs
assert kwargs["api_key"] == "sk-ant-from-env-ccc"