Skip to content
Open
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
38 changes: 38 additions & 0 deletions apps/api/src/sibyl/api/routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from __future__ import annotations

import os

import httpx
import structlog
from fastapi import APIRouter, Depends, HTTPException
Expand All @@ -25,6 +27,21 @@
_ADMIN_ROLES = (OrganizationRole.OWNER, OrganizationRole.ADMIN)


async def _try_reset_graph_client(context: str) -> None:
"""Reset the global GraphClient, logging on failure.

Args:
context: Description for log message (e.g., "API key update", "API key deletion")
"""
try:
from sibyl_core.graph.client import reset_graph_client

await reset_graph_client()
log.info(f"Reset GraphClient after {context}")
except Exception as e:
log.warning("Failed to reset GraphClient", error=str(e))


class SettingInfo(BaseModel):
"""Information about a single setting."""

Expand Down Expand Up @@ -192,6 +209,10 @@ async def update_settings(
description="OpenAI API key for embeddings and LLM operations",
)
updated.append("openai_api_key")
# Update environment variable so running server uses new key immediately
# This bridges webapp settings to GraphClient which reads from env vars
os.environ["OPENAI_API_KEY"] = request.openai_api_key
log.info("Updated OpenAI API key in environment")
else:
log.warning("OpenAI key validation failed", error=error)

Expand All @@ -208,9 +229,17 @@ async def update_settings(
description="Anthropic API key for Claude models",
)
updated.append("anthropic_api_key")
# Update environment variable so running server uses new key immediately
os.environ["ANTHROPIC_API_KEY"] = request.anthropic_api_key
log.info("Updated Anthropic API key in environment")
else:
log.warning("Anthropic key validation failed", error=error)

# If API keys were updated, reset the GraphClient so it reconnects with new keys
# The global singleton is reused, so existing connections would use stale keys
if updated:
await _try_reset_graph_client(f"API key update keys={updated}")

return UpdateSettingsResponse(updated=updated, validation=validation)


