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.
80 lines
3.5 KiB
Python
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"
|