From 585c7185d5d631314096f3343be68f4f46cda4e7 Mon Sep 17 00:00:00 2001 From: angel Date: Sun, 14 Jun 2026 19:02:59 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20fail-fast=20on=20missing=20AI=20API=20ke?= =?UTF-8?q?y=20=E2=80=94=20prevent=20silent=20credential=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentEngine.run() raises ValueError when ai_api_key is None/empty - build_deepseek_client() also validates key before constructing client - Doctor check updated: flags missing key as warning for agent mode - 4 new tests verify error is raised before any LLM call Closes #33 --- src/adapters/ai_analyst.py | 5 ++++ src/cli/doctor.py | 6 +++- src/core/services/agent_engine.py | 6 ++++ tests/test_agent_engine.py | 47 +++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/adapters/ai_analyst.py b/src/adapters/ai_analyst.py index 138d6f0..121f241 100644 --- a/src/adapters/ai_analyst.py +++ b/src/adapters/ai_analyst.py @@ -53,6 +53,11 @@ class _FallbackOpenAIError(Exception): def build_deepseek_client(*, api_key: str, base_url: str) -> AsyncOpenAI: + if not api_key: + raise ValueError( + "AI API key is empty. " + "Set OSINT_D2_AI_API_KEY via environment variable, .env file, or run: osint-d2 doctor setup-ai" + ) return AsyncOpenAI(api_key=api_key, base_url=base_url) diff --git a/src/cli/doctor.py b/src/cli/doctor.py index da375df..e856a8d 100644 --- a/src/cli/doctor.py +++ b/src/cli/doctor.py @@ -60,7 +60,11 @@ def run() -> None: if bool(settings.ai_api_key): table.add_row("AI key", "OK", "Remote AI enabled") else: - table.add_row("AI key", "OPTIONAL", "No key set -> heuristic analysis fallback") + table.add_row( + "AI key", + "[yellow]MISSING[/yellow]", + "No key → heuristic fallback. Agent mode (--agent) will fail.", + ) table.add_row("AI base_url", "OK", settings.ai_base_url) table.add_row("AI model", "OK", settings.ai_model) diff --git a/src/core/services/agent_engine.py b/src/core/services/agent_engine.py index 6278d9e..5ae0c78 100644 --- a/src/core/services/agent_engine.py +++ b/src/core/services/agent_engine.py @@ -124,6 +124,12 @@ async def run( ) -> AgentResult: """Run the agent loop until it calls generate_report or exhausts steps.""" + if not self._settings.ai_api_key: + raise ValueError( + "OSINT_D2_AI_API_KEY is not configured. " + "Set it via environment variable, .env file, or run: osint-d2 doctor setup-ai" + ) + client = AsyncOpenAI( api_key=self._settings.ai_api_key, base_url=self._settings.ai_base_url, diff --git a/tests/test_agent_engine.py b/tests/test_agent_engine.py index 892dc4e..3bd53b1 100644 --- a/tests/test_agent_engine.py +++ b/tests/test_agent_engine.py @@ -258,3 +258,50 @@ def test_callback_receives_step(self): engine._on_step(step) # type: ignore[misc] assert len(captured) == 1 assert captured[0].tool_name == "scan_username" + + +# --------------------------------------------------------------------------- +# API key validation (issue #33) +# --------------------------------------------------------------------------- + +class TestApiKeyValidation: + """Verify fail-fast when ai_api_key is None — prevents silent SDK fallback + to OPENAI_API_KEY env var which would misdirect credentials.""" + + @pytest.mark.asyncio + async def test_agent_engine_raises_without_api_key(self): + """AgentEngine.run() must raise ValueError before any LLM call.""" + settings = AppSettings( + ai_api_key=None, + ai_base_url="https://api.deepseek.com", + ) + engine = AgentEngine(settings=settings) + + with pytest.raises(ValueError, match="OSINT_D2_AI_API_KEY"): + await engine.run("investigate testuser") + + @pytest.mark.asyncio + async def test_agent_engine_raises_with_empty_string_key(self): + """Empty string should also be caught.""" + settings = AppSettings( + ai_api_key="", + ai_base_url="https://api.deepseek.com", + ) + engine = AgentEngine(settings=settings) + + with pytest.raises(ValueError, match="OSINT_D2_AI_API_KEY"): + await engine.run("investigate testuser") + + def test_build_deepseek_client_raises_on_empty_key(self): + """build_deepseek_client must fail-fast on empty key.""" + from adapters.ai_analyst import build_deepseek_client + + with pytest.raises(ValueError, match="AI API key is empty"): + build_deepseek_client(api_key="", base_url="https://api.deepseek.com") + + def test_build_deepseek_client_works_with_valid_key(self): + """Valid key should construct the client without error.""" + from adapters.ai_analyst import build_deepseek_client + + client = build_deepseek_client(api_key="sk-test-key", base_url="https://api.deepseek.com") + assert client is not None