Expand Down Expand Up @@ -239,6 +268,15 @@ async def delete_setting(
deleted = await service.delete(key)

if deleted:
# Clear from environment and reset GraphClient if this was an API key
if key in ("openai_api_key", "anthropic_api_key"):
env_key = "OPENAI_API_KEY" if key == "openai_api_key" else "ANTHROPIC_API_KEY"
# Note: This clears the env var even if it was externally set. Since webapp users
# typically configure keys via UI (not external env), this is the expected behavior.
# If external env vars need to be preserved, track DB-loaded keys at startup.
os.environ.pop(env_key, None)
await _try_reset_graph_client(f"API key deletion key={key}")

return DeleteSettingResponse(
deleted=True,
key=key,
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/sibyl/jobs/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ async def startup(ctx: dict[str, Any]) -> None:
log.info("Job worker online")
ctx["start_time"] = datetime.now(UTC)

# Load API keys from database into environment BEFORE any jobs use GraphClient
# This bridges the gap between webapp-configured settings (stored in DB)
# and CoreConfig (which reads from env vars at import time)
from sibyl.services.settings import load_api_keys_from_db

await load_api_keys_from_db()
Comment on lines +182 to +187
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, examine the worker.py file around lines 182-187 and the cleanup helpers
cat -n apps/api/src/sibyl/jobs/worker.py | sed -n '170,200p'

Repository: hyperb1iss/sibyl

Length of output: 1469


🏁 Script executed:

# Search for the cleanup helpers to see if they have try/except
rg -n '_cleanup_stale_working_agents|_cleanup_orphaned_agent_jobs' --type=py -A 10 -B 2 apps/api/src/sibyl/jobs/worker.py

Repository: hyperb1iss/sibyl

Length of output: 1682


🏁 Script executed:

# Find how main server startup handles load_api_keys_from_db
rg -n 'load_api_keys_from_db' --type=py -g '!worker.py' -B 5 -A 5

Repository: hyperb1iss/sibyl

Length of output: 1835


🏁 Script executed:

# Check the implementation of load_api_keys_from_db to understand what exceptions it might raise
fd 'settings.py' --type f | xargs grep -l 'load_api_keys_from_db' | head -1 | xargs cat -n | head -100

Repository: hyperb1iss/sibyl

Length of output: 3642


🏁 Script executed:

# Get full implementation of _cleanup_stale_working_agents
sed -n '44,105p' apps/api/src/sibyl/jobs/worker.py

Repository: hyperb1iss/sibyl

Length of output: 2355


🏁 Script executed:

# Get full implementation of _cleanup_orphaned_agent_jobs
sed -n '107,150p' apps/api/src/sibyl/jobs/worker.py

Repository: hyperb1iss/sibyl

Length of output: 1724


🏁 Script executed:

# Get full context of main.py around the db_connected logic
sed -n '100,135p' apps/api/src/sibyl/main.py

Repository: hyperb1iss/sibyl

Length of output: 1438


🏁 Script executed:

# Get full implementation of load_api_keys_from_db
sed -n '355,390p' apps/api/src/sibyl/services/settings.py

Repository: hyperb1iss/sibyl

Length of output: 1041


Wrap load_api_keys_from_db() in a try/except to match the resilience of other startup steps.

The cleanup helpers (_cleanup_stale_working_agents, _cleanup_orphaned_agent_jobs) each have try/except blocks that allow the worker to start regardless of failures. Additionally, the main server startup guards this call with if db_connected: before invoking it. The worker startup currently has neither guard nor exception handling—if the DB is unreachable when load_api_keys_from_db() runs (e.g., get_settings_service() raises), the worker will crash. Either add a try/except block matching the cleanup helper pattern, or add the if db_connected: guard as used in the main startup.

🤖 Prompt for AI Agents
In `@apps/api/src/sibyl/jobs/worker.py` around lines 182 - 187, The call to
load_api_keys_from_db() can raise when the DB is unreachable and must be made
resilient like the other startup steps; wrap the await load_api_keys_from_db()
invocation in a try/except that logs errors (mirroring the pattern used in
_cleanup_stale_working_agents and _cleanup_orphaned_agent_jobs) so the worker
still starts on failure, or alternatively guard the call with the same
db_connected check used by main startup before calling load_api_keys_from_db();
ensure you reference load_api_keys_from_db, the cleanup helpers, and
db_connected in the implementation so the behavior matches existing startup
resilience.


# Clean up stale working agents (from worker crashes)
stale_marked = await _cleanup_stale_working_agents()
if stale_marked:
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/sibyl/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ async def lifespan(_app: Starlette) -> "AsyncGenerator[None]": # noqa: PLR0915
except Exception as e:
log.warning("Source recovery failed", error=str(e))

# Load API keys from database into environment BEFORE GraphClient initializes
# This bridges the gap between webapp-configured settings (stored in DB)
# and CoreConfig (which reads from env vars at import time)
if db_connected:
from sibyl.services.settings import load_api_keys_from_db

await load_api_keys_from_db()

try:
from sibyl_core.graph.client import get_graph_client

Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/sibyl/services/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,32 @@ def reset_settings_service() -> None:
"""Reset the global settings service (for testing)."""
global _settings_service # noqa: PLW0603
_settings_service = None


async def load_api_keys_from_db() -> list[str]:
"""Load API keys from database into environment variables.

Only loads keys that are not already set in the environment.
This should be called at startup before GraphClient is initialized.

Returns:
List of keys that were loaded from the database.
"""
loaded: list[str] = []
settings_svc = get_settings_service()

for setting_key, env_var in [
("openai_api_key", "OPENAI_API_KEY"),
("anthropic_api_key", "ANTHROPIC_API_KEY"),
]:
try:
if not os.environ.get(env_var):
key = await settings_svc.get(setting_key)
if key:
os.environ[env_var] = key
loaded.append(setting_key)
log.debug(f"Loaded {setting_key} from database settings")
except Exception as e:
log.warning(f"Failed to load {setting_key} from database", error=str(e))

return loaded
191 changes: 191 additions & 0 deletions apps/api/tests/test_settings_api_key_loading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""Tests for API key loading from database at startup.

These tests verify the fix for the issue where API keys configured via the webapp
were not available to GraphClient at startup because it reads from environment
variables at import time.
"""

import os
from unittest.mock import AsyncMock, MagicMock, patch

import pytest


class TestApiKeyLoadingAtStartup:
"""Tests for API key loading during server/worker startup."""

@pytest.mark.asyncio
async def test_api_key_loaded_from_db_when_env_not_set(self, monkeypatch) -> None:
"""Verify API keys are loaded from DB into os.environ when env vars are not set."""
# Clear any existing env vars
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

# Mock the settings service
mock_settings_service = AsyncMock()
mock_settings_service.get_openai_key = AsyncMock(return_value="sk-test-openai-key")
mock_settings_service.get_anthropic_key = AsyncMock(return_value="sk-ant-test-key")

with patch(
"sibyl.services.settings.get_settings_service", return_value=mock_settings_service
):
# Simulate the key loading logic from main.py
if not os.environ.get("OPENAI_API_KEY"):
openai_key = await mock_settings_service.get_openai_key()
if openai_key:
os.environ["OPENAI_API_KEY"] = openai_key

if not os.environ.get("ANTHROPIC_API_KEY"):
anthropic_key = await mock_settings_service.get_anthropic_key()
if anthropic_key:
os.environ["ANTHROPIC_API_KEY"] = anthropic_key

assert os.environ.get("OPENAI_API_KEY") == "sk-test-openai-key"
assert os.environ.get("ANTHROPIC_API_KEY") == "sk-ant-test-key"

# Cleanup
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

@pytest.mark.asyncio
async def test_env_var_takes_precedence_over_db(self, monkeypatch) -> None:
"""Verify existing env vars are not overwritten by DB values."""
# Set existing env vars
monkeypatch.setenv("OPENAI_API_KEY", "sk-existing-env-key")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-existing-env-key")

# Mock the settings service with different values
mock_settings_service = AsyncMock()
mock_settings_service.get_openai_key = AsyncMock(return_value="sk-db-openai-key")
mock_settings_service.get_anthropic_key = AsyncMock(return_value="sk-ant-db-key")

with patch(
"sibyl.services.settings.get_settings_service", return_value=mock_settings_service
):
# Simulate the key loading logic - should NOT overwrite
if not os.environ.get("OPENAI_API_KEY"):
openai_key = await mock_settings_service.get_openai_key()
if openai_key:
os.environ["OPENAI_API_KEY"] = openai_key

if not os.environ.get("ANTHROPIC_API_KEY"):
anthropic_key = await mock_settings_service.get_anthropic_key()
if anthropic_key:
os.environ["ANTHROPIC_API_KEY"] = anthropic_key

# Verify env vars were NOT overwritten
assert os.environ.get("OPENAI_API_KEY") == "sk-existing-env-key"
assert os.environ.get("ANTHROPIC_API_KEY") == "sk-ant-existing-env-key"

# Verify DB was not even queried (optimization)
mock_settings_service.get_openai_key.assert_not_called()
mock_settings_service.get_anthropic_key.assert_not_called()

@pytest.mark.asyncio
async def test_api_key_loading_failure_does_not_crash(self, monkeypatch) -> None:
"""Ensure startup continues gracefully if DB query fails."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

# Mock settings service that raises an exception
mock_settings_service = AsyncMock()
mock_settings_service.get_openai_key = AsyncMock(
side_effect=Exception("Database connection failed")
)

error_logged = False

with patch(
"sibyl.services.settings.get_settings_service", return_value=mock_settings_service
):
# Simulate the try/except from main.py
try:
if not os.environ.get("OPENAI_API_KEY"):
openai_key = await mock_settings_service.get_openai_key()
if openai_key:
os.environ["OPENAI_API_KEY"] = openai_key
except Exception:
error_logged = True # In real code, this logs a warning

# Should have caught the exception and continued
assert error_logged is True
# Env var should still be unset
assert os.environ.get("OPENAI_API_KEY") is None

@pytest.mark.asyncio
async def test_partial_key_loading(self, monkeypatch) -> None:
"""Verify partial key loading works (only one key in DB)."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

# Mock settings service with only OpenAI key configured
mock_settings_service = AsyncMock()
mock_settings_service.get_openai_key = AsyncMock(return_value="sk-test-openai-key")
mock_settings_service.get_anthropic_key = AsyncMock(return_value=None)

with patch(
"sibyl.services.settings.get_settings_service", return_value=mock_settings_service
):
if not os.environ.get("OPENAI_API_KEY"):
openai_key = await mock_settings_service.get_openai_key()
if openai_key:
os.environ["OPENAI_API_KEY"] = openai_key

if not os.environ.get("ANTHROPIC_API_KEY"):
anthropic_key = await mock_settings_service.get_anthropic_key()
if anthropic_key:
os.environ["ANTHROPIC_API_KEY"] = anthropic_key

# Only OpenAI key should be set
assert os.environ.get("OPENAI_API_KEY") == "sk-test-openai-key"
assert os.environ.get("ANTHROPIC_API_KEY") is None

# Cleanup
monkeypatch.delenv("OPENAI_API_KEY", raising=False)


class TestSettingsHotReload:
"""Tests for hot-reloading API keys when updated via webapp."""

@pytest.mark.asyncio
async def test_update_settings_updates_env_var(self, monkeypatch) -> None:
"""Verify updating settings also updates os.environ."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)

# Simulate the logic from settings.py update endpoint
new_key = "sk-new-openai-key"
os.environ["OPENAI_API_KEY"] = new_key

assert os.environ.get("OPENAI_API_KEY") == new_key

# Cleanup
monkeypatch.delenv("OPENAI_API_KEY", raising=False)

@pytest.mark.asyncio
async def test_update_settings_resets_graph_client(self) -> None:
"""Verify GraphClient is reset after API key update."""
reset_called = False

async def mock_reset_graph_client():
nonlocal reset_called
reset_called = True

with patch(
"sibyl_core.graph.client.reset_graph_client", side_effect=mock_reset_graph_client
):
# Simulate the reset call from settings.py
from sibyl_core.graph.client import reset_graph_client

await reset_graph_client()

assert reset_called is True

@pytest.mark.asyncio
async def test_delete_setting_clears_env_var(self, monkeypatch) -> None:
"""Verify deleting a setting clears it from os.environ."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-to-be-deleted")

# Simulate the logic from delete_setting endpoint
os.environ.pop("OPENAI_API_KEY", None)

assert os.environ.get("OPENAI_API_KEY") is None
Loading